Skip to content

Commit 2070059

Browse files
committed
Added descriptions for arguments taken from docstring
1 parent c7cbfbb commit 2070059

File tree

3 files changed

+253
-8
lines changed

3 files changed

+253
-8
lines changed

src/mcp/server/fastmcp/tools/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def from_function(
6363
if func_name == "<lambda>":
6464
raise ValueError("You must provide a name for lambda functions")
6565

66-
func_doc = description or fn.__doc__ or ""
66+
func_doc = description or inspect.getdoc(fn) or ""
6767
is_async = _is_async_callable(fn)
6868

6969
if context_kwarg is None: # pragma: no branch

src/mcp/server/fastmcp/utilities/func_metadata.py

Lines changed: 158 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import inspect
22
import json
3+
import logging
4+
import re
35
from collections.abc import Awaitable, Callable, Sequence
6+
from contextlib import contextmanager
47
from itertools import chain
58
from types import GenericAlias
6-
from typing import Annotated, Any, cast, get_args, get_origin, get_type_hints
9+
from typing import Annotated, Any, Literal, cast, get_args, get_origin, get_type_hints
710

811
import pydantic_core
12+
from griffe import Docstring, DocstringSectionKind, GoogleOptions
913
from pydantic import (
1014
BaseModel,
1115
ConfigDict,
@@ -225,6 +229,8 @@ def func_metadata(
225229
raise InvalidSignature(f"Unable to evaluate type annotations for callable {func.__name__!r}") from e
226230
params = sig.parameters
227231
dynamic_pydantic_model_params: dict[str, Any] = {}
232+
param_descriptions = get_param_descriptions(func)
233+
228234
for param in params.values():
229235
if param.name.startswith("_"): # pragma: no cover
230236
raise InvalidSignature(f"Parameter {param.name} of {func.__name__} cannot start with '_'")
@@ -246,6 +252,10 @@ def func_metadata(
246252
# Use a prefixed field name
247253
field_name = f"field_{field_name}"
248254

255+
param_description = param_descriptions.get(param.name)
256+
if not has_description(param.annotation) and param_description:
257+
field_kwargs["description"] = param_description
258+
249259
if param.default is not inspect.Parameter.empty:
250260
dynamic_pydantic_model_params[field_name] = (
251261
Annotated[(annotation, *field_metadata, Field(**field_kwargs))],
@@ -327,6 +337,19 @@ def func_metadata(
327337
wrap_output=wrap_output,
328338
)
329339

340+
def has_description(tp: type) -> bool:
341+
"""
342+
given a type, check if it has already been given a description.
343+
for example like:
344+
var: Annotated[int, Field(description="hey")]
345+
"""
346+
if get_origin(tp) is not Annotated:
347+
return False
348+
for meta in get_args(tp):
349+
if isinstance(meta, FieldInfo) and meta.description is not None:
350+
return True
351+
return False
352+
330353

331354
def _try_create_model_and_schema(
332355
original_annotation: Any,
@@ -531,3 +554,137 @@ def _convert_to_content(
531554
result = pydantic_core.to_json(result, fallback=str, indent=2).decode()
532555

533556
return [TextContent(type="text", text=result)]
557+
558+
559+
DocstringStyle = Literal["google", "numpy", "sphinx"]
560+
561+
562+
@contextmanager
563+
def _disable_griffe_logging():
564+
"""disables griffe logging"""
565+
# Hacky, but suggested here: https://github.com/mkdocstrings/griffe/issues/293#issuecomment-2167668117
566+
old_level = logging.root.getEffectiveLevel()
567+
logging.root.setLevel(logging.ERROR)
568+
yield
569+
logging.root.setLevel(old_level)
570+
571+
572+
def get_param_descriptions(func: Callable[..., Any]) -> dict[str, str]:
573+
"""
574+
given a function, return a dictionary of all parameters in the doc string of the function,
575+
and their respective description.
576+
the docstring formats supported are google, sphinx and numpy.
577+
the implementation is taken from pedantic AI.
578+
"""
579+
doc = inspect.getdoc(func)
580+
if doc is None:
581+
return {}
582+
583+
docstring_style = _infer_docstring_style(doc)
584+
parser_options = (
585+
GoogleOptions(returns_named_value=False, returns_multiple_items=False) if docstring_style == "google" else None
586+
)
587+
docstring = Docstring(
588+
doc,
589+
lineno=1,
590+
parser=docstring_style,
591+
parser_options=parser_options,
592+
)
593+
594+
with _disable_griffe_logging():
595+
sections = docstring.parse()
596+
597+
params = {}
598+
if parameters := next((p for p in sections if p.kind == DocstringSectionKind.parameters), None):
599+
params = {p.name: p.description for p in parameters.value}
600+
601+
return params
602+
603+
604+
def _infer_docstring_style(doc: str) -> DocstringStyle:
605+
"""Simplistic docstring style inference."""
606+
for pattern, replacements, style in _docstring_style_patterns:
607+
matches = (
608+
re.search(pattern.format(replacement), doc, re.IGNORECASE | re.MULTILINE) for replacement in replacements
609+
)
610+
if any(matches):
611+
return style
612+
# fallback to google style
613+
return "google"
614+
615+
616+
# See https://github.com/mkdocstrings/griffe/issues/329#issuecomment-2425017804
617+
_docstring_style_patterns: list[tuple[str, list[str], DocstringStyle]] = [
618+
(
619+
r"\n[ \t]*:{0}([ \t]+\w+)*:([ \t]+.+)?\n",
620+
[
621+
"param",
622+
"parameter",
623+
"arg",
624+
"argument",
625+
"key",
626+
"keyword",
627+
"type",
628+
"var",
629+
"ivar",
630+
"cvar",
631+
"vartype",
632+
"returns",
633+
"return",
634+
"rtype",
635+
"raises",
636+
"raise",
637+
"except",
638+
"exception",
639+
],
640+
"sphinx",
641+
),
642+
(
643+
r"\n[ \t]*{0}:([ \t]+.+)?\n[ \t]+.+",
644+
[
645+
"args",
646+
"arguments",
647+
"params",
648+
"parameters",
649+
"keyword args",
650+
"keyword arguments",
651+
"other args",
652+
"other arguments",
653+
"other params",
654+
"other parameters",
655+
"raises",
656+
"exceptions",
657+
"returns",
658+
"yields",
659+
"receives",
660+
"examples",
661+
"attributes",
662+
"functions",
663+
"methods",
664+
"classes",
665+
"modules",
666+
"warns",
667+
"warnings",
668+
],
669+
"google",
670+
),
671+
(
672+
r"\n[ \t]*{0}\n[ \t]*---+\n",
673+
[
674+
"deprecated",
675+
"parameters",
676+
"other parameters",
677+
"returns",
678+
"yields",
679+
"receives",
680+
"raises",
681+
"warns",
682+
"attributes",
683+
"functions",
684+
"methods",
685+
"classes",
686+
"modules",
687+
],
688+
"numpy",
689+
),
690+
]

tests/server/fastmcp/test_func_metadata.py

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -347,8 +347,14 @@ def test_complex_function_json_schema():
347347
},
348348
},
349349
"properties": {
350-
"an_int": {"title": "An Int", "type": "integer"},
351-
"must_be_none": {"title": "Must Be None", "type": "null"},
350+
"an_int": {
351+
"title": "An Int",
352+
"type": "integer",
353+
},
354+
"must_be_none": {
355+
"title": "Must Be None",
356+
"type": "null",
357+
},
352358
"must_be_none_dumb_annotation": {
353359
"title": "Must Be None Dumb Annotation",
354360
"type": "null",
@@ -385,10 +391,19 @@ def test_complex_function_json_schema():
385391
"title": "Field With Default Via Field Annotation Before Nondefault Arg",
386392
"type": "integer",
387393
},
388-
"unannotated": {"title": "unannotated", "type": "string"},
389-
"my_model_a": {"$ref": "#/$defs/SomeInputModelA"},
390-
"my_model_a_forward_ref": {"$ref": "#/$defs/SomeInputModelA"},
391-
"my_model_b": {"$ref": "#/$defs/SomeInputModelB"},
394+
"unannotated": {
395+
"title": "unannotated",
396+
"type": "string",
397+
},
398+
"my_model_a": {
399+
"$ref": "#/$defs/SomeInputModelA",
400+
},
401+
"my_model_a_forward_ref": {
402+
"$ref": "#/$defs/SomeInputModelA",
403+
},
404+
"my_model_b": {
405+
"$ref": "#/$defs/SomeInputModelB",
406+
},
392407
"an_int_annotated_with_field_default": {
393408
"default": 1,
394409
"description": "An int with a field",
@@ -1202,3 +1217,76 @@ def func_with_metadata() -> Annotated[int, Field(gt=1)]: ... # pragma: no branc
12021217

12031218
assert meta.output_schema is not None
12041219
assert meta.output_schema["properties"]["result"] == {"exclusiveMinimum": 1, "title": "Result", "type": "integer"}
1220+
1221+
1222+
def sphinx_arguments(a: int, b: int):
1223+
"""
1224+
test the discovery of parameter descriptions for the sphinx format
1225+
1226+
:param a: parameter a
1227+
:param b: parameter b
1228+
1229+
:return: valid return
1230+
"""
1231+
return "cat-person-statue"
1232+
1233+
1234+
def google_arguments(a: int, b: int):
1235+
"""
1236+
test the discovery of parameter descriptions for the google format
1237+
1238+
Args:
1239+
a (int): parameter a
1240+
b (int): parameter b
1241+
1242+
Returns:
1243+
str: valid return
1244+
"""
1245+
return "a very very very very large number"
1246+
1247+
1248+
def numpy_arguments(a: int, b: int):
1249+
"""
1250+
test the discovery of parameter descriptions for the numpy format
1251+
1252+
Parameters
1253+
----------
1254+
a : int
1255+
parameter a
1256+
b : int
1257+
parameter b
1258+
1259+
Returns
1260+
-------
1261+
int
1262+
valid return
1263+
"""
1264+
return "I have nothing for this one"
1265+
1266+
1267+
def test_argument_description_formats():
1268+
"""
1269+
tests the parsing of arguments from different formats,
1270+
currently supported formats are: google, sphinx, numpy
1271+
"""
1272+
expected_response = {
1273+
"properties": {
1274+
"a": {"description": "parameter a", "title": "A", "type": "integer"},
1275+
"b": {"description": "parameter b", "title": "B", "type": "integer"},
1276+
},
1277+
"required": ["a", "b"],
1278+
"title": "",
1279+
"type": "object",
1280+
}
1281+
1282+
google_schema = func_metadata(google_arguments).arg_model.model_json_schema()
1283+
google_schema["title"] = ""
1284+
assert google_schema == expected_response
1285+
1286+
sphinx_schema = func_metadata(sphinx_arguments).arg_model.model_json_schema()
1287+
sphinx_schema["title"] = ""
1288+
assert sphinx_schema == expected_response
1289+
1290+
numpy_schema = func_metadata(numpy_arguments).arg_model.model_json_schema()
1291+
numpy_schema["title"] = ""
1292+
assert numpy_schema == expected_response

0 commit comments

Comments
 (0)