Skip to content

Commit a868f7c

Browse files
committed
feat: extract parameter descriptions from docstrings into tool JSON schemas
Parse Google, NumPy, and Sphinx-style docstrings using the griffe library to automatically include parameter descriptions in generated tool JSON schemas. This means users no longer need to use Field(description=...) annotations for their tool parameters — standard Python docstrings work out of the box. Explicit Field descriptions always take priority over docstring descriptions. Github-Issue:#226 Reported-by:salman1993
1 parent e1fd62e commit a868f7c

File tree

4 files changed

+311
-2
lines changed

4 files changed

+311
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ classifiers = [
2626
]
2727
dependencies = [
2828
"anyio>=4.9",
29+
"griffe>=1.5.0",
2930
"httpx>=0.27.1",
3031
"httpx-sse>=0.4",
3132
"pydantic>=2.12.0",

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

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import functools
22
import inspect
33
import json
4-
from collections.abc import Awaitable, Callable, Sequence
4+
import logging
5+
import re
6+
from collections.abc import Awaitable, Callable, Iterator, Sequence
7+
from contextlib import contextmanager
58
from itertools import chain
69
from types import GenericAlias
7-
from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
10+
from typing import Annotated, Any, Literal, cast, get_args, get_origin, get_type_hints
811

912
import anyio
1013
import anyio.to_thread
1114
import pydantic_core
15+
from griffe import Docstring, DocstringSectionKind, GoogleOptions
1216
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
1317
from pydantic.fields import FieldInfo
1418
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
@@ -167,6 +171,129 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
167171
)
168172

169173

174+
_DocstringStyle = Literal["google", "numpy", "sphinx"]
175+
176+
# Patterns to infer docstring style, adapted from pydantic-ai.
177+
# Each entry is (pattern_template, replacement_keywords, style).
178+
_DOCSTRING_STYLE_PATTERNS: list[tuple[str, list[str], _DocstringStyle]] = [
179+
(
180+
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
181+
[
182+
"param",
183+
"parameter",
184+
"arg",
185+
"argument",
186+
"key",
187+
"keyword",
188+
"type",
189+
"var",
190+
"ivar",
191+
"cvar",
192+
"vartype",
193+
"returns",
194+
"return",
195+
"rtype",
196+
"raises",
197+
"raise",
198+
"except",
199+
"exception",
200+
],
201+
"sphinx",
202+
),
203+
(
204+
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
205+
[
206+
"args",
207+
"arguments",
208+
"params",
209+
"parameters",
210+
"keyword args",
211+
"keyword arguments",
212+
"raises",
213+
"exceptions",
214+
"returns",
215+
"yields",
216+
"receives",
217+
"examples",
218+
"attributes",
219+
],
220+
"google",
221+
),
222+
(
223+
r"\n[ \t]*{0}\n[ \t]*---+\n",
224+
[
225+
"deprecated",
226+
"parameters",
227+
"other parameters",
228+
"returns",
229+
"yields",
230+
"receives",
231+
"raises",
232+
"warns",
233+
"attributes",
234+
],
235+
"numpy",
236+
),
237+
]
238+
239+
240+
def _infer_docstring_style(doc: str) -> _DocstringStyle:
241+
"""Infer the docstring style from its content."""
242+
for pattern, replacements, style in _DOCSTRING_STYLE_PATTERNS:
243+
matches = (
244+
re.search(pattern.format(replacement), doc, re.IGNORECASE | re.MULTILINE) for replacement in replacements
245+
)
246+
if any(matches):
247+
return style
248+
return "google"
249+
250+
251+
@contextmanager
252+
def _suppress_griffe_logging() -> Iterator[None]:
253+
"""Temporarily suppress griffe's verbose logging."""
254+
old_level = logging.root.getEffectiveLevel()
255+
logging.root.setLevel(logging.ERROR)
256+
yield
257+
logging.root.setLevel(old_level)
258+
259+
260+
def _parse_docstring_params(func: Callable[..., Any]) -> dict[str, str]:
261+
"""Parse a function's docstring to extract parameter descriptions.
262+
263+
Supports Google, NumPy, and Sphinx-style docstrings with automatic format detection.
264+
265+
Returns:
266+
A dict mapping parameter names to their descriptions.
267+
"""
268+
doc = func.__doc__
269+
if not doc:
270+
return {}
271+
272+
docstring_style = _infer_docstring_style(doc)
273+
parser_options = (
274+
GoogleOptions(returns_named_value=False, returns_multiple_items=False) if docstring_style == "google" else None
275+
)
276+
docstring = Docstring(doc, lineno=1, parser=docstring_style, parser_options=parser_options)
277+
278+
with _suppress_griffe_logging():
279+
sections = docstring.parse()
280+
281+
for section in sections:
282+
if section.kind == DocstringSectionKind.parameters:
283+
return {p.name: p.description for p in section.value}
284+
285+
return {}
286+
287+
288+
def _annotation_has_description(annotation: Any) -> bool:
289+
"""Check if an Annotated type already includes a Field with a description."""
290+
if get_origin(annotation) is Annotated:
291+
for arg in get_args(annotation)[1:]:
292+
if isinstance(arg, FieldInfo) and arg.description is not None:
293+
return True
294+
return False
295+
296+
170297
def func_metadata(
171298
func: Callable[..., Any],
172299
skip_names: Sequence[str] = (),
@@ -215,6 +342,7 @@ def func_metadata(
215342
# model_rebuild right before using it 🤷
216343
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
217344
params = sig.parameters
345+
docstring_descriptions = _parse_docstring_params(func)
218346
dynamic_pydantic_model_params: dict[str, Any] = {}
219347
for param in params.values():
220348
if param.name.startswith("_"): # pragma: no cover
@@ -229,6 +357,15 @@ def func_metadata(
229357

230358
if param.annotation is inspect.Parameter.empty:
231359
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
360+
361+
# Add description from docstring if no explicit Field description exists
362+
if param.name in docstring_descriptions:
363+
has_explicit_desc = _annotation_has_description(annotation) or (
364+
isinstance(param.default, FieldInfo) and param.default.description is not None
365+
)
366+
if not has_explicit_desc:
367+
field_kwargs["description"] = docstring_descriptions[param.name]
368+
232369
# Check if the parameter name conflicts with BaseModel attributes
233370
# This is necessary because Pydantic warns about shadowing parent attributes
234371
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):

tests/server/mcpserver/test_func_metadata.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,3 +1189,172 @@ 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 test_docstring_google_style():
1195+
"""Test that Google-style docstrings produce parameter descriptions in the schema."""
1196+
1197+
def greet(name: str, age: int) -> str: # pragma: no cover
1198+
"""Greet a user.
1199+
1200+
Args:
1201+
name: The user's full name
1202+
age: The user's age in years
1203+
"""
1204+
return f"{name} is {age}"
1205+
1206+
meta = func_metadata(greet)
1207+
schema = meta.arg_model.model_json_schema()
1208+
1209+
assert schema["properties"]["name"]["description"] == "The user's full name"
1210+
assert schema["properties"]["age"]["description"] == "The user's age in years"
1211+
1212+
1213+
def test_docstring_numpy_style():
1214+
"""Test that NumPy-style docstrings produce parameter descriptions in the schema."""
1215+
1216+
def greet(name: str, age: int) -> str: # pragma: no cover
1217+
"""Greet a user.
1218+
1219+
Parameters
1220+
----------
1221+
name
1222+
The user's full name
1223+
age
1224+
The user's age in years
1225+
"""
1226+
return f"{name} is {age}"
1227+
1228+
meta = func_metadata(greet)
1229+
schema = meta.arg_model.model_json_schema()
1230+
1231+
assert schema["properties"]["name"]["description"] == "The user's full name"
1232+
assert schema["properties"]["age"]["description"] == "The user's age in years"
1233+
1234+
1235+
def test_docstring_sphinx_style():
1236+
"""Test that Sphinx-style docstrings produce parameter descriptions in the schema."""
1237+
1238+
def greet(name: str, age: int) -> str: # pragma: no cover
1239+
"""Greet a user.
1240+
1241+
:param name: The user's full name
1242+
:param age: The user's age in years
1243+
"""
1244+
return f"{name} is {age}"
1245+
1246+
meta = func_metadata(greet)
1247+
schema = meta.arg_model.model_json_schema()
1248+
1249+
assert schema["properties"]["name"]["description"] == "The user's full name"
1250+
assert schema["properties"]["age"]["description"] == "The user's age in years"
1251+
1252+
1253+
def test_docstring_does_not_override_field_description():
1254+
"""Test that explicit Field descriptions take priority over docstring descriptions."""
1255+
1256+
def greet(
1257+
name: Annotated[str, Field(description="Explicit description")],
1258+
age: int,
1259+
) -> str: # pragma: no cover
1260+
"""Greet a user.
1261+
1262+
Args:
1263+
name: Docstring description that should be ignored
1264+
age: The user's age
1265+
"""
1266+
return f"{name} is {age}"
1267+
1268+
meta = func_metadata(greet)
1269+
schema = meta.arg_model.model_json_schema()
1270+
1271+
assert schema["properties"]["name"]["description"] == "Explicit description"
1272+
assert schema["properties"]["age"]["description"] == "The user's age"
1273+
1274+
1275+
def test_docstring_no_docstring():
1276+
"""Test that functions without docstrings still work correctly."""
1277+
1278+
def greet(name: str, age: int) -> str: # pragma: no cover
1279+
return f"{name} is {age}"
1280+
1281+
meta = func_metadata(greet)
1282+
schema = meta.arg_model.model_json_schema()
1283+
1284+
assert "description" not in schema["properties"]["name"]
1285+
assert "description" not in schema["properties"]["age"]
1286+
1287+
1288+
def test_docstring_with_default_values():
1289+
"""Test docstring descriptions work with default parameter values."""
1290+
1291+
def greet(name: str, age: int = 25) -> str: # pragma: no cover
1292+
"""Greet a user.
1293+
1294+
Args:
1295+
name: The user's full name
1296+
age: The user's age in years
1297+
"""
1298+
return f"{name} is {age}"
1299+
1300+
meta = func_metadata(greet)
1301+
schema = meta.arg_model.model_json_schema()
1302+
1303+
assert schema["properties"]["name"]["description"] == "The user's full name"
1304+
assert schema["properties"]["age"]["description"] == "The user's age in years"
1305+
assert schema["properties"]["age"]["default"] == 25
1306+
1307+
1308+
def test_docstring_partial_params():
1309+
"""Test that docstrings with only some parameters documented still work."""
1310+
1311+
def greet(name: str, age: int, city: str) -> str: # pragma: no cover
1312+
"""Greet a user.
1313+
1314+
Args:
1315+
name: The user's full name
1316+
"""
1317+
return f"{name} is {age} from {city}"
1318+
1319+
meta = func_metadata(greet)
1320+
schema = meta.arg_model.model_json_schema()
1321+
1322+
assert schema["properties"]["name"]["description"] == "The user's full name"
1323+
assert "description" not in schema["properties"]["age"]
1324+
assert "description" not in schema["properties"]["city"]
1325+
1326+
1327+
def test_docstring_no_args_section():
1328+
"""Test that docstrings without an Args section don't cause issues."""
1329+
1330+
def greet(name: str) -> str: # pragma: no cover
1331+
"""Greet a user by name."""
1332+
return f"Hello {name}"
1333+
1334+
meta = func_metadata(greet)
1335+
schema = meta.arg_model.model_json_schema()
1336+
1337+
assert "description" not in schema["properties"]["name"]
1338+
1339+
1340+
def test_docstring_with_annotated_non_field_metadata():
1341+
"""Test that docstring descriptions are used when Annotated has non-Field metadata."""
1342+
1343+
def greet(
1344+
name: Annotated[str, "some_metadata"],
1345+
age: int,
1346+
) -> str: # pragma: no cover
1347+
"""Greet a user.
1348+
1349+
Args:
1350+
name: The user's name
1351+
age: The user's age
1352+
"""
1353+
return f"{name} is {age}"
1354+
1355+
meta = func_metadata(greet)
1356+
schema = meta.arg_model.model_json_schema()
1357+
1358+
# Docstring description should be used since Annotated has no Field with description
1359+
assert schema["properties"]["name"]["description"] == "The user's name"
1360+
assert schema["properties"]["age"]["description"] == "The user's age"

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)