From c6694419352e9d79f650dec0ea2386e762c65618 Mon Sep 17 00:00:00 2001 From: Maanik Garg Date: Wed, 8 Apr 2026 09:40:18 +0530 Subject: [PATCH 1/2] 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 --- src/mcp/server/mcpserver/tools/base.py | 7 +- .../server/mcpserver/utilities/docstring.py | 175 +++++++++++++++++ .../mcpserver/utilities/func_metadata.py | 5 + tests/server/mcpserver/test_docstring.py | 178 ++++++++++++++++++ tests/server/mcpserver/test_func_metadata.py | 28 +++ tests/server/mcpserver/test_tool_manager.py | 21 +++ 6 files changed, 413 insertions(+), 1 deletion(-) create mode 100644 src/mcp/server/mcpserver/utilities/docstring.py create mode 100644 tests/server/mcpserver/test_docstring.py diff --git a/src/mcp/server/mcpserver/tools/base.py b/src/mcp/server/mcpserver/tools/base.py index dc65be988..32df46376 100644 --- a/src/mcp/server/mcpserver/tools/base.py +++ b/src/mcp/server/mcpserver/tools/base.py @@ -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 @@ -62,7 +63,11 @@ def from_function( if func_name == "": 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 diff --git a/src/mcp/server/mcpserver/utilities/docstring.py b/src/mcp/server/mcpserver/utilities/docstring.py new file mode 100644 index 000000000..b85c24e58 --- /dev/null +++ b/src/mcp/server/mcpserver/utilities/docstring.py @@ -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 diff --git a/src/mcp/server/mcpserver/utilities/func_metadata.py b/src/mcp/server/mcpserver/utilities/func_metadata.py index 062b47d0f..211b5e520 100644 --- a/src/mcp/server/mcpserver/utilities/func_metadata.py +++ b/src/mcp/server/mcpserver/utilities/func_metadata.py @@ -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 @@ -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 @@ -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)): diff --git a/tests/server/mcpserver/test_docstring.py b/tests/server/mcpserver/test_docstring.py new file mode 100644 index 000000000..9f0fe877d --- /dev/null +++ b/tests/server/mcpserver/test_docstring.py @@ -0,0 +1,178 @@ +"""Tests for the Google-style docstring parser.""" + +from mcp.server.mcpserver.utilities.docstring import parse_docstring + + +def test_none_docstring(): + summary, params = parse_docstring(None) + assert summary == "" + assert params == {} + + +def test_empty_docstring(): + summary, params = parse_docstring("") + assert summary == "" + assert params == {} + + +def test_whitespace_only_docstring(): + summary, params = parse_docstring(" \n \n ") + assert summary == "" + assert params == {} + + +def test_summary_only(): + summary, params = parse_docstring("Adds two numbers.") + assert summary == "Adds two numbers." + assert params == {} + + +def test_multi_line_summary(): + docstring = """ + Adds two numbers + and returns the result. + """ + summary, params = parse_docstring(docstring) + assert summary == "Adds two numbers and returns the result." + assert params == {} + + +def test_google_style_with_types(): + docstring = """ + Adds two numbers and returns the result. + + Args: + a (float): The first number. + b (float): The second number. + + Returns: + float: The sum of a and b. + """ + summary, params = parse_docstring(docstring) + assert summary == "Adds two numbers and returns the result." + assert params == {"a": "The first number.", "b": "The second number."} + + +def test_google_style_without_types(): + docstring = """ + Greets a user. + + Args: + name: The name of the user. + greeting: The greeting message. + """ + summary, params = parse_docstring(docstring) + assert summary == "Greets a user." + assert params == {"name": "The name of the user.", "greeting": "The greeting message."} + + +def test_multiline_param_description(): + docstring = """ + Does a thing. + + Args: + config: A long configuration value + that spans multiple lines + with extra detail. + other: Single line. + """ + summary, params = parse_docstring(docstring) + assert summary == "Does a thing." + assert params["config"] == "A long configuration value that spans multiple lines with extra detail." + assert params["other"] == "Single line." + + +def test_arguments_section_header_alias(): + docstring = """ + Tool function. + + Arguments: + x: First arg. + y: Second arg. + """ + _, params = parse_docstring(docstring) + assert params == {"x": "First arg.", "y": "Second arg."} + + +def test_parameters_section_header_alias(): + docstring = """ + Tool function. + + Parameters: + x: First arg. + """ + _, params = parse_docstring(docstring) + assert params == {"x": "First arg."} + + +def test_section_after_args_terminates_parsing(): + docstring = """ + Reads a file. + + Args: + path: Path to file. + + Raises: + FileNotFoundError: If the file does not exist. + """ + summary, params = parse_docstring(docstring) + assert summary == "Reads a file." + assert params == {"path": "Path to file."} + + +def test_empty_line_in_args_section_resets_continuation(): + docstring = """ + Function. + + Args: + a: First param. + + b: Second param after blank line. + """ + _, params = parse_docstring(docstring) + assert params == {"a": "First param.", "b": "Second param after blank line."} + + +def test_summary_with_section_header_immediately(): + docstring = """Args: + x: Just a param. + """ + summary, params = parse_docstring(docstring) + assert summary == "" + assert params == {"x": "Just a param."} + + +def test_unrecognized_continuation_without_current_param(): + docstring = """ + Function. + + Args: + not a param line + indented continuation that should be ignored + x: Real param. + """ + _, params = parse_docstring(docstring) + assert params == {"x": "Real param."} + + +def test_returns_section_only_no_args(): + docstring = """ + Computes a value. + + Returns: + int: The computed value. + """ + summary, params = parse_docstring(docstring) + assert summary == "Computes a value." + assert params == {} + + +def test_complex_type_annotation_in_param(): + docstring = """ + Function. + + Args: + data (Annotated[list[int], Field(min_length=1)]): Input data. + """ + _, params = parse_docstring(docstring) + assert params == {"data": "Input data."} diff --git a/tests/server/mcpserver/test_func_metadata.py b/tests/server/mcpserver/test_func_metadata.py index c57d1ee9f..c6379e820 100644 --- a/tests/server/mcpserver/test_func_metadata.py +++ b/tests/server/mcpserver/test_func_metadata.py @@ -1189,3 +1189,31 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc assert meta.output_schema is not None assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"} + + +def test_docstring_param_descriptions_populate_schema(): + def add_numbers(a: float, b: float) -> float: + """Adds two numbers and returns the result. + + Args: + a: The first number to add. + b: The second number to add. + """ + return a + b # pragma: no cover + + meta = func_metadata(add_numbers) + schema = meta.arg_model.model_json_schema() + + assert schema["properties"]["a"]["description"] == "The first number to add." + assert schema["properties"]["b"]["description"] == "The second number to add." + + +def test_docstring_without_args_section_leaves_descriptions_empty(): + def no_args_section(x: int) -> int: + """A function with no Args section in the docstring.""" + return x # pragma: no cover + + meta = func_metadata(no_args_section) + schema = meta.arg_model.model_json_schema() + + assert "description" not in schema["properties"]["x"] diff --git a/tests/server/mcpserver/test_tool_manager.py b/tests/server/mcpserver/test_tool_manager.py index e4dfd4ff9..761ebb017 100644 --- a/tests/server/mcpserver/test_tool_manager.py +++ b/tests/server/mcpserver/test_tool_manager.py @@ -33,6 +33,27 @@ def sum(a: int, b: int) -> int: # pragma: no cover assert tool.parameters["properties"]["a"]["type"] == "integer" assert tool.parameters["properties"]["b"]["type"] == "integer" + def test_function_with_google_style_docstring(self): + """The summary becomes the description and Args populate parameter descriptions.""" + + def add_numbers(a: float, b: float) -> float: # pragma: no cover + """Adds two numbers and returns the result. + + Args: + a: The first number to add. + b: The second number to add. + """ + return a + b + + manager = ToolManager() + manager.add_tool(add_numbers) + + tool = manager.get_tool("add_numbers") + assert tool is not None + assert tool.description == "Adds two numbers and returns the result." + assert tool.parameters["properties"]["a"]["description"] == "The first number to add." + assert tool.parameters["properties"]["b"]["description"] == "The second number to add." + def test_init_with_tools(self, caplog: pytest.LogCaptureFixture): def sum(a: int, b: int) -> int: # pragma: no cover return a + b From 6173587bd23a647a55127d94ec8f89aa2eea6270 Mon Sep 17 00:00:00 2001 From: Maanik Garg Date: Wed, 8 Apr 2026 09:52:37 +0530 Subject: [PATCH 2/2] test: cover defensive branches in docstring parser The MCP repo enforces 100% test coverage. Add two tests that exercise the previously uncovered defensive branches in _parse_param_line: - test_param_line_with_unclosed_parenthesis covers the end_idx == -1 fallback when a type annotation has no closing paren - test_param_line_starting_with_non_word covers the regex no-match fallback for lines that do not begin with a word character --- tests/server/mcpserver/test_docstring.py | 28 ++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/server/mcpserver/test_docstring.py b/tests/server/mcpserver/test_docstring.py index 9f0fe877d..bccac4d94 100644 --- a/tests/server/mcpserver/test_docstring.py +++ b/tests/server/mcpserver/test_docstring.py @@ -176,3 +176,31 @@ def test_complex_type_annotation_in_param(): """ _, params = parse_docstring(docstring) assert params == {"data": "Input data."} + + +def test_param_line_with_unclosed_parenthesis(): + """An unclosed type annotation should be treated as a non-param line.""" + docstring = """ + Function. + + Args: + broken (unclosed type: description + ok: A real param. + """ + summary, params = parse_docstring(docstring) + assert summary == "Function." + assert params == {"ok": "A real param."} + + +def test_param_line_starting_with_non_word(): + """A line that does not start with a word character should be skipped.""" + docstring = """ + Function. + + Args: + : orphan colon line + x: Real param. + """ + summary, params = parse_docstring(docstring) + assert summary == "Function." + assert params == {"x": "Real param."}