|
| 1 | +From 87e72741d8d403d6bfe4a5ae13760842010972b0 Mon Sep 17 00:00:00 2001 |
| 2 | +From: Gaurang <gaurangdhawan17@gmail.com> |
| 3 | +Date: Mon, 15 Jun 2026 12:46:49 +0000 |
| 4 | +Subject: [PATCH] Extract tool parameter descriptions from docstrings |
| 5 | + |
| 6 | +FastMCP/MCPServer previously ignored per-parameter documentation in |
| 7 | +function docstrings, so generated tool JSON schemas had empty parameter |
| 8 | +descriptions. This parses Google, NumPy, and Sphinx style docstrings via |
| 9 | +griffe and populates each parameter's description in the schema. |
| 10 | + |
| 11 | +Explicit Annotated[T, Field(description=...)] still takes precedence, and |
| 12 | +functions without docstrings are unaffected. |
| 13 | + |
| 14 | +Closes #226 |
| 15 | +--- |
| 16 | + pyproject.toml | 1 + |
| 17 | + .../mcpserver/utilities/func_metadata.py | 65 +++++++++- |
| 18 | + tests/server/mcpserver/test_func_metadata.py | 117 ++++++++++++++++++ |
| 19 | + uv.lock | 2 + |
| 20 | + 4 files changed, 184 insertions(+), 1 deletion(-) |
| 21 | + |
| 22 | +diff --git a/pyproject.toml b/pyproject.toml |
| 23 | +index 749af47..cb99902 100644 |
| 24 | +--- a/pyproject.toml |
| 25 | ++++ b/pyproject.toml |
| 26 | +@@ -30,6 +30,7 @@ dependencies = [ |
| 27 | + # stderr (agronholm/anyio#816, fixed in 4.10). |
| 28 | + "anyio>=4.10; python_version >= '3.14'", |
| 29 | + "anyio>=4.9; python_version < '3.14'", |
| 30 | ++ "griffe>=1.0.0", |
| 31 | + "httpx>=0.27.1,<1.0.0", |
| 32 | + "httpx-sse>=0.4", |
| 33 | + "pydantic>=2.12.0", |
| 34 | +diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py |
| 35 | +index 4a76106..3f365ab 100644 |
| 36 | +--- a/src/mcp/server/mcpserver/utilities/func_metadata.py |
| 37 | ++++ b/src/mcp/server/mcpserver/utilities/func_metadata.py |
| 38 | +@@ -1,6 +1,7 @@ |
| 39 | + import functools |
| 40 | + import inspect |
| 41 | + import json |
| 42 | ++import logging |
| 43 | + from collections.abc import Awaitable, Callable, Sequence |
| 44 | + from itertools import chain |
| 45 | + from types import GenericAlias |
| 46 | +@@ -9,6 +10,7 @@ from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints |
| 47 | + import anyio |
| 48 | + import anyio.to_thread |
| 49 | + import pydantic_core |
| 50 | ++from griffe import Docstring, DocstringSectionKind, Parser |
| 51 | + from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model |
| 52 | + from pydantic.fields import FieldInfo |
| 53 | + from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind |
| 54 | +@@ -28,6 +30,57 @@ from mcp.types import CallToolResult, ContentBlock, TextContent |
| 55 | + |
| 56 | + logger = get_logger(__name__) |
| 57 | + |
| 58 | ++# griffe emits its own logging when a docstring section is malformed (e.g. a |
| 59 | ++# documented parameter that isn't in the signature). That's noise for our use |
| 60 | ++# case - we only want whatever descriptions we can extract - so silence it. |
| 61 | ++_griffe_logger = logging.getLogger("griffe") |
| 62 | ++if _griffe_logger.level == logging.NOTSET: |
| 63 | ++ _griffe_logger.setLevel(logging.ERROR) |
| 64 | ++ |
| 65 | ++ |
| 66 | ++def _extract_param_descriptions(func: Callable[..., Any]) -> dict[str, str]: |
| 67 | ++ """Extract per-parameter descriptions from a function's docstring. |
| 68 | ++ |
| 69 | ++ Supports the Google, NumPy, and Sphinx docstring styles. The style is not |
| 70 | ++ declared anywhere, so we parse with each supported parser and keep whichever |
| 71 | ++ yields the most parameter descriptions. |
| 72 | ++ |
| 73 | ++ Returns a mapping of parameter name to description. Returns an empty mapping |
| 74 | ++ if the function has no docstring or no documented parameters. |
| 75 | ++ """ |
| 76 | ++ doc = inspect.getdoc(func) |
| 77 | ++ if not doc: |
| 78 | ++ return {} |
| 79 | ++ |
| 80 | ++ best: dict[str, str] = {} |
| 81 | ++ for parser in (Parser.google, Parser.numpy, Parser.sphinx): |
| 82 | ++ try: |
| 83 | ++ sections = Docstring(doc).parse(parser) |
| 84 | ++ except Exception: # pragma: no cover - defensive: never fail tool registration |
| 85 | ++ continue |
| 86 | ++ descriptions: dict[str, str] = {} |
| 87 | ++ for section in sections: |
| 88 | ++ if section.kind is not DocstringSectionKind.parameters: |
| 89 | ++ continue |
| 90 | ++ for param in section.value: |
| 91 | ++ if param.description: |
| 92 | ++ descriptions[param.name] = param.description.strip() |
| 93 | ++ if len(descriptions) > len(best): |
| 94 | ++ best = descriptions |
| 95 | ++ return best |
| 96 | ++ |
| 97 | ++ |
| 98 | ++def _param_has_description(annotation: Any) -> bool: |
| 99 | ++ """Return True if an annotation already carries a Field description. |
| 100 | ++ |
| 101 | ++ This lets an explicit ``Annotated[T, Field(description=...)]`` take |
| 102 | ++ precedence over a description parsed from the docstring. |
| 103 | ++ """ |
| 104 | ++ for meta in getattr(annotation, "__metadata__", ()): |
| 105 | ++ if isinstance(meta, FieldInfo) and meta.description: |
| 106 | ++ return True |
| 107 | ++ return False |
| 108 | ++ |
| 109 | + |
| 110 | + class StrictJsonSchema(GenerateJsonSchema): |
| 111 | + """A JSON schema generator that raises exceptions instead of emitting warnings. |
| 112 | +@@ -215,6 +268,7 @@ def func_metadata( |
| 113 | + # model_rebuild right before using it 🤷 |
| 114 | + raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e |
| 115 | + params = sig.parameters |
| 116 | ++ param_descriptions = _extract_param_descriptions(func) |
| 117 | + dynamic_pydantic_model_params: dict[str, Any] = {} |
| 118 | + for param in params.values(): |
| 119 | + if param.name.startswith("_"): # pragma: no cover |
| 120 | +@@ -227,8 +281,17 @@ def func_metadata( |
| 121 | + field_kwargs: dict[str, Any] = {} |
| 122 | + field_metadata: list[Any] = [] |
| 123 | + |
| 124 | ++ # Apply a description parsed from the docstring, unless the parameter already |
| 125 | ++ # declares one via `Annotated[T, Field(description=...)]`, which takes precedence. |
| 126 | ++ doc_description = param_descriptions.get(param.name) |
| 127 | ++ if doc_description and not _param_has_description(param.annotation): |
| 128 | ++ field_kwargs["description"] = doc_description |
| 129 | ++ |
| 130 | + if param.annotation is inspect.Parameter.empty: |
| 131 | +- field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"})) |
| 132 | ++ json_schema: dict[str, Any] = {"title": param.name, "type": "string"} |
| 133 | ++ if doc_description: |
| 134 | ++ json_schema["description"] = doc_description |
| 135 | ++ field_metadata.append(WithJsonSchema(json_schema)) |
| 136 | + # Check if the parameter name conflicts with BaseModel attributes |
| 137 | + # This is necessary because Pydantic warns about shadowing parent attributes |
| 138 | + if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)): |
| 139 | +diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py |
| 140 | +index c57d1ee..4803d30 100644 |
| 141 | +--- a/tests/server/mcpserver/test_func_metadata.py |
| 142 | ++++ b/tests/server/mcpserver/test_func_metadata.py |
| 143 | +@@ -1189,3 +1189,120 @@ def test_preserves_pydantic_metadata(): |
| 144 | + |
| 145 | + assert meta.output_schema is not None |
| 146 | + assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} |
| 147 | ++ |
| 148 | ++ |
| 149 | ++def _props(meta: Any) -> dict[str, Any]: |
| 150 | ++ """Return the JSON schema properties for a function's arguments.""" |
| 151 | ++ return meta.arg_model.model_json_schema()["properties"] |
| 152 | ++ |
| 153 | ++ |
| 154 | ++def test_docstring_param_descriptions_google(): |
| 155 | ++ """Parameter descriptions are extracted from a Google-style docstring.""" |
| 156 | ++ |
| 157 | ++ def add(a: int, b: int): # pragma: no cover |
| 158 | ++ """Add two numbers. |
| 159 | ++ |
| 160 | ++ Args: |
| 161 | ++ a: The first number to add. |
| 162 | ++ b: The second number to add. |
| 163 | ++ """ |
| 164 | ++ return a + b |
| 165 | ++ |
| 166 | ++ props = _props(func_metadata(add)) |
| 167 | ++ assert props["a"]["description"] == "The first number to add." |
| 168 | ++ assert props["b"]["description"] == "The second number to add." |
| 169 | ++ |
| 170 | ++ |
| 171 | ++def test_docstring_param_descriptions_numpy(): |
| 172 | ++ """Parameter descriptions are extracted from a NumPy-style docstring.""" |
| 173 | ++ |
| 174 | ++ def sub(a: int, b: int): # pragma: no cover |
| 175 | ++ """Subtract two numbers. |
| 176 | ++ |
| 177 | ++ Parameters |
| 178 | ++ ---------- |
| 179 | ++ a : int |
| 180 | ++ The minuend value. |
| 181 | ++ b : int |
| 182 | ++ The subtrahend value. |
| 183 | ++ """ |
| 184 | ++ return a - b |
| 185 | ++ |
| 186 | ++ props = _props(func_metadata(sub)) |
| 187 | ++ assert props["a"]["description"] == "The minuend value." |
| 188 | ++ assert props["b"]["description"] == "The subtrahend value." |
| 189 | ++ |
| 190 | ++ |
| 191 | ++def test_docstring_param_descriptions_sphinx(): |
| 192 | ++ """Parameter descriptions are extracted from a Sphinx-style docstring.""" |
| 193 | ++ |
| 194 | ++ def mul(a: int, b: int): # pragma: no cover |
| 195 | ++ """Multiply two numbers. |
| 196 | ++ |
| 197 | ++ :param a: The first factor. |
| 198 | ++ :param b: The second factor. |
| 199 | ++ """ |
| 200 | ++ return a * b |
| 201 | ++ |
| 202 | ++ props = _props(func_metadata(mul)) |
| 203 | ++ assert props["a"]["description"] == "The first factor." |
| 204 | ++ assert props["b"]["description"] == "The second factor." |
| 205 | ++ |
| 206 | ++ |
| 207 | ++def test_docstring_param_description_does_not_override_explicit_field(): |
| 208 | ++ """An explicit Field(description=...) takes precedence over the docstring.""" |
| 209 | ++ |
| 210 | ++ def func( # pragma: no cover |
| 211 | ++ a: Annotated[int, Field(description="Explicit description for a.")], |
| 212 | ++ b: int, |
| 213 | ++ ): |
| 214 | ++ """Do something. |
| 215 | ++ |
| 216 | ++ Args: |
| 217 | ++ a: Docstring description for a (should be ignored). |
| 218 | ++ b: Docstring description for b. |
| 219 | ++ """ |
| 220 | ++ return a + b |
| 221 | ++ |
| 222 | ++ props = _props(func_metadata(func)) |
| 223 | ++ assert props["a"]["description"] == "Explicit description for a." |
| 224 | ++ assert props["b"]["description"] == "Docstring description for b." |
| 225 | ++ |
| 226 | ++ |
| 227 | ++def test_docstring_param_descriptions_untyped_params(): |
| 228 | ++ """Descriptions are applied to parameters without type annotations.""" |
| 229 | ++ |
| 230 | ++ def func(a, b): # pragma: no cover |
| 231 | ++ """Do something. |
| 232 | ++ |
| 233 | ++ Args: |
| 234 | ++ a: Description for untyped a. |
| 235 | ++ b: Description for untyped b. |
| 236 | ++ """ |
| 237 | ++ return a, b |
| 238 | ++ |
| 239 | ++ props = _props(func_metadata(func)) |
| 240 | ++ assert props["a"]["description"] == "Description for untyped a." |
| 241 | ++ assert props["b"]["description"] == "Description for untyped b." |
| 242 | ++ |
| 243 | ++ |
| 244 | ++def test_no_docstring_yields_no_descriptions(): |
| 245 | ++ """Functions without a docstring produce schemas without descriptions.""" |
| 246 | ++ |
| 247 | ++ def func(a: int, b: int): # pragma: no cover |
| 248 | ++ return a + b |
| 249 | ++ |
| 250 | ++ props = _props(func_metadata(func)) |
| 251 | ++ assert "description" not in props["a"] |
| 252 | ++ assert "description" not in props["b"] |
| 253 | ++ |
| 254 | ++ |
| 255 | ++def test_docstring_without_params_section_is_safe(): |
| 256 | ++ """A docstring lacking a parameters section doesn't add descriptions or error.""" |
| 257 | ++ |
| 258 | ++ def func(a: int): # pragma: no cover |
| 259 | ++ """Just a summary line with no documented parameters.""" |
| 260 | ++ return a |
| 261 | ++ |
| 262 | ++ props = _props(func_metadata(func)) |
| 263 | ++ assert "description" not in props["a"] |
| 264 | +diff --git a/uv.lock b/uv.lock |
| 265 | +index b9755c3..55a70c0 100644 |
| 266 | +--- a/uv.lock |
| 267 | ++++ b/uv.lock |
| 268 | +@@ -846,6 +846,7 @@ name = "mcp" |
| 269 | + source = { editable = "." } |
| 270 | + dependencies = [ |
| 271 | + { name = "anyio" }, |
| 272 | ++ { name = "griffe" }, |
| 273 | + { name = "httpx" }, |
| 274 | + { name = "httpx-sse" }, |
| 275 | + { name = "jsonschema" }, |
| 276 | +@@ -903,6 +904,7 @@ docs = [ |
| 277 | + requires-dist = [ |
| 278 | + { name = "anyio", marker = "python_full_version < '3.14'", specifier = ">=4.9" }, |
| 279 | + { name = "anyio", marker = "python_full_version >= '3.14'", specifier = ">=4.10" }, |
| 280 | ++ { name = "griffe", specifier = ">=1.0.0" }, |
| 281 | + { name = "httpx", specifier = ">=0.27.1,<1.0.0" }, |
| 282 | + { name = "httpx-sse", specifier = ">=0.4" }, |
| 283 | + { name = "jsonschema", specifier = ">=4.20.0" }, |
| 284 | +-- |
| 285 | +2.43.0 |
| 286 | + |
0 commit comments