Skip to content

Commit 06659a4

Browse files
GXd27Gaurangwad
authored andcommitted
Extract tool parameter descriptions from docstrings
FastMCP/MCPServer previously ignored per-parameter documentation in function docstrings, so generated tool JSON schemas had empty parameter descriptions. This parses Google, NumPy, and Sphinx style docstrings via griffe and populates 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 cf110e3 commit 06659a4

4 files changed

Lines changed: 184 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: 64 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,57 @@
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+
_griffe_logger = logging.getLogger("griffe")
37+
if _griffe_logger.level == logging.NOTSET:
38+
_griffe_logger.setLevel(logging.ERROR)
39+
40+
41+
def _extract_param_descriptions(func: Callable[..., Any]) -> dict[str, str]:
42+
"""Extract per-parameter descriptions from a function's docstring.
43+
44+
Supports the Google, NumPy, and Sphinx docstring styles. The style is not
45+
declared anywhere, so we parse with each supported parser and keep whichever
46+
yields the most parameter descriptions.
47+
48+
Returns a mapping of parameter name to description. Returns an empty mapping
49+
if the function has no docstring or no documented parameters.
50+
"""
51+
doc = inspect.getdoc(func)
52+
if not doc:
53+
return {}
54+
55+
best: dict[str, str] = {}
56+
for parser in (Parser.google, Parser.numpy, Parser.sphinx):
57+
try:
58+
sections = Docstring(doc).parse(parser)
59+
except Exception: # pragma: no cover - defensive: never fail tool registration
60+
continue
61+
descriptions: dict[str, str] = {}
62+
for section in sections:
63+
if section.kind is not DocstringSectionKind.parameters:
64+
continue
65+
for param in section.value:
66+
if param.description:
67+
descriptions[param.name] = param.description.strip()
68+
if len(descriptions) > len(best):
69+
best = descriptions
70+
return best
71+
72+
73+
def _param_has_description(annotation: Any) -> bool:
74+
"""Return True if an annotation already carries a Field description.
75+
76+
This lets an explicit ``Annotated[T, Field(description=...)]`` take
77+
precedence over a description parsed from the docstring.
78+
"""
79+
for meta in getattr(annotation, "__metadata__", ()):
80+
if isinstance(meta, FieldInfo) and meta.description:
81+
return True
82+
return False
83+
3184

3285
class StrictJsonSchema(GenerateJsonSchema):
3386
"""A JSON schema generator that raises exceptions instead of emitting warnings.
@@ -215,6 +268,7 @@ def func_metadata(
215268
# model_rebuild right before using it 🤷
216269
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
217270
params = sig.parameters
271+
param_descriptions = _extract_param_descriptions(func)
218272
dynamic_pydantic_model_params: dict[str, Any] = {}
219273
for param in params.values():
220274
if param.name.startswith("_"): # pragma: no cover
@@ -227,8 +281,17 @@ def func_metadata(
227281
field_kwargs: dict[str, Any] = {}
228282
field_metadata: list[Any] = []
229283

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

tests/server/mcpserver/test_func_metadata.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,3 +1189,120 @@ 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+
return a, b
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"]

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)