Skip to content

Commit 9d07720

Browse files
GXd27Gaurangwad
authored andcommitted
Extract tool parameter descriptions from docstrings
FastMCP/MCPServer ignored per-parameter documentation in function docstrings, so generated tool JSON schemas had empty parameter descriptions. Parse Google, NumPy, and Sphinx style docstrings via griffe and populate each parameter's description in the schema. Explicit Annotated[T, Field(description=...)] still takes precedence, and functions without docstrings are unaffected. Closes #226
1 parent ac36a39 commit 9d07720

4 files changed

Lines changed: 214 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dependencies = [
3030
# stderr (agronholm/anyio#816, fixed in 4.10).
3131
"anyio>=4.10; python_version >= '3.14'",
3232
"anyio>=4.9; python_version < '3.14'",
33+
"griffe>=1.0.0",
3334
"httpx>=0.27.1,<1.0.0",
3435
"httpx-sse>=0.4",
3536
"pydantic>=2.12.0",

src/mcp/server/mcpserver/utilities/func_metadata.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import functools
22
import inspect
33
import json
4+
import logging
45
from collections.abc import Awaitable, Callable, Sequence
56
from itertools import chain
67
from types import GenericAlias
@@ -9,6 +10,7 @@
910
import anyio
1011
import anyio.to_thread
1112
import pydantic_core
13+
from griffe import Docstring, DocstringSectionKind, Parser
1214
from pydantic import BaseModel, ConfigDict, Field, PydanticUserError, WithJsonSchema, create_model
1315
from pydantic.fields import FieldInfo
1416
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
@@ -28,6 +30,55 @@
2830

2931
logger = get_logger(__name__)
3032

33+
# griffe emits its own logging when a docstring section is malformed (e.g. a
34+
# documented parameter that isn't in the signature). That's noise for our use
35+
# case - we only want whatever descriptions we can extract - so silence it.
36+
logging.getLogger("griffe").setLevel(logging.ERROR)
37+
38+
39+
def _extract_param_descriptions(func: Callable[..., Any]) -> dict[str, str]:
40+
"""Extract per-parameter descriptions from a function's docstring.
41+
42+
Supports the Google, NumPy, and Sphinx docstring styles. The style is not
43+
declared anywhere, so we parse with each supported parser and keep whichever
44+
yields the most parameter descriptions.
45+
46+
Returns a mapping of parameter name to description. Returns an empty mapping
47+
if the function has no docstring or no documented parameters.
48+
"""
49+
doc = inspect.getdoc(func)
50+
if not doc:
51+
return {}
52+
53+
best: dict[str, str] = {}
54+
for parser in (Parser.google, Parser.numpy, Parser.sphinx):
55+
try:
56+
sections = Docstring(doc).parse(parser)
57+
except Exception: # pragma: no cover - defensive: never fail tool registration
58+
continue
59+
descriptions: dict[str, str] = {}
60+
for section in sections:
61+
if section.kind is not DocstringSectionKind.parameters:
62+
continue
63+
for param in section.value:
64+
if param.description:
65+
descriptions[param.name] = param.description.strip()
66+
if len(descriptions) > len(best):
67+
best = descriptions
68+
return best
69+
70+
71+
def _param_has_description(annotation: Any) -> bool:
72+
"""Return True if an annotation already carries a Field description.
73+
74+
This lets an explicit ``Annotated[T, Field(description=...)]`` take
75+
precedence over a description parsed from the docstring.
76+
"""
77+
for meta in getattr(annotation, "__metadata__", ()):
78+
if isinstance(meta, FieldInfo) and meta.description:
79+
return True
80+
return False
81+
3182

3283
class StrictJsonSchema(GenerateJsonSchema):
3384
"""A JSON schema generator that raises exceptions instead of emitting warnings.
@@ -215,6 +266,7 @@ def func_metadata(
215266
# model_rebuild right before using it 🤷
216267
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
217268
params = sig.parameters
269+
param_descriptions = _extract_param_descriptions(func)
218270
dynamic_pydantic_model_params: dict[str, Any] = {}
219271
for param in params.values():
220272
if param.name.startswith("_"): # pragma: no cover
@@ -227,8 +279,17 @@ def func_metadata(
227279
field_kwargs: dict[str, Any] = {}
228280
field_metadata: list[Any] = []
229281

282+
# Apply a description parsed from the docstring, unless the parameter already
283+
# declares one via `Annotated[T, Field(description=...)]`, which takes precedence.
284+
doc_description = param_descriptions.get(param.name)
285+
if doc_description and not _param_has_description(param.annotation):
286+
field_kwargs["description"] = doc_description
287+
230288
if param.annotation is inspect.Parameter.empty:
231-
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
289+
json_schema: dict[str, Any] = {"title": param.name, "type": "string"}
290+
if doc_description:
291+
json_schema["description"] = doc_description
292+
field_metadata.append(WithJsonSchema(json_schema))
232293
# Check if the parameter name conflicts with BaseModel attributes
233294
# This is necessary because Pydantic warns about shadowing parent attributes
234295
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):

tests/server/mcpserver/test_func_metadata.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,3 +1189,152 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc
11891189

11901190
assert meta.output_schema is not None
11911191
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
1192+
1193+
1194+
def _props(meta: Any) -> dict[str, Any]:
1195+
"""Return the JSON schema properties for a function's arguments."""
1196+
return meta.arg_model.model_json_schema()["properties"]
1197+
1198+
1199+
def test_docstring_param_descriptions_google():
1200+
"""Parameter descriptions are extracted from a Google-style docstring."""
1201+
1202+
def add(a: int, b: int): # pragma: no cover
1203+
"""Add two numbers.
1204+
1205+
Args:
1206+
a: The first number to add.
1207+
b: The second number to add.
1208+
"""
1209+
return a + b
1210+
1211+
props = _props(func_metadata(add))
1212+
assert props["a"]["description"] == "The first number to add."
1213+
assert props["b"]["description"] == "The second number to add."
1214+
1215+
1216+
def test_docstring_param_descriptions_numpy():
1217+
"""Parameter descriptions are extracted from a NumPy-style docstring."""
1218+
1219+
def sub(a: int, b: int): # pragma: no cover
1220+
"""Subtract two numbers.
1221+
1222+
Parameters
1223+
----------
1224+
a : int
1225+
The minuend value.
1226+
b : int
1227+
The subtrahend value.
1228+
"""
1229+
return a - b
1230+
1231+
props = _props(func_metadata(sub))
1232+
assert props["a"]["description"] == "The minuend value."
1233+
assert props["b"]["description"] == "The subtrahend value."
1234+
1235+
1236+
def test_docstring_param_descriptions_sphinx():
1237+
"""Parameter descriptions are extracted from a Sphinx-style docstring."""
1238+
1239+
def mul(a: int, b: int): # pragma: no cover
1240+
"""Multiply two numbers.
1241+
1242+
:param a: The first factor.
1243+
:param b: The second factor.
1244+
"""
1245+
return a * b
1246+
1247+
props = _props(func_metadata(mul))
1248+
assert props["a"]["description"] == "The first factor."
1249+
assert props["b"]["description"] == "The second factor."
1250+
1251+
1252+
def test_docstring_param_description_does_not_override_explicit_field():
1253+
"""An explicit Field(description=...) takes precedence over the docstring."""
1254+
1255+
def func( # pragma: no cover
1256+
a: Annotated[int, Field(description="Explicit description for a.")],
1257+
b: int,
1258+
):
1259+
"""Do something.
1260+
1261+
Args:
1262+
a: Docstring description for a (should be ignored).
1263+
b: Docstring description for b.
1264+
"""
1265+
return a + b
1266+
1267+
props = _props(func_metadata(func))
1268+
assert props["a"]["description"] == "Explicit description for a."
1269+
assert props["b"]["description"] == "Docstring description for b."
1270+
1271+
1272+
def test_docstring_param_descriptions_untyped_params():
1273+
"""Descriptions are applied to parameters without type annotations."""
1274+
1275+
def func(a, b): # pragma: no cover
1276+
"""Do something.
1277+
1278+
Args:
1279+
a: Description for untyped a.
1280+
b: Description for untyped b.
1281+
"""
1282+
...
1283+
1284+
props = _props(func_metadata(func))
1285+
assert props["a"]["description"] == "Description for untyped a."
1286+
assert props["b"]["description"] == "Description for untyped b."
1287+
1288+
1289+
def test_no_docstring_yields_no_descriptions():
1290+
"""Functions without a docstring produce schemas without descriptions."""
1291+
1292+
def func(a: int, b: int): # pragma: no cover
1293+
return a + b
1294+
1295+
props = _props(func_metadata(func))
1296+
assert "description" not in props["a"]
1297+
assert "description" not in props["b"]
1298+
1299+
1300+
def test_docstring_without_params_section_is_safe():
1301+
"""A docstring lacking a parameters section doesn't add descriptions or error."""
1302+
1303+
def func(a: int): # pragma: no cover
1304+
"""Just a summary line with no documented parameters."""
1305+
return a
1306+
1307+
props = _props(func_metadata(func))
1308+
assert "description" not in props["a"]
1309+
1310+
1311+
def test_docstring_param_with_empty_description_is_skipped():
1312+
"""A documented parameter with no description text gets no description."""
1313+
1314+
def func(a: int, b: int): # pragma: no cover
1315+
"""Do something.
1316+
1317+
Args:
1318+
a:
1319+
b: Description for b.
1320+
"""
1321+
...
1322+
1323+
props = _props(func_metadata(func))
1324+
assert "description" not in props["a"]
1325+
assert props["b"]["description"] == "Description for b."
1326+
1327+
1328+
def test_docstring_description_applied_with_non_field_metadata():
1329+
"""Docstring descriptions still apply when annotation metadata isn't a Field."""
1330+
1331+
def func(a: Annotated[int, "not a field"]): # pragma: no cover
1332+
"""Do something.
1333+
1334+
Args:
1335+
a: Description for a.
1336+
"""
1337+
...
1338+
1339+
props = _props(func_metadata(func))
1340+
assert props["a"]["description"] == "Description for a."

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)