Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/mcp/server/mcpserver/tools/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from mcp.server.mcpserver.exceptions import ToolError
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
from mcp.server.mcpserver.utilities.docstring import parse_docstring
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
from mcp.shared.exceptions import UrlElicitationRequiredError
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
Expand Down Expand Up @@ -62,7 +63,11 @@ def from_function(
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")

func_doc = description or fn.__doc__ or ""
if description is not None:
func_doc = description
else:
doc_summary, _ = parse_docstring(fn.__doc__)
func_doc = doc_summary or fn.__doc__ or ""
is_async = _is_async_callable(fn)

if context_kwarg is None: # pragma: no branch
Expand Down
175 changes: 175 additions & 0 deletions src/mcp/server/mcpserver/utilities/docstring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
"""Lightweight Google-style docstring parser.

Extracts the summary line and per-parameter descriptions from a function
docstring so FastMCP can populate JSON schema descriptions for tool
parameters. Only Google-style docstrings are supported. NumPy and Sphinx
styles fall back to the summary-only behavior.
"""

import re
from textwrap import dedent

# Section headers we recognize. The summary ends at the first one of these,
# and the Args section ends when any header other than itself appears.
_SECTION_HEADERS = frozenset(
[
"args",
"arguments",
"params",
"parameters",
"returns",
"return",
"yields",
"yield",
"raises",
"raise",
"examples",
"example",
"notes",
"note",
"see also",
"references",
"attributes",
"warnings",
"warning",
"todo",
]
)

_ARGS_HEADERS = frozenset(["args", "arguments", "params", "parameters"])


def _is_section_header(line: str) -> bool:
"""Return True if the stripped line is a recognized section header."""
return line.strip().rstrip(":").lower() in _SECTION_HEADERS


def _parse_param_line(line: str) -> tuple[str, str] | None:
"""Try to parse a Google-style parameter line.

Handles three forms::

name: description
name (type): description
name (Annotated[list[int], Field(min_length=1)]): description

The type annotation in parentheses may contain balanced nested parentheses,
so we walk the string manually instead of using a simple regex.
"""
match = re.match(r"^(\w+)\s*", line)
if match is None:
return None
name = match.group(1)
rest = line[match.end() :]

# Optional type annotation in balanced parentheses
if rest.startswith("("):
depth = 0
end_idx = -1
for i, char in enumerate(rest):
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if depth == 0:
end_idx = i
break
if end_idx == -1:
return None
rest = rest[end_idx + 1 :]

rest = rest.lstrip()
if not rest.startswith(":"):
return None
description = rest[1:].strip()
return name, description


def parse_docstring(docstring: str | None) -> tuple[str, dict[str, str]]:
"""Parse a Google-style docstring into a summary and parameter descriptions.

Args:
docstring: The raw function docstring, or None.

Returns:
A tuple of ``(summary, param_descriptions)`` where ``summary`` is the
leading description text (everything before the first recognized
section header) and ``param_descriptions`` maps parameter names to
their description strings extracted from the Args section.
"""
if not docstring:
return "", {}

text = dedent(docstring).strip()
if not text:
return "", {}

lines = text.splitlines()
summary_lines: list[str] = []
param_descriptions: dict[str, str] = {}

summary_done = False
in_args_section = False
current_param: str | None = None
args_indent: int | None = None

for raw_line in lines:
line = raw_line.rstrip()
stripped = line.strip()

# Detect Args/Parameters section start
if not in_args_section and stripped.lower().rstrip(":") in _ARGS_HEADERS:
in_args_section = True
summary_done = True
current_param = None
args_indent = None
continue

if in_args_section:
# Empty line: end the current parameter's continuation
if not stripped:
current_param = None
continue

# Any other section header ends the Args section permanently
if _is_section_header(stripped):
in_args_section = False
current_param = None
continue

indent = len(line) - len(line.lstrip())

# First non-empty line in Args sets the baseline indent
if args_indent is None:
args_indent = indent

# A line at the args baseline indent starts a new parameter entry
if indent <= args_indent:
parsed = _parse_param_line(stripped)
if parsed is not None:
name, desc = parsed
param_descriptions[name] = desc
current_param = name
else:
current_param = None
elif current_param is not None:
# Continuation line for the current parameter
existing = param_descriptions[current_param]
joined = f"{existing} {stripped}" if existing else stripped
param_descriptions[current_param] = joined
continue

# Outside Args section: collect summary lines until we hit any section
if summary_done:
continue
if _is_section_header(stripped):
summary_done = True
continue
summary_lines.append(stripped)

# Trim trailing empty summary lines and collapse to a single paragraph
while summary_lines and not summary_lines[-1]:
summary_lines.pop()
summary = " ".join(line for line in summary_lines if line).strip()

return summary, param_descriptions
5 changes: 5 additions & 0 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)

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

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