Skip to content

Commit f3c00ba

Browse files
authored
Extract parameter descriptions from docstrings (#3872)
1 parent fb03e85 commit f3c00ba

9 files changed

Lines changed: 884 additions & 12 deletions

File tree

docs/servers/prompts.mdx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def generate_code_request(language: str, task_description: str) -> list[Message]
5454
* **Parameters:** The function parameters define the inputs needed to generate the prompt.
5555
* **Inferred Metadata:** By default:
5656
* Prompt Name: Taken from the function name (`ask_about_topic`).
57-
* Prompt Description: Taken from the function's docstring.
57+
* Prompt Description: Taken from the summary of the function's docstring. If the docstring includes parameter descriptions (Google, NumPy, or Sphinx style), they populate each prompt argument's description in the MCP protocol (see [Argument Descriptions](#argument-descriptions)).
5858
<Tip>
5959
Functions with `*args` or `**kwargs` are not supported as prompts. This restriction exists because FastMCP needs to generate a complete parameter schema for the MCP protocol, which isn't possible with variable argument lists.
6060
</Tip>
@@ -88,7 +88,7 @@ def data_analysis_prompt(
8888
</ParamField>
8989

9090
<ParamField body="description" type="str | None">
91-
Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose
91+
Provides the description exposed via MCP. If set, the function's docstring is ignored for the prompt description, though docstring-derived argument descriptions still apply (see [Argument Descriptions](#argument-descriptions)).
9292
</ParamField>
9393

9494
<ParamField body="tags" type="set[str] | None">
@@ -201,6 +201,28 @@ Good choices: `list[int]`, `dict[str, str]`, `float`, `bool`
201201
Avoid: Complex Pydantic models, deeply nested structures, custom classes
202202
</Warning>
203203

204+
### Argument Descriptions
205+
206+
<VersionBadge version="3.2.4" />
207+
208+
FastMCP parses your function's docstring to extract the prompt description and per-argument descriptions. Google, NumPy, and Sphinx styles are all supported:
209+
210+
```python
211+
@mcp.prompt
212+
def analyze_data(dataset: str, method: str = "summary") -> str:
213+
"""Generate an analysis prompt for a dataset.
214+
215+
Args:
216+
dataset: URI or identifier of the dataset to analyze.
217+
method: Type of analysis to perform (summary, detailed, etc).
218+
"""
219+
return f"Please perform a '{method}' analysis on {dataset}."
220+
```
221+
222+
The free-form text above the `Args` section — whether a single line or multiple paragraphs — becomes the prompt description, and each argument's docstring entry becomes the description on the corresponding `PromptArgument` in the MCP protocol. Sections like `Returns`, `Raises`, and `Example` are excluded from the description but otherwise ignored.
223+
224+
If an argument already has an explicit description — via `Annotated[x, "..."]` or `Field(description=...)` — that description takes precedence over the docstring. This makes it safe to adopt docstring-based descriptions incrementally: existing annotations keep working, and docstrings fill in the gaps.
225+
204226
### Return Values
205227

206228
Prompt functions must return one of these types:

docs/servers/tools.mdx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def add(a: int, b: int) -> int:
3636

3737
When this tool is registered, FastMCP automatically:
3838
- Uses the function name (`add`) as the tool name.
39-
- Uses the function's docstring (`Adds two integer numbers...`) as the tool description.
39+
- Parses the function's docstring for the tool description and, if present, per-parameter descriptions (see [Docstring Descriptions](#docstring-descriptions)).
4040
- Generates an input schema based on the function's parameters and type annotations.
4141
- Handles parameter validation and error reporting.
4242

@@ -70,7 +70,7 @@ def search_products_implementation(query: str, category: str | None = None) -> l
7070
</ParamField>
7171

7272
<ParamField body="description" type="str | None">
73-
Provides the description exposed via MCP. If set, the function's docstring is ignored for this purpose
73+
Provides the description exposed via MCP. If set, the function's docstring is ignored for the tool description, though docstring-derived parameter descriptions still apply (see [Docstring Descriptions](#docstring-descriptions)).
7474
</ParamField>
7575

7676
<ParamField body="tags" type="set[str] | None">
@@ -289,6 +289,33 @@ The default flexible validation mode is recommended for most use cases as it han
289289

290290
You can provide additional metadata about parameters in several ways:
291291

292+
#### Docstring Descriptions
293+
294+
<VersionBadge version="3.2.4" />
295+
296+
FastMCP parses your function's docstring to extract both the tool description and per-parameter descriptions. Google, NumPy, and Sphinx docstring styles are all supported — the parser tries each and uses whichever finds parameter descriptions:
297+
298+
```python
299+
@mcp.tool
300+
def process_image(
301+
image_url: str,
302+
resize: bool = False,
303+
width: int = 800,
304+
) -> dict:
305+
"""Process an image with optional resizing.
306+
307+
Args:
308+
image_url: URL of the image to process.
309+
resize: Whether to resize the image.
310+
width: Target width in pixels.
311+
"""
312+
# Implementation...
313+
```
314+
315+
The free-form text above the `Args` section — whether a single line or multiple paragraphs — becomes the tool description, and each parameter's docstring entry becomes the description for that parameter in the generated schema. Sections like `Returns`, `Raises`, and `Example` are excluded from the description but otherwise ignored.
316+
317+
If a parameter already has an explicit description — via `Annotated[x, "..."]` or `Field(description=...)` — that description takes precedence over the docstring. This makes it safe to adopt docstring-based descriptions incrementally: existing annotations keep working, and docstrings fill in the gaps.
318+
292319
#### Simple String Descriptions
293320

294321
<VersionBadge version="2.11.0" />

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ dependencies = [
2525
"jsonref>=1.1.0",
2626
"uncalled-for>=0.2.0",
2727
"watchfiles>=1.0.0",
28+
"griffelib>=2.0.0",
2829
]
2930

3031
requires-python = ">=3.10"

src/fastmcp/prompts/function_prompt.py

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
call_sync_fn_in_threadpool,
3737
is_coroutine_function,
3838
)
39+
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
3940
from fastmcp.utilities.json_schema import compress_schema
4041
from fastmcp.utilities.logging import get_logger
4142
from fastmcp.utilities.types import get_cached_typeadapter
@@ -152,11 +153,9 @@ def from_function(
152153
if param.kind == inspect.Parameter.VAR_KEYWORD:
153154
raise ValueError("Functions with **kwargs are not supported as prompts")
154155

155-
description = (
156-
metadata.description
157-
if metadata.description is not None
158-
else inspect.getdoc(fn)
159-
)
156+
# Parse the outer docstring (before unwrapping) to preserve the class
157+
# docstring as the prompt description for callable class instances.
158+
outer_docstring = parse_docstring(fn)
160159

161160
# Normalize task to TaskConfig and validate
162161
task_value = metadata.task
@@ -175,6 +174,24 @@ def from_function(
175174
if isinstance(fn, staticmethod):
176175
fn = fn.__func__
177176

177+
# For callable classes, argument descriptions must come from
178+
# __call__'s docstring — where the exposed parameters are actually
179+
# declared. The class docstring's Args section, if any, typically
180+
# describes __init__, so falling back to it would risk injecting
181+
# constructor docs into __call__'s arguments on overlapping names.
182+
# The description, however, comes from the class docstring (which
183+
# describes what the prompt IS) when present.
184+
inner_docstring = parse_docstring(fn)
185+
parsed_docstring = ParsedDocstring(
186+
description=outer_docstring.description or inner_docstring.description,
187+
parameters=inner_docstring.parameters,
188+
)
189+
description = (
190+
metadata.description
191+
if metadata.description is not None
192+
else parsed_docstring.description
193+
)
194+
178195
# Transform Context type annotations to Depends() for unified DI
179196
fn = transform_context_annotations(fn)
180197

@@ -184,6 +201,18 @@ def from_function(
184201
parameters = type_adapter.json_schema()
185202
parameters = compress_schema(parameters, prune_titles=True)
186203

204+
# Inject parameter descriptions from the docstring into the schema.
205+
# Explicit annotations (Field(description=...), Annotated[x, "..."])
206+
# already have a "description" key and take precedence.
207+
if parsed_docstring.parameters:
208+
properties = parameters.get("properties", {})
209+
for param_name, param_desc in parsed_docstring.parameters.items():
210+
if (
211+
param_name in properties
212+
and "description" not in properties[param_name]
213+
):
214+
properties[param_name]["description"] = param_desc
215+
187216
# Convert parameters to PromptArguments
188217
arguments: list[PromptArgument] = []
189218
if "properties" in parameters:

src/fastmcp/tools/function_parsing.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
without_injected_parameters,
1919
)
2020
from fastmcp.tools.base import ToolResult
21+
from fastmcp.utilities.docstring_parsing import ParsedDocstring, parse_docstring
2122
from fastmcp.utilities.json_schema import compress_schema
2223
from fastmcp.utilities.logging import get_logger
2324
from fastmcp.utilities.types import (
@@ -164,9 +165,9 @@ def from_function(
164165
f"Parameter '{arg_name}' in exclude_args must have a default value."
165166
)
166167

167-
# collect name and doc before we potentially modify the function
168+
# collect name and description before we potentially modify the function
168169
fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__
169-
fn_doc = inspect.getdoc(fn)
170+
outer_docstring = parse_docstring(fn)
170171

171172
# if the fn is a callable class, we need to get the __call__ method from here out
172173
if not inspect.isroutine(fn) and not isinstance(fn, functools.partial):
@@ -175,6 +176,19 @@ def from_function(
175176
if isinstance(fn, staticmethod):
176177
fn = fn.__func__
177178

179+
# For callable classes, parameter descriptions must come from
180+
# __call__'s docstring — where the exposed parameters are actually
181+
# declared. The class docstring's Args section, if any, typically
182+
# describes __init__, so falling back to it would risk injecting
183+
# constructor docs into __call__'s schema on overlapping names.
184+
# The description, however, comes from the class docstring (which
185+
# describes what the tool IS) when present.
186+
inner_docstring = parse_docstring(fn)
187+
parsed_docstring = ParsedDocstring(
188+
description=outer_docstring.description or inner_docstring.description,
189+
parameters=inner_docstring.parameters,
190+
)
191+
178192
# Transform Context type annotations to Depends() for unified DI
179193
fn = transform_context_annotations(fn)
180194

@@ -195,6 +209,18 @@ def from_function(
195209
input_schema, prune_params=prune_params, prune_titles=True
196210
)
197211

212+
# Inject parameter descriptions from the docstring into the schema.
213+
# Explicit annotations (Field(description=...), Annotated[x, "..."])
214+
# already have a "description" key and take precedence.
215+
if parsed_docstring.parameters:
216+
properties = input_schema.get("properties", {})
217+
for param_name, param_desc in parsed_docstring.parameters.items():
218+
if (
219+
param_name in properties
220+
and "description" not in properties[param_name]
221+
):
222+
properties[param_name]["description"] = param_desc
223+
198224
output_schema = None
199225
# Get the return annotation from the signature
200226
sig = inspect.signature(fn)
@@ -282,7 +308,7 @@ def from_function(
282308
return cls(
283309
fn=fn,
284310
name=fn_name,
285-
description=fn_doc,
311+
description=parsed_docstring.description,
286312
input_schema=input_schema,
287313
output_schema=output_schema or None,
288314
return_type=original_output_type,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Extract descriptions from function docstrings.
2+
3+
Uses griffelib to parse Google, NumPy, and Sphinx-style docstrings. The
4+
interface is intentionally narrow — a single function returning a
5+
`ParsedDocstring` — so the implementation can be swapped without touching
6+
callers.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import inspect
12+
import logging
13+
from collections.abc import Callable
14+
from dataclasses import dataclass, field
15+
from typing import Any
16+
17+
from griffe import Docstring, DocstringSectionKind
18+
19+
_PARSERS = ("google", "numpy", "sphinx")
20+
21+
logger = logging.getLogger("griffe")
22+
# Griffe warns about missing type annotations in docstrings, which is noisy
23+
# and irrelevant — we only care about descriptions.
24+
logger.setLevel(logging.ERROR)
25+
26+
27+
@dataclass(frozen=True)
28+
class ParsedDocstring:
29+
"""The extracted description and per-parameter descriptions from a docstring."""
30+
31+
description: str | None = None
32+
parameters: dict[str, str] = field(default_factory=dict)
33+
34+
35+
def parse_docstring(fn: Callable[..., Any]) -> ParsedDocstring:
36+
"""Parse a function's docstring into a summary and parameter descriptions.
37+
38+
Tries Google, NumPy, and Sphinx parsers in order, using the first one that
39+
successfully extracts parameter descriptions. If none do, returns the full
40+
docstring as the description with no parameter descriptions.
41+
"""
42+
doc = inspect.getdoc(fn)
43+
if not doc:
44+
return ParsedDocstring()
45+
46+
# Try each parser and use the first one that finds parameters.
47+
for parser in _PARSERS:
48+
docstring = Docstring(doc, lineno=1, parser=parser)
49+
sections = docstring.parse()
50+
51+
description: str | None = None
52+
parameters: dict[str, str] = {}
53+
54+
for section in sections:
55+
if section.kind == DocstringSectionKind.text and description is None:
56+
description = section.value
57+
elif section.kind == DocstringSectionKind.parameters:
58+
for param in section.value:
59+
parameters[param.name] = param.description
60+
61+
if parameters:
62+
return ParsedDocstring(description=description, parameters=parameters)
63+
64+
# No parser found parameters — return the full docstring unchanged.
65+
return ParsedDocstring(description=doc)

0 commit comments

Comments
 (0)