Skip to content

Commit bdffc88

Browse files
feat: extract parameter descriptions from function docstrings
Parse Google, NumPy, and Sphinx-style docstrings to automatically populate parameter descriptions in tool/prompt/resource JSON schemas. Previously, parameter descriptions required explicit Field(description=...) annotations. Now they can come from standard docstrings, with Field() annotations taking precedence when both are present. Closes #226 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 2182205 commit bdffc88

File tree

8 files changed

+482
-3
lines changed

8 files changed

+482
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ classifiers = [
2424
]
2525
dependencies = [
2626
"anyio>=4.5",
27+
"griffe>=1.0",
2728
"httpx>=0.27.1",
2829
"httpx-sse>=0.4",
2930
"pydantic>=2.12.0",

src/mcp/server/mcpserver/prompts/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from pydantic import BaseModel, Field, TypeAdapter, validate_call
1111

1212
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
13+
from mcp.server.mcpserver.utilities.docstring_utils import parse_docstring
1314
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1415
from mcp.types import ContentBlock, Icon, TextContent
1516

@@ -101,10 +102,14 @@ def from_function(
101102
if context_kwarg is None: # pragma: no branch
102103
context_kwarg = find_context_parameter(fn)
103104

105+
# Parse docstring to extract summary and parameter descriptions
106+
doc_summary, param_descriptions = parse_docstring(fn)
107+
104108
# Get schema from func_metadata, excluding context parameter
105109
func_arg_metadata = func_metadata(
106110
fn,
107111
skip_names=[context_kwarg] if context_kwarg is not None else [],
112+
param_descriptions=param_descriptions,
108113
)
109114
parameters = func_arg_metadata.arg_model.model_json_schema()
110115

@@ -127,7 +132,7 @@ def from_function(
127132
return cls(
128133
name=func_name,
129134
title=title,
130-
description=description or fn.__doc__ or "",
135+
description=description or doc_summary or fn.__doc__ or "",
131136
arguments=arguments,
132137
fn=fn,
133138
icons=icons,

src/mcp/server/mcpserver/resources/templates.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
1414
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
15+
from mcp.server.mcpserver.utilities.docstring_utils import parse_docstring
1516
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
1617
from mcp.types import Annotations, Icon
1718

@@ -59,10 +60,14 @@ def from_function(
5960
if context_kwarg is None: # pragma: no branch
6061
context_kwarg = find_context_parameter(fn)
6162

63+
# Parse docstring to extract summary and parameter descriptions
64+
doc_summary, param_descriptions = parse_docstring(fn)
65+
6266
# Get schema from func_metadata, excluding context parameter
6367
func_arg_metadata = func_metadata(
6468
fn,
6569
skip_names=[context_kwarg] if context_kwarg is not None else [],
70+
param_descriptions=param_descriptions,
6671
)
6772
parameters = func_arg_metadata.arg_model.model_json_schema()
6873

@@ -73,7 +78,7 @@ def from_function(
7378
uri_template=uri_template,
7479
name=func_name,
7580
title=title,
76-
description=description or fn.__doc__ or "",
81+
description=description or doc_summary or fn.__doc__ or "",
7782
mime_type=mime_type or "text/plain",
7883
icons=icons,
7984
annotations=annotations,

src/mcp/server/mcpserver/tools/base.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from mcp.server.mcpserver.exceptions import ToolError
1212
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
13+
from mcp.server.mcpserver.utilities.docstring_utils import parse_docstring
1314
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
1415
from mcp.shared.exceptions import UrlElicitationRequiredError
1516
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
@@ -63,16 +64,20 @@ def from_function(
6364
if func_name == "<lambda>":
6465
raise ValueError("You must provide a name for lambda functions")
6566

66-
func_doc = description or fn.__doc__ or ""
6767
is_async = _is_async_callable(fn)
6868

6969
if context_kwarg is None: # pragma: no branch
7070
context_kwarg = find_context_parameter(fn)
7171

72+
# Parse docstring to extract summary and parameter descriptions
73+
doc_summary, param_descriptions = parse_docstring(fn)
74+
func_doc = description or doc_summary or fn.__doc__ or ""
75+
7276
func_arg_metadata = func_metadata(
7377
fn,
7478
skip_names=[context_kwarg] if context_kwarg is not None else [],
7579
structured_output=structured_output,
80+
param_descriptions=param_descriptions,
7681
)
7782
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
7883

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Utilities for parsing function docstrings to extract descriptions and parameter info.
2+
3+
Supports Google, NumPy, and Sphinx docstring formats with automatic detection.
4+
Adapted from pydantic-ai's _griffe.py implementation.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import logging
10+
import re
11+
from collections.abc import Callable
12+
from contextlib import contextmanager
13+
from typing import Any, Iterator, Literal
14+
15+
from griffe import Docstring, DocstringSectionKind
16+
17+
try:
18+
from griffe import GoogleOptions
19+
20+
_GOOGLE_PARSER_OPTIONS = GoogleOptions(returns_named_value=False, returns_multiple_items=False)
21+
except ImportError:
22+
_GOOGLE_PARSER_OPTIONS = None
23+
24+
DocstringStyle = Literal["google", "numpy", "sphinx"]
25+
26+
27+
def parse_docstring(
28+
func: Callable[..., Any],
29+
) -> tuple[str | None, dict[str, str]]:
30+
"""Extract the function summary and parameter descriptions from a docstring.
31+
32+
Automatically infers the docstring format (Google, NumPy, or Sphinx).
33+
34+
Returns:
35+
A tuple of (summary, param_descriptions) where:
36+
- summary: The main description text (first section), or None if no docstring
37+
- param_descriptions: Dict mapping parameter names to their descriptions
38+
"""
39+
doc = func.__doc__
40+
if doc is None:
41+
return None, {}
42+
43+
docstring_style = _infer_docstring_style(doc)
44+
parser_options = _GOOGLE_PARSER_OPTIONS if docstring_style == "google" else None
45+
docstring = Docstring(
46+
doc,
47+
lineno=1,
48+
parser=docstring_style,
49+
parser_options=parser_options,
50+
)
51+
with _disable_griffe_logging():
52+
sections = docstring.parse()
53+
54+
params: dict[str, str] = {}
55+
if parameters := next(
56+
(s for s in sections if s.kind == DocstringSectionKind.parameters), None
57+
):
58+
params = {p.name: p.description for p in parameters.value if p.description}
59+
60+
summary: str | None = None
61+
if main := next(
62+
(s for s in sections if s.kind == DocstringSectionKind.text), None
63+
):
64+
summary = main.value.strip() if main.value else None
65+
66+
return summary, params
67+
68+
69+
def _infer_docstring_style(doc: str) -> DocstringStyle:
70+
"""Infer the docstring style from its content."""
71+
for pattern, replacements, style in _DOCSTRING_STYLE_PATTERNS:
72+
matches = (
73+
re.search(pattern.format(replacement), doc, re.IGNORECASE | re.MULTILINE)
74+
for replacement in replacements
75+
)
76+
if any(matches):
77+
return style
78+
return "google"
79+
80+
81+
# Pattern matching for docstring style detection.
82+
# See https://github.com/mkdocstrings/griffe/issues/329#issuecomment-2425017804
83+
_DOCSTRING_STYLE_PATTERNS: list[tuple[str, list[str], DocstringStyle]] = [
84+
(
85+
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
86+
[
87+
"param",
88+
"parameter",
89+
"arg",
90+
"argument",
91+
"type",
92+
"returns",
93+
"return",
94+
"rtype",
95+
"raises",
96+
"raise",
97+
],
98+
"sphinx",
99+
),
100+
(
101+
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
102+
[
103+
"args",
104+
"arguments",
105+
"params",
106+
"parameters",
107+
"raises",
108+
"returns",
109+
"yields",
110+
"examples",
111+
"attributes",
112+
],
113+
"google",
114+
),
115+
(
116+
r"\n[ \t]*{0}\n[ \t]*---+\n",
117+
[
118+
"parameters",
119+
"returns",
120+
"yields",
121+
"raises",
122+
"attributes",
123+
],
124+
"numpy",
125+
),
126+
]
127+
128+
129+
@contextmanager
130+
def _disable_griffe_logging() -> Iterator[None]:
131+
"""Temporarily suppress griffe logging to avoid noisy warnings."""
132+
old_level = logging.root.getEffectiveLevel()
133+
logging.root.setLevel(logging.ERROR)
134+
try:
135+
yield
136+
finally:
137+
logging.root.setLevel(old_level)

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ def func_metadata(
172172
func: Callable[..., Any],
173173
skip_names: Sequence[str] = (),
174174
structured_output: bool | None = None,
175+
param_descriptions: dict[str, str] | None = None,
175176
) -> FuncMetadata:
176177
"""Given a function, return metadata including a pydantic model representing its
177178
signature.
@@ -203,6 +204,10 @@ def func_metadata(
203204
- TypedDict - converted to a Pydantic model with same fields
204205
- Dataclasses and other annotated classes - converted to Pydantic models
205206
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
207+
param_descriptions: Optional dict mapping parameter names to descriptions
208+
extracted from the function's docstring. These are used as fallback
209+
descriptions when a parameter does not already have a description
210+
from a Field() annotation.
206211
207212
Returns:
208213
A FuncMetadata object containing:
@@ -231,6 +236,13 @@ def func_metadata(
231236

232237
if param.annotation is inspect.Parameter.empty:
233238
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
239+
240+
# Inject docstring parameter description as fallback, but only if the
241+
# parameter doesn't already have a description from a Field() annotation.
242+
if param_descriptions and param.name in param_descriptions:
243+
if not _has_field_description(annotation, param.default):
244+
field_kwargs["description"] = param_descriptions[param.name]
245+
234246
# Check if the parameter name conflicts with BaseModel attributes
235247
# This is necessary because Pydantic warns about shadowing parent attributes
236248
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):
@@ -418,6 +430,25 @@ def _try_create_model_and_schema(
418430
return None, None, False
419431

420432

433+
def _has_field_description(annotation: Any, default: Any) -> bool:
434+
"""Check if a parameter already has a description from a Field() annotation.
435+
436+
Checks both Annotated metadata (e.g., Annotated[int, Field(description="...")])
437+
and default values (e.g., param: str = Field(description="...")).
438+
"""
439+
# Check if the default value is a FieldInfo with a description
440+
if isinstance(default, FieldInfo) and default.description is not None:
441+
return True
442+
443+
# Check if the annotation is Annotated with a FieldInfo that has a description
444+
if get_origin(annotation) is Annotated:
445+
for arg in get_args(annotation)[1:]:
446+
if isinstance(arg, FieldInfo) and arg.description is not None:
447+
return True
448+
449+
return False
450+
451+
421452
_no_default = object()
422453

423454

0 commit comments

Comments
 (0)