Skip to content

Commit ff53da6

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 ff53da6

4 files changed

Lines changed: 242 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: 53 additions & 0 deletions
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
import sys
56
from collections.abc import Awaitable, Callable, Sequence
67
from itertools import chain
@@ -9,7 +10,9 @@
910

1011
import anyio
1112
import anyio.to_thread
13+
import griffe
1214
import pydantic_core
15+
from griffe import DocstringSectionParameters
1316
from mcp_types import CallToolResult, ContentBlock, InputRequiredResult, TextContent
1417
from pydantic import (
1518
BaseModel,
@@ -36,9 +39,55 @@
3639
from mcp.server.mcpserver.utilities.logging import get_logger
3740
from mcp.server.mcpserver.utilities.types import Audio, Image
3841

42+
# Suppress griffe's "No type or annotation" warnings — we parse descriptions, not types
43+
logging.getLogger("griffe").setLevel(logging.ERROR)
44+
3945
logger = get_logger(__name__)
4046

4147

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

@@ -293,6 +342,7 @@ def func_metadata(
293342
# model_rebuild right before using it 🤷
294343
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
295344
params = sig.parameters
345+
param_descriptions = _parse_docstring_params(func)
296346
dynamic_pydantic_model_params: dict[str, Any] = {}
297347
for param in params.values():
298348
if param.name.startswith("_"): # pragma: no cover
@@ -303,6 +353,9 @@ def func_metadata(
303353
annotation = param.annotation if param.annotation is not inspect.Parameter.empty else Any
304354
field_name = param.name
305355
field_kwargs: dict[str, Any] = {}
356+
# Only add docstring description if the annotation doesn't already have a Field description
357+
if param.name in param_descriptions and not _has_field_description(annotation):
358+
field_kwargs["description"] = param_descriptions[param.name]
306359
field_metadata: list[Any] = []
307360

308361
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)