diff --git a/pyproject.toml b/pyproject.toml index d12cb7485e..3cb0780220 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -144,6 +144,7 @@ dependencies = [ "typing-extensions>=4.13.0", "typing-inspection>=0.4.1", "opentelemetry-api>=1.28.0", + "griffe>=1.0.0", ] [project.urls] diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index a4b7f4873e..ff5baad9fc 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -36,9 +36,60 @@ from mcp.server.mcpserver.utilities.logging import get_logger from mcp.server.mcpserver.utilities.types import Audio, Image +import logging as _logging + +import griffe +from griffe import DocstringSectionParameters + +# Suppress griffe's "No type or annotation" warnings — we parse descriptions, not types +_logging.getLogger("griffe").setLevel(_logging.ERROR) + logger = get_logger(__name__) +def _parse_docstring_params(func: Callable[..., Any]) -> dict[str, str]: + """Parse parameter descriptions from a function's docstring. + + Supports Google, NumPy, and Sphinx docstring styles via griffe. + Tries all styles and returns the one that yields the most parameter descriptions. + + Returns: + A dict mapping parameter names to their descriptions. + """ + docstring = func.__doc__ + if not docstring: + return {} + + docstring_obj = griffe.Docstring(docstring) + best: dict[str, str] = {} + + for parser in (griffe.parse_google, griffe.parse_numpy, griffe.parse_sphinx): + try: + parsed = parser(docstring_obj) + found: dict[str, str] = {} + for section in parsed: + if isinstance(section, DocstringSectionParameters): + for param in section.value: + if param.description: + found[param.name] = param.description + if len(found) > len(best): + best = found + except Exception: + continue + + return best + + +def _has_field_description(annotation: Any) -> bool: + """Check if a type annotation already contains a Pydantic Field with a description.""" + if get_origin(annotation) is Annotated: + args = get_args(annotation) + for arg in args[1:]: + if isinstance(arg, FieldInfo) and arg.description is not None: + return True + return False + + def _is_input_required_type(obj: Any) -> bool: return isinstance(obj, type) and issubclass(obj, InputRequiredResult) @@ -293,6 +344,7 @@ def func_metadata( # model_rebuild right before using it 🤷 raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e params = sig.parameters + param_descriptions = _parse_docstring_params(func) dynamic_pydantic_model_params: dict[str, Any] = {} for param in params.values(): if param.name.startswith("_"): # pragma: no cover @@ -303,6 +355,9 @@ def func_metadata( annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any field_name = param.name field_kwargs: dict[str, Any] = {} + # Only add docstring description if the annotation doesn't already have a Field description + if param.name in param_descriptions and not _has_field_description(annotation): + field_kwargs["description"] = param_descriptions[param.name] field_metadata: list[Any] = [] if param.annotation is inspect.Parameter.empty: diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index eff3479279..aa8659d741 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1458,3 +1458,162 @@ def fn() -> StepA | StepB: ... # pragma: no branch meta = func_metadata(fn) assert meta.output_schema is None + + +# Tests for docstring → JSON Schema description propagation (issue #226) + + +def test_google_style_docstring_descriptions(): + """Test that Google-style docstrings are parsed and descriptions added to schema.""" + + def func_google_style(name: str, age: int, verbose: bool = False) -> str: + """A function with Google-style docstring. + + Args: + name: The person's full name. + age: Age in years. + verbose: Whether to print verbose output. + + Returns: + A greeting string. + """ + return f"Hello {name}, you are {age}" + + meta = func_metadata(func_google_style) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert schema["properties"]["name"]["description"] == "The person's full name." + assert schema["properties"]["age"]["description"] == "Age in years." + assert schema["properties"]["verbose"]["description"] == "Whether to print verbose output." + + +def test_numpy_style_docstring_descriptions(): + """Test that NumPy-style docstrings are parsed and descriptions added to schema.""" + + def func_numpy_style(filename: str, encoding: str = "utf-8") -> str: + """A function with NumPy-style docstring. + + Parameters + ---------- + filename : str + Path to the file to read. + encoding : str, optional + File encoding. Defaults to utf-8. + + Returns + ------- + str + File contents. + """ + return f"Reading {filename}" + + meta = func_metadata(func_numpy_style) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert schema["properties"]["filename"]["description"] == "Path to the file to read." + assert schema["properties"]["encoding"]["description"] == "File encoding. Defaults to utf-8." + + +def test_sphinx_style_docstring_descriptions(): + """Test that Sphinx-style docstrings are parsed and descriptions added to schema.""" + + def func_sphinx_style(url: str, timeout: int = 30) -> str: + """A function with Sphinx-style docstring. + + :param url: The URL to fetch. + :param timeout: Request timeout in seconds. + :returns: Response text. + """ + return f"Fetching {url}" + + meta = func_metadata(func_sphinx_style) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert schema["properties"]["url"]["description"] == "The URL to fetch." + assert schema["properties"]["timeout"]["description"] == "Request timeout in seconds." + + +def test_no_docstring(): + """Test that functions without docstrings still work correctly.""" + + def func_no_doc(x: int, y: str) -> str: # pragma: no cover + return f"{x}: {y}" + + meta = func_metadata(func_no_doc) + schema = meta.arg_model.model_json_schema(by_alias=True) + + # No description should be added + assert "description" not in schema["properties"]["x"] + assert "description" not in schema["properties"]["y"] + + +def test_docstring_no_args_section(): + """Test docstrings without an Args section don't add descriptions.""" + + def func_no_args_section(x: int) -> str: + """Just a summary, no args section.""" + return str(x) + + meta = func_metadata(func_no_args_section) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert "description" not in schema["properties"]["x"] + + +def test_docstring_partial_args(): + """Test that only documented parameters get descriptions.""" + + def func_partial(a: int, b: str, c: float) -> str: + """Function with partial docstring. + + Args: + a: First parameter. + c: Third parameter. + """ + return f"{a}{b}{c}" + + meta = func_metadata(func_partial) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert schema["properties"]["a"]["description"] == "First parameter." + assert "description" not in schema["properties"]["b"] + assert schema["properties"]["c"]["description"] == "Third parameter." + + +def test_docstring_with_skip_names(): + """Test that docstring parsing works correctly with skip_names.""" + + def func_skip(name: str, secret: str, verbose: bool = False) -> str: + """Function with skip. + + Args: + name: User name. + secret: Secret to skip. + verbose: Be verbose. + """ + return name + + meta = func_metadata(func_skip, skip_names=["secret"]) + schema = meta.arg_model.model_json_schema(by_alias=True) + + assert "secret" not in schema["properties"] + assert schema["properties"]["name"]["description"] == "User name." + assert schema["properties"]["verbose"]["description"] == "Be verbose." + + +def test_field_description_preserved_over_docstring(): + """Test that Annotated Field descriptions take precedence over docstring descriptions.""" + + def func_field_priority(name: Annotated[str, Field(description="Field description")]) -> str: + """Function. + + Args: + name: Docstring description. + """ + return name + + meta = func_metadata(func_field_priority) + schema = meta.arg_model.model_json_schema(by_alias=True) + + # Field description should be preserved (Pydantic uses it directly) + assert schema["properties"]["name"]["description"] == "Field description" diff --git a/uv.lock b/uv.lock index a391152f0e..85a9df0a05 100644 --- a/uv.lock +++ b/uv.lock @@ -555,7 +555,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -613,6 +613,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" }, ] +[[package]] +name = "griffe" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffecli" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/44/63913c007814cab5ba9d36f25ad40dfc640c2e2931d195bd2d05f774a5d6/griffe-2.1.0.tar.gz", hash = "sha256:c58845df5a364feaabd05ee8c767b97b03e478da8aa18b9923553c812fb0d955", size = 244879, upload-time = "2026-06-19T12:05:41.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/fb/3c65d392feae6c36dc2b55a14dd8270b7b35a3171c93b71a4d2ee4abf241/griffe-2.1.0-py3-none-any.whl", hash = "sha256:2ccdab17fb9cd76f278d7b5611cfc8f68cbe846d8d48df63dff80b62ecfa6f65", size = 5140, upload-time = "2026-06-19T12:05:39.913Z" }, +] + +[[package]] +name = "griffecli" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "griffelib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/c6/90f85d47af96300d629b38c25b71aad9467a620cac964a39280e822efc8a/griffecli-2.1.0.tar.gz", hash = "sha256:2ff68dbee9395fdb668b10374c51683392d697b226ac60159798f4add1ee716c", size = 56913, upload-time = "2026-06-19T12:05:43.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/2f/513232ec1d5f5da182e4ce45a11427e37dd210844a9e2bca451fc9661fb3/griffecli-2.1.0-py3-none-any.whl", hash = "sha256:6e22b1423d562ddc510997b4be1fe89de59e19dcff78831c0f4bfc3b8134a718", size = 9500, upload-time = "2026-06-19T12:05:37.517Z" }, +] + [[package]] name = "griffelib" version = "2.1.0" @@ -997,6 +1023,7 @@ name = "mcp" source = { editable = "." } dependencies = [ { name = "anyio" }, + { name = "griffe" }, { name = "httpx2" }, { name = "jsonschema" }, { name = "mcp-types" }, @@ -1061,6 +1088,7 @@ translate = [ requires-dist = [ { name = "anyio", marker = "python_full_version < '3.14'", specifier = ">=4.9" }, { name = "anyio", marker = "python_full_version >= '3.14'", specifier = ">=4.10" }, + { name = "griffe", specifier = ">=1.0.0" }, { name = "httpx2", specifier = ">=2.5.0" }, { name = "jsonschema", specifier = ">=4.20.0" }, { name = "mcp-types", editable = "src/mcp-types" },