diff --git a/src/mcp/server/fastmcp/tools/base.py b/src/mcp/server/fastmcp/tools/base.py index 1ae6d90d19..74b4e062cb 100644 --- a/src/mcp/server/fastmcp/tools/base.py +++ b/src/mcp/server/fastmcp/tools/base.py @@ -63,7 +63,7 @@ def from_function( if func_name == "": raise ValueError("You must provide a name for lambda functions") - func_doc = description or fn.__doc__ or "" + func_doc = description or inspect.getdoc(fn) or "" is_async = _is_async_callable(fn) if context_kwarg is None: # pragma: no branch diff --git a/src/mcp/server/fastmcp/utilities/func_metadata.py b/src/mcp/server/fastmcp/utilities/func_metadata.py index fa443d2fcb..9616271027 100644 --- a/src/mcp/server/fastmcp/utilities/func_metadata.py +++ b/src/mcp/server/fastmcp/utilities/func_metadata.py @@ -1,11 +1,15 @@ import inspect import json +import logging +import re from collections.abc import Awaitable, Callable, 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 pydantic_core +from griffe import Docstring, DocstringSectionKind, GoogleOptions from pydantic import ( BaseModel, ConfigDict, @@ -225,6 +229,8 @@ def func_metadata( raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e params = sig.parameters dynamic_pydantic_model_params: dict[str, Any] = {} + param_descriptions = get_param_descriptions(func) + for param in params.values(): if param.name.startswith("_"): # pragma: no cover raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'") @@ -246,6 +252,10 @@ def func_metadata( # Use a prefixed field name field_name = f"field_{field_name}" + param_description = param_descriptions.get(param.name) + if not has_description(param.annotation) and param_description: + field_kwargs["description"] = param_description + if param.default is not inspect.Parameter.empty: dynamic_pydantic_model_params[field_name] = ( Annotated[(annotation, *field_metadata, Field(**field_kwargs))], @@ -327,6 +337,19 @@ def func_metadata( wrap_output=wrap_output, ) +def has_description(tp: type) -> bool: + """ + given a type, check if it has already been given a description. + for example like: + var: Annotated[int, Field(description="hey")] + """ + if get_origin(tp) is not Annotated: + return False + for meta in get_args(tp): + if isinstance(meta, FieldInfo) and meta.description is not None: + return True + return False + def _try_create_model_and_schema( original_annotation: Any, @@ -531,3 +554,137 @@ def _convert_to_content( result = pydantic_core.to_json(result, fallback=str, indent=2).decode() return [TextContent(type="text", text=result)] + + +DocstringStyle = Literal["google", "numpy", "sphinx"] + + +@contextmanager +def _disable_griffe_logging(): + """disables griffe logging""" + # Hacky, but suggested here: https://github.com/mkdocstrings/griffe/issues/293#issuecomment-2167668117 + old_level = logging.root.getEffectiveLevel() + logging.root.setLevel(logging.ERROR) + yield + logging.root.setLevel(old_level) + + +def get_param_descriptions(func: Callable[..., Any]) -> dict[str, str]: + """ + given a function, return a dictionary of all parameters in the doc string of the function, + and their respective description. + the docstring formats supported are google, sphinx and numpy. + the implementation is taken from pedantic AI. + """ + doc = inspect.getdoc(func) + if doc is None: + return {} + + docstring_style = _infer_docstring_style(doc) + parser_options = ( + GoogleOptions(returns_named_value=False, returns_multiple_items=False) 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 = {} + if parameters := next((p for p in sections if p.kind == DocstringSectionKind.parameters), None): + params = {p.name: p.description for p in parameters.value} + + return params + + +def _infer_docstring_style(doc: str) -> DocstringStyle: + """Simplistic docstring style inference.""" + 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 + # fallback to google style + return "google" + + +# 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", + "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", + "other args", + "other arguments", + "other params", + "other parameters", + "raises", + "exceptions", + "returns", + "yields", + "receives", + "examples", + "attributes", + "functions", + "methods", + "classes", + "modules", + "warns", + "warnings", + ], + "google", + ), + ( + r"\n[ \t]*{0}\n[ \t]*---+\n", + [ + "deprecated", + "parameters", + "other parameters", + "returns", + "yields", + "receives", + "raises", + "warns", + "attributes", + "functions", + "methods", + "classes", + "modules", + ], + "numpy", + ), +] diff --git a/tests/server/fastmcp/test_func_metadata.py b/tests/server/fastmcp/test_func_metadata.py index 61e524290e..1f49698dfd 100644 --- a/tests/server/fastmcp/test_func_metadata.py +++ b/tests/server/fastmcp/test_func_metadata.py @@ -347,8 +347,14 @@ def test_complex_function_json_schema(): }, }, "properties": { - "an_int": {"title": "An Int", "type": "integer"}, - "must_be_none": {"title": "Must Be None", "type": "null"}, + "an_int": { + "title": "An Int", + "type": "integer", + }, + "must_be_none": { + "title": "Must Be None", + "type": "null", + }, "must_be_none_dumb_annotation": { "title": "Must Be None Dumb Annotation", "type": "null", @@ -385,10 +391,19 @@ def test_complex_function_json_schema(): "title": "Field With Default Via Field Annotation Before Nondefault Arg", "type": "integer", }, - "unannotated": {"title": "unannotated", "type": "string"}, - "my_model_a": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"}, - "my_model_b": {"$ref": "#/$defs/SomeInputModelB"}, + "unannotated": { + "title": "unannotated", + "type": "string", + }, + "my_model_a": { + "$ref": "#/$defs/SomeInputModelA", + }, + "my_model_a_forward_ref": { + "$ref": "#/$defs/SomeInputModelA", + }, + "my_model_b": { + "$ref": "#/$defs/SomeInputModelB", + }, "an_int_annotated_with_field_default": { "default": 1, "description": "An int with a field", @@ -1202,3 +1217,76 @@ 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 sphinx_arguments(a: int, b: int): + """ + test the discovery of parameter descriptions for the sphinx format + + :param a: parameter a + :param b: parameter b + + :return: valid return + """ + return "cat-person-statue" + + +def google_arguments(a: int, b: int): + """ + test the discovery of parameter descriptions for the google format + + Args: + a (int): parameter a + b (int): parameter b + + Returns: + str: valid return + """ + return "a very very very very large number" + + +def numpy_arguments(a: int, b: int): + """ + test the discovery of parameter descriptions for the numpy format + + Parameters + ---------- + a : int + parameter a + b : int + parameter b + + Returns + ------- + int + valid return + """ + return "I have nothing for this one" + + +def test_argument_description_formats(): + """ + tests the parsing of arguments from different formats, + currently supported formats are: google, sphinx, numpy + """ + expected_response = { + "properties": { + "a": {"description": "parameter a", "title": "A", "type": "integer"}, + "b": {"description": "parameter b", "title": "B", "type": "integer"}, + }, + "required": ["a", "b"], + "title": "", + "type": "object", + } + + google_schema = func_metadata(google_arguments).arg_model.model_json_schema() + google_schema["title"] = "" + assert google_schema == expected_response + + sphinx_schema = func_metadata(sphinx_arguments).arg_model.model_json_schema() + sphinx_schema["title"] = "" + assert sphinx_schema == expected_response + + numpy_schema = func_metadata(numpy_arguments).arg_model.model_json_schema() + numpy_schema["title"] = "" + assert numpy_schema == expected_response