diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 062b47d0f..28ba6f0eb 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -1,6 +1,7 @@ import functools import inspect import json +import re from collections.abc import Awaitable, Callable, Sequence from itertools import chain from types import GenericAlias @@ -167,6 +168,74 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]: ) +# Regex patterns for extracting parameter descriptions from docstrings. +# Supports Google, NumPy, and Sphinx styles without any external dependencies. +_GOOGLE_ARGS_RE = re.compile( + r"(?:Args|Arguments|Parameters)\s*:\s*\n((?:[ \t]+.+\n?)+)", + re.IGNORECASE, +) +_GOOGLE_PARAM_RE = re.compile( + r"^[ \t]+(\w+)\s*(?:\(.+?\))?\s*:\s*(.+(?:\n(?:[ \t]+(?![ \t]*\w+\s*(?:\(.+?\))?\s*:).+))*)", + re.MULTILINE, +) +_SPHINX_PARAM_RE = re.compile( + r":param\s+(\w+)\s*:\s*(.+(?:\n(?:[ \t]+(?!:).+))*)", + re.MULTILINE, +) +_NUMPY_PARAMS_RE = re.compile( + r"(?:Parameters)\s*\n\s*-{3,}\s*\n((?:.*\n?)+?)(?:\n\s*\w+\s*\n\s*-{3,}|\Z)", + re.IGNORECASE, +) +_NUMPY_PARAM_RE = re.compile( + r"^(\w+)\s*(?::.*)?$\n((?:[ \t]+.+\n?)+)", + re.MULTILINE, +) + + +def _parse_docstring_params(func: Callable[..., Any]) -> dict[str, str]: + """Parse a function's docstring to extract parameter descriptions. + + Supports Google, NumPy, and Sphinx-style docstrings using simple regex patterns. + No external dependencies required. + + Returns: + A dict mapping parameter names to their descriptions. + """ + doc = func.__doc__ + if not doc: + return {} + + # Try Sphinx style first (:param name: description) + sphinx_matches = _SPHINX_PARAM_RE.findall(doc) + if sphinx_matches: + return {name: " ".join(desc.split()) for name, desc in sphinx_matches} + + # Try Google style (Args: / Arguments: / Parameters:) + google_section = _GOOGLE_ARGS_RE.search(doc) + if google_section: + params = _GOOGLE_PARAM_RE.findall(google_section.group(1)) + if params: + return {name: " ".join(desc.split()) for name, desc in params} + + # Try NumPy style (Parameters\n----------) + numpy_section = _NUMPY_PARAMS_RE.search(doc) + if numpy_section: + params = _NUMPY_PARAM_RE.findall(numpy_section.group(1)) + if params: + return {name: " ".join(desc.split()) for name, desc in params} + + return {} + + +def _annotation_has_description(annotation: Any) -> bool: + """Check if an Annotated type already includes a Field with a description.""" + if get_origin(annotation) is Annotated: + for arg in get_args(annotation)[1:]: + if isinstance(arg, FieldInfo) and arg.description is not None: + return True + return False + + def func_metadata( func: Callable[..., Any], skip_names: Sequence[str] = (), @@ -215,6 +284,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 + docstring_descriptions = _parse_docstring_params(func) dynamic_pydantic_model_params: dict[str, Any] = {} for param in params.values(): if param.name.startswith("_"): # pragma: no cover @@ -229,6 +299,15 @@ def func_metadata( if param.annotation is inspect.Parameter.empty: field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) + + # Add description from docstring if no explicit Field description exists + if param.name in docstring_descriptions: + has_explicit_desc = _annotation_has_description(annotation) or ( + isinstance(param.default, FieldInfo) and param.default.description is not None + ) + if not has_explicit_desc: + field_kwargs["description"] = docstring_descriptions[param.name] + # Check if the parameter name conflicts with BaseModel attributes # This is necessary because Pydantic warns about shadowing parent attributes if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..73213904d 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1189,3 +1189,172 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} + + +def test_docstring_google_style(): + """Test that Google-style docstrings produce parameter descriptions in the schema.""" + + def greet(name: str, age: int) -> str: # pragma: no cover + """Greet a user. + + Args: + name: The user's full name + age: The user's age in years + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "The user's full name" + assert schema["properties"]["age"]["description"] == "The user's age in years" + + +def test_docstring_numpy_style(): + """Test that NumPy-style docstrings produce parameter descriptions in the schema.""" + + def greet(name: str, age: int) -> str: # pragma: no cover + """Greet a user. + + Parameters + ---------- + name + The user's full name + age + The user's age in years + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "The user's full name" + assert schema["properties"]["age"]["description"] == "The user's age in years" + + +def test_docstring_sphinx_style(): + """Test that Sphinx-style docstrings produce parameter descriptions in the schema.""" + + def greet(name: str, age: int) -> str: # pragma: no cover + """Greet a user. + + :param name: The user's full name + :param age: The user's age in years + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "The user's full name" + assert schema["properties"]["age"]["description"] == "The user's age in years" + + +def test_docstring_does_not_override_field_description(): + """Test that explicit Field descriptions take priority over docstring descriptions.""" + + def greet( + name: Annotated[str, Field(description="Explicit description")], + age: int, + ) -> str: # pragma: no cover + """Greet a user. + + Args: + name: Docstring description that should be ignored + age: The user's age + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "Explicit description" + assert schema["properties"]["age"]["description"] == "The user's age" + + +def test_docstring_no_docstring(): + """Test that functions without docstrings still work correctly.""" + + def greet(name: str, age: int) -> str: # pragma: no cover + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert "description" not in schema["properties"]["name"] + assert "description" not in schema["properties"]["age"] + + +def test_docstring_with_default_values(): + """Test docstring descriptions work with default parameter values.""" + + def greet(name: str, age: int = 25) -> str: # pragma: no cover + """Greet a user. + + Args: + name: The user's full name + age: The user's age in years + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "The user's full name" + assert schema["properties"]["age"]["description"] == "The user's age in years" + assert schema["properties"]["age"]["default"] == 25 + + +def test_docstring_partial_params(): + """Test that docstrings with only some parameters documented still work.""" + + def greet(name: str, age: int, city: str) -> str: # pragma: no cover + """Greet a user. + + Args: + name: The user's full name + """ + return f"{name} is {age} from {city}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["name"]["description"] == "The user's full name" + assert "description" not in schema["properties"]["age"] + assert "description" not in schema["properties"]["city"] + + +def test_docstring_no_args_section(): + """Test that docstrings without an Args section don't cause issues.""" + + def greet(name: str) -> str: # pragma: no cover + """Greet a user by name.""" + return f"Hello {name}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + assert "description" not in schema["properties"]["name"] + + +def test_docstring_with_annotated_non_field_metadata(): + """Test that docstring descriptions are used when Annotated has non-Field metadata.""" + + def greet( + name: Annotated[str, "some_metadata"], + age: int, + ) -> str: # pragma: no cover + """Greet a user. + + Args: + name: The user's name + age: The user's age + """ + return f"{name} is {age}" + + meta = func_metadata(greet) + schema = meta.arg_model.model_json_schema() + + # Docstring description should be used since Annotated has no Field with description + assert schema["properties"]["name"]["description"] == "The user's name" + assert schema["properties"]["age"]["description"] == "The user's age"