Skip to content

Commit 83bd37f

Browse files
committed
Fix unknown tuple return type in untyped-params test
1 parent 5c22fca commit 83bd37f

2 files changed

Lines changed: 287 additions & 1 deletion

File tree

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
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+

tests/server/mcpserver/test_func_metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1279,7 +1279,7 @@ def func(a, b): # pragma: no cover
12791279
a: Description for untyped a.
12801280
b: Description for untyped b.
12811281
"""
1282-
return a, b
1282+
...
12831283

12841284
props = _props(func_metadata(func))
12851285
assert props["a"]["description"] == "Description for untyped a."

0 commit comments

Comments
 (0)