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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ classifiers = [
]
dependencies = [
"anyio>=4.5",
"griffe>=1.0",
"httpx>=0.27.1",
"httpx-sse>=0.4",
"pydantic>=2.12.0",
Expand Down
7 changes: 6 additions & 1 deletion src/mcp/server/mcpserver/prompts/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from pydantic import BaseModel, Field, TypeAdapter, validate_call

from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
from mcp.server.mcpserver.utilities.docstring_utils import parse_docstring
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.types import ContentBlock, Icon, TextContent

Expand Down Expand Up @@ -101,10 +102,14 @@ def from_function(
if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

# Parse docstring to extract summary and parameter descriptions
doc_summary, param_descriptions = parse_docstring(fn)

# Get schema from func_metadata, excluding context parameter
func_arg_metadata = func_metadata(
fn,
skip_names=[context_kwarg] if context_kwarg is not None else [],
param_descriptions=param_descriptions,
)
parameters = func_arg_metadata.arg_model.model_json_schema()

Expand All @@ -127,7 +132,7 @@ def from_function(
return cls(
name=func_name,
title=title,
description=description or fn.__doc__ or "",
description=description or doc_summary or fn.__doc__ or "",
arguments=arguments,
fn=fn,
icons=icons,
Expand Down
7 changes: 6 additions & 1 deletion src/mcp/server/mcpserver/resources/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from mcp.server.mcpserver.resources.types import FunctionResource, Resource
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
from mcp.server.mcpserver.utilities.docstring_utils import parse_docstring
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
from mcp.types import Annotations, Icon

Expand Down Expand Up @@ -59,10 +60,14 @@ def from_function(
if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

# Parse docstring to extract summary and parameter descriptions
doc_summary, param_descriptions = parse_docstring(fn)

# Get schema from func_metadata, excluding context parameter
func_arg_metadata = func_metadata(
fn,
skip_names=[context_kwarg] if context_kwarg is not None else [],
param_descriptions=param_descriptions,
)
parameters = func_arg_metadata.arg_model.model_json_schema()

Expand All @@ -73,7 +78,7 @@ def from_function(
uri_template=uri_template,
name=func_name,
title=title,
description=description or fn.__doc__ or "",
description=description or doc_summary or fn.__doc__ or "",
mime_type=mime_type or "text/plain",
icons=icons,
annotations=annotations,
Expand Down
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_utils 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 @@ -63,16 +64,20 @@ def from_function(
if func_name == "<lambda>":
raise ValueError("You must provide a name for lambda functions")

func_doc = description or fn.__doc__ or ""
is_async = _is_async_callable(fn)

if context_kwarg is None: # pragma: no branch
context_kwarg = find_context_parameter(fn)

# Parse docstring to extract summary and parameter descriptions
doc_summary, param_descriptions = parse_docstring(fn)
func_doc = description or doc_summary or fn.__doc__ or ""

func_arg_metadata = func_metadata(
fn,
skip_names=[context_kwarg] if context_kwarg is not None else [],
structured_output=structured_output,
param_descriptions=param_descriptions,
)
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)

Expand Down
137 changes: 137 additions & 0 deletions src/mcp/server/mcpserver/utilities/docstring_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Utilities for parsing function docstrings to extract descriptions and parameter info.
Supports Google, NumPy, and Sphinx docstring formats with automatic detection.
Adapted from pydantic-ai's _griffe.py implementation.
"""

from __future__ import annotations

import logging
import re
from collections.abc import Callable
from contextlib import contextmanager
from typing import Any, Iterator, Literal

from griffe import Docstring, DocstringSectionKind

try:
from griffe import GoogleOptions

_GOOGLE_PARSER_OPTIONS = GoogleOptions(returns_named_value=False, returns_multiple_items=False)
except ImportError:
_GOOGLE_PARSER_OPTIONS = None

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


def parse_docstring(
func: Callable[..., Any],
) -> tuple[str | None, dict[str, str]]:
"""Extract the function summary and parameter descriptions from a docstring.
Automatically infers the docstring format (Google, NumPy, or Sphinx).
Returns:
A tuple of (summary, param_descriptions) where:
- summary: The main description text (first section), or None if no docstring
- param_descriptions: Dict mapping parameter names to their descriptions
"""
doc = func.__doc__
if doc is None:
return None, {}

docstring_style = _infer_docstring_style(doc)
parser_options = _GOOGLE_PARSER_OPTIONS if docstring_style == "google" else None
docstring = Docstring(
doc,
lineno=1,
parser=docstring_style,
parser_options=parser_options,
)
with _disable_griffe_logging():
sections = docstring.parse()

params: dict[str, str] = {}
if parameters := next(
(s for s in sections if s.kind == DocstringSectionKind.parameters), None
):
params = {p.name: p.description for p in parameters.value if p.description}

summary: str | None = None
if main := next(
(s for s in sections if s.kind == DocstringSectionKind.text), None
):
summary = main.value.strip() if main.value else None

return summary, params


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"


# Pattern matching for docstring style detection.
# See https://github.com/mkdocstrings/griffe/issues/329#issuecomment-2425017804
_DOCSTRING_STYLE_PATTERNS: list[tuple[str, list[str], DocstringStyle]] = [
(
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
[
"param",
"parameter",
"arg",
"argument",
"type",
"returns",
"return",
"rtype",
"raises",
"raise",
],
"sphinx",
),
(
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
[
"args",
"arguments",
"params",
"parameters",
"raises",
"returns",
"yields",
"examples",
"attributes",
],
"google",
),
(
r"\n[ \t]*{0}\n[ \t]*---+\n",
[
"parameters",
"returns",
"yields",
"raises",
"attributes",
],
"numpy",
),
]


@contextmanager
def _disable_griffe_logging() -> Iterator[None]:
"""Temporarily suppress griffe logging to avoid noisy warnings."""
old_level = logging.root.getEffectiveLevel()
logging.root.setLevel(logging.ERROR)
try:
yield
finally:
logging.root.setLevel(old_level)
31 changes: 31 additions & 0 deletions src/mcp/server/mcpserver/utilities/func_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ def func_metadata(
func: Callable[..., Any],
skip_names: Sequence[str] = (),
structured_output: bool | None = None,
param_descriptions: dict[str, str] | None = None,
) -> FuncMetadata:
"""Given a function, return metadata including a pydantic model representing its
signature.
Expand Down Expand Up @@ -203,6 +204,10 @@ def func_metadata(
- TypedDict - converted to a Pydantic model with same fields
- Dataclasses and other annotated classes - converted to Pydantic models
- Generic types (list, dict, Union, etc.) - wrapped in a model with a 'result' field
param_descriptions: Optional dict mapping parameter names to descriptions
extracted from the function's docstring. These are used as fallback
descriptions when a parameter does not already have a description
from a Field() annotation.
Returns:
A FuncMetadata object containing:
Expand Down Expand Up @@ -231,6 +236,13 @@ def func_metadata(

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

# Inject docstring parameter description as fallback, but only if the
# parameter doesn't already have a description from a Field() annotation.
if param_descriptions and param.name in param_descriptions:
if not _has_field_description(annotation, param.default):
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 Expand Up @@ -418,6 +430,25 @@ def _try_create_model_and_schema(
return None, None, False


def _has_field_description(annotation: Any, default: Any) -> bool:
"""Check if a parameter already has a description from a Field() annotation.
Checks both Annotated metadata (e.g., Annotated[int, Field(description="...")])
and default values (e.g., param: str = Field(description="...")).
"""
# Check if the default value is a FieldInfo with a description
if isinstance(default, FieldInfo) and default.description is not None:
return True

# Check if the annotation is Annotated with a FieldInfo that has 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


_no_default = object()


Expand Down
Loading