Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ classifiers = [
]
dependencies = [
"anyio>=4.9",
"griffe>=1.5.0",
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

griffe pulls in transitive deps (colorama, etc.) and is a non-trivial addition to the dependency tree for what's ultimately a convenience feature (docstring → description). Most users who care about tool descriptions are already using Field(description=...) or Annotated.

Worth considering:

  • Making this an optional/extra dependency (e.g., mcp[docstrings]), with a graceful fallback when not installed
  • Or using a simpler regex-based approach for the most common docstring formats (Google-style Args: covers the vast majority of cases)

Copy link
Copy Markdown

@pawamoy pawamoy Mar 27, 2026

Choose a reason for hiding this comment

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

You can depend on griffelib to avoid pulling CLI dependencies such as colorama. Anyway you seem to have opted for a no-deps approach which is totally fine to me 😄 Just wanted to provide details 🙂

By the way most Griffe dependents missed that our infer_docstring_style function is now public: https://mkdocstrings.github.io/griffe/reference/api/docstrings/parsers/#griffe.infer_docstring_style. You could also get inspiration from our own patterns.

"httpx>=0.27.1",
"httpx-sse>=0.4",
"pydantic>=2.12.0",
Expand Down
138 changes: 136 additions & 2 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import functools
import inspect
import json
from collections.abc import Awaitable, Callable, Sequence
import logging
import re
from collections.abc import Awaitable, Callable, Iterator, Sequence
from contextlib import contextmanager
from itertools import chain
from types import GenericAlias
from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
from typing import Annotated, Any, Literal, cast, get_args, get_origin, get_type_hints

import anyio
import anyio.to_thread
import pydantic_core
from griffe import Docstring, DocstringSectionKind
from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
from pydantic.fields import FieldInfo
from pydantic.json_schema import GenerateJsonSchema, JsonSchemaWarningKind
Expand Down Expand Up @@ -167,6 +171,126 @@ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
)


_DocstringStyle = Literal["google", "numpy", "sphinx"]

# Patterns to infer docstring style, adapted from pydantic-ai.
# Each entry is (pattern_template, replacement_keywords, style).
_DOCSTRING_STYLE_PATTERNS: list[tuple[str, list[str], _DocstringStyle]] = [
(
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
[
"param",
"parameter",
"arg",
"argument",
"key",
"keyword",
"type",
"var",
"ivar",
"cvar",
"vartype",
"returns",
"return",
"rtype",
"raises",
"raise",
"except",
"exception",
],
"sphinx",
),
(
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
[
"args",
"arguments",
"params",
"parameters",
"keyword args",
"keyword arguments",
"raises",
"exceptions",
"returns",
"yields",
"receives",
"examples",
"attributes",
],
"google",
),
(
r"\n[ \t]*{0}\n[ \t]*---+\n",
[
"deprecated",
"parameters",
"other parameters",
"returns",
"yields",
"receives",
"raises",
"warns",
"attributes",
],
"numpy",
),
]


def _infer_docstring_style(doc: str) -> _DocstringStyle:
"""Infer the docstring style from its content."""
for pattern, replacements, style in _DOCSTRING_STYLE_PATTERNS:
matches = (
re.search(pattern.format(replacement), doc, re.IGNORECASE | re.MULTILINE) for replacement in replacements
)
if any(matches):
return style
return "google"


@contextmanager
def _suppress_griffe_logging() -> Iterator[None]:
"""Temporarily suppress griffe's verbose logging."""
old_level = logging.root.getEffectiveLevel()
logging.root.setLevel(logging.ERROR)
yield
logging.root.setLevel(old_level)

Copy link
Copy Markdown

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe on a old version? On latest it's "griffe":

>>> import griffe
>>> griffe.logger._logger.name
'griffe'


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 with automatic format detection.

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

docstring_style = _infer_docstring_style(doc)
docstring = Docstring(doc, lineno=1, parser=docstring_style)

with _suppress_griffe_logging():
sections = docstring.parse()

for section in sections:
if section.kind == DocstringSectionKind.parameters:
return {p.name: p.description for p in section.value}

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 +339,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 +354,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"
2 changes: 2 additions & 0 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading