Skip to content

Commit c669441

Browse files
committed
feat: parse Google-style docstrings to populate tool parameter descriptions
FastMCP previously discarded the parameter descriptions in a function's docstring when generating the JSON schema for a tool. The schema only contained titles and types, which gives LLMs less context when deciding how to call the tool. This change adds a small Google-style docstring parser (no new dependencies) that extracts: - the leading summary line, used as the tool description in place of the full raw docstring - per-parameter descriptions from the Args/Arguments/Parameters section, which are passed to Pydantic Field(description=...) so they appear in the generated JSON schema The parser handles: - multi-line summaries collapsed into one paragraph - parameters with or without type annotations - complex annotations like Annotated[list[int], Field(min_length=1)] - multi-line continuation of a parameter description - Args/Arguments/Parameters section header aliases - early termination when a Returns/Raises/Examples section appears - empty/None docstrings Existing behaviour is preserved: an explicit description= argument to Tool.from_function() still wins over the parsed summary, and tools without an Args section keep working without any description fields on their parameters. Github-Issue:#226
1 parent d5b9155 commit c669441

File tree

6 files changed

+413
-1
lines changed

6 files changed

+413
-1
lines changed

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 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
@@ -62,7 +63,11 @@ def from_function(
6263
if func_name == "<lambda>":
6364
raise ValueError("You must provide a name for lambda functions")
6465

65-
func_doc = description or fn.__doc__ or ""
66+
if description is not None:
67+
func_doc = description
68+
else:
69+
doc_summary, _ = parse_docstring(fn.__doc__)
70+
func_doc = doc_summary or fn.__doc__ or ""
6671
is_async = _is_async_callable(fn)
6772

6873
if context_kwarg is None: # pragma: no branch
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
"""Lightweight Google-style docstring parser.
2+
3+
Extracts the summary line and per-parameter descriptions from a function
4+
docstring so FastMCP can populate JSON schema descriptions for tool
5+
parameters. Only Google-style docstrings are supported. NumPy and Sphinx
6+
styles fall back to the summary-only behavior.
7+
"""
8+
9+
import re
10+
from textwrap import dedent
11+
12+
# Section headers we recognize. The summary ends at the first one of these,
13+
# and the Args section ends when any header other than itself appears.
14+
_SECTION_HEADERS = frozenset(
15+
[
16+
"args",
17+
"arguments",
18+
"params",
19+
"parameters",
20+
"returns",
21+
"return",
22+
"yields",
23+
"yield",
24+
"raises",
25+
"raise",
26+
"examples",
27+
"example",
28+
"notes",
29+
"note",
30+
"see also",
31+
"references",
32+
"attributes",
33+
"warnings",
34+
"warning",
35+
"todo",
36+
]
37+
)
38+
39+
_ARGS_HEADERS = frozenset(["args", "arguments", "params", "parameters"])
40+
41+
42+
def _is_section_header(line: str) -> bool:
43+
"""Return True if the stripped line is a recognized section header."""
44+
return line.strip().rstrip(":").lower() in _SECTION_HEADERS
45+
46+
47+
def _parse_param_line(line: str) -> tuple[str, str] | None:
48+
"""Try to parse a Google-style parameter line.
49+
50+
Handles three forms::
51+
52+
name: description
53+
name (type): description
54+
name (Annotated[list[int], Field(min_length=1)]): description
55+
56+
The type annotation in parentheses may contain balanced nested parentheses,
57+
so we walk the string manually instead of using a simple regex.
58+
"""
59+
match = re.match(r"^(\w+)\s*", line)
60+
if match is None:
61+
return None
62+
name = match.group(1)
63+
rest = line[match.end() :]
64+
65+
# Optional type annotation in balanced parentheses
66+
if rest.startswith("("):
67+
depth = 0
68+
end_idx = -1
69+
for i, char in enumerate(rest):
70+
if char == "(":
71+
depth += 1
72+
elif char == ")":
73+
depth -= 1
74+
if depth == 0:
75+
end_idx = i
76+
break
77+
if end_idx == -1:
78+
return None
79+
rest = rest[end_idx + 1 :]
80+
81+
rest = rest.lstrip()
82+
if not rest.startswith(":"):
83+
return None
84+
description = rest[1:].strip()
85+
return name, description
86+
87+
88+
def parse_docstring(docstring: str | None) -> tuple[str, dict[str, str]]:
89+
"""Parse a Google-style docstring into a summary and parameter descriptions.
90+
91+
Args:
92+
docstring: The raw function docstring, or None.
93+
94+
Returns:
95+
A tuple of ``(summary, param_descriptions)`` where ``summary`` is the
96+
leading description text (everything before the first recognized
97+
section header) and ``param_descriptions`` maps parameter names to
98+
their description strings extracted from the Args section.
99+
"""
100+
if not docstring:
101+
return "", {}
102+
103+
text = dedent(docstring).strip()
104+
if not text:
105+
return "", {}
106+
107+
lines = text.splitlines()
108+
summary_lines: list[str] = []
109+
param_descriptions: dict[str, str] = {}
110+
111+
summary_done = False
112+
in_args_section = False
113+
current_param: str | None = None
114+
args_indent: int | None = None
115+
116+
for raw_line in lines:
117+
line = raw_line.rstrip()
118+
stripped = line.strip()
119+
120+
# Detect Args/Parameters section start
121+
if not in_args_section and stripped.lower().rstrip(":") in _ARGS_HEADERS:
122+
in_args_section = True
123+
summary_done = True
124+
current_param = None
125+
args_indent = None
126+
continue
127+
128+
if in_args_section:
129+
# Empty line: end the current parameter's continuation
130+
if not stripped:
131+
current_param = None
132+
continue
133+
134+
# Any other section header ends the Args section permanently
135+
if _is_section_header(stripped):
136+
in_args_section = False
137+
current_param = None
138+
continue
139+
140+
indent = len(line) - len(line.lstrip())
141+
142+
# First non-empty line in Args sets the baseline indent
143+
if args_indent is None:
144+
args_indent = indent
145+
146+
# A line at the args baseline indent starts a new parameter entry
147+
if indent <= args_indent:
148+
parsed = _parse_param_line(stripped)
149+
if parsed is not None:
150+
name, desc = parsed
151+
param_descriptions[name] = desc
152+
current_param = name
153+
else:
154+
current_param = None
155+
elif current_param is not None:
156+
# Continuation line for the current parameter
157+
existing = param_descriptions[current_param]
158+
joined = f"{existing} {stripped}" if existing else stripped
159+
param_descriptions[current_param] = joined
160+
continue
161+
162+
# Outside Args section: collect summary lines until we hit any section
163+
if summary_done:
164+
continue
165+
if _is_section_header(stripped):
166+
summary_done = True
167+
continue
168+
summary_lines.append(stripped)
169+
170+
# Trim trailing empty summary lines and collapse to a single paragraph
171+
while summary_lines and not summary_lines[-1]:
172+
summary_lines.pop()
173+
summary = " ".join(line for line in summary_lines if line).strip()
174+
175+
return summary, param_descriptions

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
)
2323

2424
from mcp.server.mcpserver.exceptions import InvalidSignature
25+
from mcp.server.mcpserver.utilities.docstring import parse_docstring
2526
from mcp.server.mcpserver.utilities.logging import get_logger
2627
from mcp.server.mcpserver.utilities.types import Audio, Image
2728
from mcp.types import CallToolResult, ContentBlock, TextContent
@@ -215,6 +216,7 @@ def func_metadata(
215216
# model_rebuild right before using it 🤷
216217
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
217218
params = sig.parameters
219+
_, param_descriptions = parse_docstring(func.__doc__)
218220
dynamic_pydantic_model_params: dict[str, Any] = {}
219221
for param in params.values():
220222
if param.name.startswith("_"): # pragma: no cover
@@ -229,6 +231,9 @@ def func_metadata(
229231

230232
if param.annotation is inspect.Parameter.empty:
231233
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))
234+
# Populate JSON schema description from the docstring if available
235+
if param.name in param_descriptions:
236+
field_kwargs["description"] = param_descriptions[param.name]
232237
# Check if the parameter name conflicts with BaseModel attributes
233238
# This is necessary because Pydantic warns about shadowing parent attributes
234239
if hasattr(BaseModel, field_name) and callable(getattr(BaseModel, field_name)):

0 commit comments

Comments
 (0)