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
79 changes: 79 additions & 0 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import functools
import inspect
import json
import re
from collections.abc import Awaitable, Callable, Sequence
from itertools import chain
from types import GenericAlias
Expand Down Expand Up @@ -167,6 +168,74 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
)


# Regex patterns for extracting parameter descriptions from docstrings.
# Supports Google, NumPy, and Sphinx styles without any external dependencies.
_GOOGLE_ARGS_RE = re.compile(
r"(?:Args|Arguments|Parameters)\s*:\s*\n((?:[ \t]+.+\n?)+)",
re.IGNORECASE,
)
_GOOGLE_PARAM_RE = re.compile(
r"^[ \t]+(\w+)\s*(?:\(.+?\))?\s*:\s*(.+(?:\n(?:[ \t]+(?![ \t]*\w+\s*(?:\(.+?\))?\s*:).+))*)",
re.MULTILINE,
)
_SPHINX_PARAM_RE = re.compile(
r":param\s+(\w+)\s*:\s*(.+(?:\n(?:[ \t]+(?!:).+))*)",
re.MULTILINE,
)
_NUMPY_PARAMS_RE = re.compile(
r"(?:Parameters)\s*\n\s*-{3,}\s*\n((?:.*\n?)+?)(?:\n\s*\w+\s*\n\s*-{3,}|\Z)",
re.IGNORECASE,
)
_NUMPY_PARAM_RE = re.compile(
r"^(\w+)\s*(?::.*)?$\n((?:[ \t]+.+\n?)+)",
re.MULTILINE,
)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sets the root logger level, which will suppress all logging globally (not just griffe's) for the duration of the context manager. It's also not thread-safe — concurrent code that logs during this window will silently lose messages.

Should target the griffe logger specifically:

@contextmanager
def _suppress_griffe_logging() -> Iterator[None]:
    logger = logging.getLogger("_griffe")
    old_level = logger.getEffectiveLevel()
    logger.setLevel(logging.ERROR)
    yield
    logger.setLevel(old_level)

(_griffe is the internal logger name griffe uses — you can verify with griffe.logger)


def _parse_docstring_params(func: Callable[..., Any]) -> dict[str, str]:
"""Parse a function's docstring to extract parameter descriptions.

Supports Google, NumPy, and Sphinx-style docstrings using simple regex patterns.
No external dependencies required.

Returns:
A dict mapping parameter names to their descriptions.
"""
doc = func.__doc__
if not doc:
return {}

# Try Sphinx style first (:param name: description)
sphinx_matches = _SPHINX_PARAM_RE.findall(doc)
if sphinx_matches:
return {name: " ".join(desc.split()) for name, desc in sphinx_matches}

# Try Google style (Args: / Arguments: / Parameters:)
google_section = _GOOGLE_ARGS_RE.search(doc)
if google_section:
params = _GOOGLE_PARAM_RE.findall(google_section.group(1))
if params:
return {name: " ".join(desc.split()) for name, desc in params}

# Try NumPy style (Parameters\n----------)
numpy_section = _NUMPY_PARAMS_RE.search(doc)
if numpy_section:
params = _NUMPY_PARAM_RE.findall(numpy_section.group(1))
if params:
return {name: " ".join(desc.split()) for name, desc in params}

return {}


def _annotation_has_description(annotation: Any) -> bool:
"""Check if an Annotated type already includes a Field with a description."""
if get_origin(annotation) is Annotated:
for arg in get_args(annotation)[1:]:
if isinstance(arg, FieldInfo) and arg.description is not None:
return True
return False


def func_metadata(
func: Callable[..., Any],
skip_names: Sequence[str] = (),
Expand Down Expand Up @@ -215,6 +284,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
docstring_descriptions = _parse_docstring_params(func)
dynamic_pydantic_model_params: dict[str, Any] = {}
for param in params.values():
if param.name.startswith("_"): # pragma: no cover
Expand All @@ -229,6 +299,15 @@ def func_metadata(

if param.annotation is inspect.Parameter.empty:
field_metadata.append(WithJsonSchema({"title": param.name, "type": "string"}))

# Add description from docstring if no explicit Field description exists
if param.name in docstring_descriptions:
has_explicit_desc = _annotation_has_description(annotation) or (
isinstance(param.default, FieldInfo) and param.default.description is not None
)
if not has_explicit_desc:
field_kwargs["description"] = docstring_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
169 changes: 169 additions & 0 deletions tests/server/mcpserver/test_func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -1189,3 +1189,172 @@ 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_google_style():
"""Test that Google-style docstrings produce parameter descriptions in the schema."""

def greet(name: str, age: int) -> str: # pragma: no cover
"""Greet a user.

Args:
name: The user's full name
age: The user's age in years
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "The user's full name"
assert schema["properties"]["age"]["description"] == "The user's age in years"


def test_docstring_numpy_style():
"""Test that NumPy-style docstrings produce parameter descriptions in the schema."""

def greet(name: str, age: int) -> str: # pragma: no cover
"""Greet a user.

Parameters
----------
name
The user's full name
age
The user's age in years
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "The user's full name"
assert schema["properties"]["age"]["description"] == "The user's age in years"


def test_docstring_sphinx_style():
"""Test that Sphinx-style docstrings produce parameter descriptions in the schema."""

def greet(name: str, age: int) -> str: # pragma: no cover
"""Greet a user.

:param name: The user's full name
:param age: The user's age in years
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "The user's full name"
assert schema["properties"]["age"]["description"] == "The user's age in years"


def test_docstring_does_not_override_field_description():
"""Test that explicit Field descriptions take priority over docstring descriptions."""

def greet(
name: Annotated[str, Field(description="Explicit description")],
age: int,
) -> str: # pragma: no cover
"""Greet a user.

Args:
name: Docstring description that should be ignored
age: The user's age
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "Explicit description"
assert schema["properties"]["age"]["description"] == "The user's age"


def test_docstring_no_docstring():
"""Test that functions without docstrings still work correctly."""

def greet(name: str, age: int) -> str: # pragma: no cover
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert "description" not in schema["properties"]["name"]
assert "description" not in schema["properties"]["age"]


def test_docstring_with_default_values():
"""Test docstring descriptions work with default parameter values."""

def greet(name: str, age: int = 25) -> str: # pragma: no cover
"""Greet a user.

Args:
name: The user's full name
age: The user's age in years
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "The user's full name"
assert schema["properties"]["age"]["description"] == "The user's age in years"
assert schema["properties"]["age"]["default"] == 25


def test_docstring_partial_params():
"""Test that docstrings with only some parameters documented still work."""

def greet(name: str, age: int, city: str) -> str: # pragma: no cover
"""Greet a user.

Args:
name: The user's full name
"""
return f"{name} is {age} from {city}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert schema["properties"]["name"]["description"] == "The user's full name"
assert "description" not in schema["properties"]["age"]
assert "description" not in schema["properties"]["city"]


def test_docstring_no_args_section():
"""Test that docstrings without an Args section don't cause issues."""

def greet(name: str) -> str: # pragma: no cover
"""Greet a user by name."""
return f"Hello {name}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

assert "description" not in schema["properties"]["name"]


def test_docstring_with_annotated_non_field_metadata():
"""Test that docstring descriptions are used when Annotated has non-Field metadata."""

def greet(
name: Annotated[str, "some_metadata"],
age: int,
) -> str: # pragma: no cover
"""Greet a user.

Args:
name: The user's name
age: The user's age
"""
return f"{name} is {age}"

meta = func_metadata(greet)
schema = meta.arg_model.model_json_schema()

# Docstring description should be used since Annotated has no Field with description
assert schema["properties"]["name"]["description"] == "The user's name"
assert schema["properties"]["age"]["description"] == "The user's age"
Loading