Skip to content

Commit 52250bc

Browse files
committed
feat: propagate docstring parameter descriptions to JSON Schema
Parse Python docstrings (Google, NumPy, and Sphinx styles) using griffe to extract parameter descriptions, then include them in the generated JSON Schema via Field(description=...). This addresses issue #226: when a tool function has a docstring with an Args/Parameters section, the descriptions are now automatically added to the JSON Schema output, giving LLMs richer context about each parameter. Key design decisions: - Tries all three docstring styles (Google, NumPy, Sphinx) and picks the one that yields the most parameter descriptions - Annotated Field descriptions take precedence over docstring descriptions - Gracefully degrades: no docstring or unrecognized format → no descriptions - Suppresses griffe's noisy type-annotation warnings (we only need descriptions) Signed-off-by: zsxh1990 <zsxh1990@gmail.com> Signed-off-by: zsxh1990 <445655361@qq.com>
1 parent 57394b0 commit 52250bc

4 files changed

Lines changed: 244 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ dependencies = [
144144
"typing-extensions>=4.13.0",
145145
"typing-inspection>=0.4.1",
146146
"opentelemetry-api>=1.28.0",
147+
"griffe>=1.0.0",
147148
]
148149

149150
[project.urls]

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,60 @@
3636
from mcp.server.mcpserver.utilities.logging import get_logger
3737
from mcp.server.mcpserver.utilities.types import Audio, Image
3838

39+
import logging as _logging
40+
41+
import griffe
42+
from griffe import DocstringSectionParameters
43+
44+
# Suppress griffe's "No type or annotation" warnings — we parse descriptions, not types
45+
_logging.getLogger("griffe").setLevel(_logging.ERROR)
46+
3947
logger = get_logger(__name__)
4048

4149

50+
def _parse_docstring_params(func: Callable[..., Any]) -> dict[str, str]:
51+
"""Parse parameter descriptions from a function's docstring.
52+
53+
Supports Google, NumPy, and Sphinx docstring styles via griffe.
54+
Tries all styles and returns the one that yields the most parameter descriptions.
55+
56+
Returns:
57+
A dict mapping parameter names to their descriptions.
58+
"""
59+
docstring = func.__doc__
60+
if not docstring:
61+
return {}
62+
63+
docstring_obj = griffe.Docstring(docstring)
64+
best: dict[str, str] = {}
65+
66+
for parser in (griffe.parse_google, griffe.parse_numpy, griffe.parse_sphinx):
67+
try:
68+
parsed = parser(docstring_obj)
69+
found: dict[str, str] = {}
70+
for section in parsed:
71+
if isinstance(section, DocstringSectionParameters):
72+
for param in section.value:
73+
if param.description:
74+
found[param.name] = param.description
75+
if len(found) > len(best):
76+
best = found
77+
except Exception:
78+
continue
79+
80+
return best
81+
82+
83+
def _has_field_description(annotation: Any) -> bool:
84+
"""Check if a type annotation already contains a Pydantic Field with a description."""
85+
if get_origin(annotation) is Annotated:
86+
args = get_args(annotation)
87+
for arg in args[1:]:
88+
if isinstance(arg, FieldInfo) and arg.description is not None:
89+
return True
90+
return False
91+
92+
4293
def _is_input_required_type(obj: Any) -> bool:
4394
return isinstance(obj, type) and issubclass(obj, InputRequiredResult)
4495

@@ -293,6 +344,7 @@ def func_metadata(
293344
# model_rebuild right before using it 🤷
294345
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
295346
params = sig.parameters
347+
param_descriptions = _parse_docstring_params(func)
296348
dynamic_pydantic_model_params: dict[str, Any] = {}
297349
for param in params.values():
298350
if param.name.startswith("_"): # pragma: no cover
@@ -303,6 +355,9 @@ def func_metadata(
303355
annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any
304356
field_name = param.name
305357
field_kwargs: dict[str, Any] = {}
358+
# Only add docstring description if the annotation doesn't already have a Field description
359+
if param.name in param_descriptions and not _has_field_description(annotation):
360+
field_kwargs["description"] = param_descriptions[param.name]
306361
field_metadata: list[Any] = []
307362

308363
if param.annotation is inspect.Parameter.empty:

tests/server/mcpserver/test_func_metadata.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1458,3 +1458,162 @@ def fn() -> StepA | StepB: ... # pragma: no branch
14581458

14591459
meta = func_metadata(fn)
14601460
assert meta.output_schema is None
1461+
1462+
1463+
# Tests for docstring → JSON Schema description propagation (issue #226)
1464+
1465+
1466+
def test_google_style_docstring_descriptions():
1467+
"""Test that Google-style docstrings are parsed and descriptions added to schema."""
1468+
1469+
def func_google_style(name: str, age: int, verbose: bool = False) -> str:
1470+
"""A function with Google-style docstring.
1471+
1472+
Args:
1473+
name: The person's full name.
1474+
age: Age in years.
1475+
verbose: Whether to print verbose output.
1476+
1477+
Returns:
1478+
A greeting string.
1479+
"""
1480+
return f"Hello {name}, you are {age}"
1481+
1482+
meta = func_metadata(func_google_style)
1483+
schema = meta.arg_model.model_json_schema(by_alias=True)
1484+
1485+
assert schema["properties"]["name"]["description"] == "The person's full name."
1486+
assert schema["properties"]["age"]["description"] == "Age in years."
1487+
assert schema["properties"]["verbose"]["description"] == "Whether to print verbose output."
1488+
1489+
1490+
def test_numpy_style_docstring_descriptions():
1491+
"""Test that NumPy-style docstrings are parsed and descriptions added to schema."""
1492+
1493+
def func_numpy_style(filename: str, encoding: str = "utf-8") -> str:
1494+
"""A function with NumPy-style docstring.
1495+
1496+
Parameters
1497+
----------
1498+
filename : str
1499+
Path to the file to read.
1500+
encoding : str, optional
1501+
File encoding. Defaults to utf-8.
1502+
1503+
Returns
1504+
-------
1505+
str
1506+
File contents.
1507+
"""
1508+
return f"Reading {filename}"
1509+
1510+
meta = func_metadata(func_numpy_style)
1511+
schema = meta.arg_model.model_json_schema(by_alias=True)
1512+
1513+
assert schema["properties"]["filename"]["description"] == "Path to the file to read."
1514+
assert schema["properties"]["encoding"]["description"] == "File encoding. Defaults to utf-8."
1515+
1516+
1517+
def test_sphinx_style_docstring_descriptions():
1518+
"""Test that Sphinx-style docstrings are parsed and descriptions added to schema."""
1519+
1520+
def func_sphinx_style(url: str, timeout: int = 30) -> str:
1521+
"""A function with Sphinx-style docstring.
1522+
1523+
:param url: The URL to fetch.
1524+
:param timeout: Request timeout in seconds.
1525+
:returns: Response text.
1526+
"""
1527+
return f"Fetching {url}"
1528+
1529+
meta = func_metadata(func_sphinx_style)
1530+
schema = meta.arg_model.model_json_schema(by_alias=True)
1531+
1532+
assert schema["properties"]["url"]["description"] == "The URL to fetch."
1533+
assert schema["properties"]["timeout"]["description"] == "Request timeout in seconds."
1534+
1535+
1536+
def test_no_docstring():
1537+
"""Test that functions without docstrings still work correctly."""
1538+
1539+
def func_no_doc(x: int, y: str) -> str: # pragma: no cover
1540+
return f"{x}: {y}"
1541+
1542+
meta = func_metadata(func_no_doc)
1543+
schema = meta.arg_model.model_json_schema(by_alias=True)
1544+
1545+
# No description should be added
1546+
assert "description" not in schema["properties"]["x"]
1547+
assert "description" not in schema["properties"]["y"]
1548+
1549+
1550+
def test_docstring_no_args_section():
1551+
"""Test docstrings without an Args section don't add descriptions."""
1552+
1553+
def func_no_args_section(x: int) -> str:
1554+
"""Just a summary, no args section."""
1555+
return str(x)
1556+
1557+
meta = func_metadata(func_no_args_section)
1558+
schema = meta.arg_model.model_json_schema(by_alias=True)
1559+
1560+
assert "description" not in schema["properties"]["x"]
1561+
1562+
1563+
def test_docstring_partial_args():
1564+
"""Test that only documented parameters get descriptions."""
1565+
1566+
def func_partial(a: int, b: str, c: float) -> str:
1567+
"""Function with partial docstring.
1568+
1569+
Args:
1570+
a: First parameter.
1571+
c: Third parameter.
1572+
"""
1573+
return f"{a}{b}{c}"
1574+
1575+
meta = func_metadata(func_partial)
1576+
schema = meta.arg_model.model_json_schema(by_alias=True)
1577+
1578+
assert schema["properties"]["a"]["description"] == "First parameter."
1579+
assert "description" not in schema["properties"]["b"]
1580+
assert schema["properties"]["c"]["description"] == "Third parameter."
1581+
1582+
1583+
def test_docstring_with_skip_names():
1584+
"""Test that docstring parsing works correctly with skip_names."""
1585+
1586+
def func_skip(name: str, secret: str, verbose: bool = False) -> str:
1587+
"""Function with skip.
1588+
1589+
Args:
1590+
name: User name.
1591+
secret: Secret to skip.
1592+
verbose: Be verbose.
1593+
"""
1594+
return name
1595+
1596+
meta = func_metadata(func_skip, skip_names=["secret"])
1597+
schema = meta.arg_model.model_json_schema(by_alias=True)
1598+
1599+
assert "secret" not in schema["properties"]
1600+
assert schema["properties"]["name"]["description"] == "User name."
1601+
assert schema["properties"]["verbose"]["description"] == "Be verbose."
1602+
1603+
1604+
def test_field_description_preserved_over_docstring():
1605+
"""Test that Annotated Field descriptions take precedence over docstring descriptions."""
1606+
1607+
def func_field_priority(name: Annotated[str, Field(description="Field description")]) -> str:
1608+
"""Function.
1609+
1610+
Args:
1611+
name: Docstring description.
1612+
"""
1613+
return name
1614+
1615+
meta = func_metadata(func_field_priority)
1616+
schema = meta.arg_model.model_json_schema(by_alias=True)
1617+
1618+
# Field description should be preserved (Pydantic uses it directly)
1619+
assert schema["properties"]["name"]["description"] == "Field description"

uv.lock

Lines changed: 29 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)