Skip to content

Commit c745ebb

Browse files
committed
[minimcp] Add MCPFunc wrapper for function validation and execution
- Add MCPFunc class for validating and executing MCP handler functions - Support automatic schema generation from function signatures - Add argument validation and async/sync execution support - Add comprehensive unit test suite
1 parent 24bf23c commit c745ebb

File tree

2 files changed

+907
-0
lines changed

2 files changed

+907
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import functools
2+
import inspect
3+
from typing import Any
4+
5+
from pydantic import ValidationError
6+
7+
from mcp.server.fastmcp.utilities.func_metadata import FuncMetadata, func_metadata
8+
from mcp.server.minimcp.exceptions import InvalidArgumentsError, MCPFuncError
9+
from mcp.types import AnyFunction
10+
11+
12+
# TODO: Do performance profiling of this class, find hot spots and optimize.
13+
# This needs to be lean and fast.
14+
class MCPFunc:
15+
"""
16+
Validates and wraps a Python function for use as an MCP handler.
17+
18+
Function is valid if it satisfies the following conditions:
19+
- Is not a classmethod, staticmethod, or abstract method
20+
- Does not use *args or **kwargs (MCP requires explicit parameters)
21+
- Is a valid callable
22+
23+
Generates schemas from function signature and return type:
24+
- input_schema: Function parameters (via Pydantic model)
25+
- output_schema: Return type (optional, for structured output)
26+
27+
The execute() method can be called with a set of arguments. MCPFunc will
28+
validate the arguments against the function signature, call the function,
29+
and return the result.
30+
"""
31+
32+
func: AnyFunction
33+
name: str
34+
doc: str | None
35+
is_async: bool
36+
37+
meta: FuncMetadata
38+
input_schema: dict[str, Any]
39+
output_schema: dict[str, Any] | None
40+
41+
def __init__(self, func: AnyFunction, name: str | None = None):
42+
"""
43+
Args:
44+
func: The function to validate.
45+
name: The custom name to use for the function.
46+
"""
47+
48+
self._validate_func(func)
49+
50+
self.func = func
51+
self.name = self._get_name(name)
52+
self.doc = inspect.getdoc(func)
53+
self.is_async = self._is_async_callable(func)
54+
55+
self.meta = func_metadata(func)
56+
self.input_schema = self.meta.arg_model.model_json_schema(by_alias=True)
57+
self.output_schema = self.meta.output_schema
58+
59+
def _validate_func(self, func: AnyFunction) -> None:
60+
"""
61+
Validates a function's usability as an MCP handler function.
62+
63+
Validation fails for the following reasons:
64+
- If the function is a classmethod - MCP cannot inject cls as the first parameter
65+
- If the function is a staticmethod - @staticmethod returns a descriptor object, not a callable function
66+
- If the function is an abstract method - Abstract methods are not directly callable
67+
- If the function is not a function or method
68+
- If the function has *args or **kwargs - MCP cannot pass variable number of arguments
69+
70+
Args:
71+
func: The function to validate.
72+
73+
Raises:
74+
ValueError: If the function is not a valid MCP handler function.
75+
"""
76+
77+
if isinstance(func, classmethod):
78+
raise MCPFuncError("Function cannot be a classmethod")
79+
80+
if isinstance(func, staticmethod):
81+
raise MCPFuncError("Function cannot be a staticmethod")
82+
83+
if getattr(func, "__isabstractmethod__", False):
84+
raise MCPFuncError("Function cannot be an abstract method")
85+
86+
if not inspect.isroutine(func):
87+
raise MCPFuncError("Object passed is not a function or method")
88+
89+
sig = inspect.signature(func)
90+
for param in sig.parameters.values():
91+
if param.kind == inspect.Parameter.VAR_POSITIONAL:
92+
raise MCPFuncError("Functions with *args are not supported")
93+
if param.kind == inspect.Parameter.VAR_KEYWORD:
94+
raise MCPFuncError("Functions with **kwargs are not supported")
95+
96+
def _get_name(self, name: str | None) -> str:
97+
"""
98+
Infers the name of the function from the function object.
99+
100+
Args:
101+
name: The custom name to use for the function.
102+
103+
Raises:
104+
MCPFuncError: If the name cannot be inferred from the function and no custom name is provided.
105+
"""
106+
107+
if name:
108+
name = name.strip()
109+
110+
if not name:
111+
name = str(getattr(self.func, "__name__", None))
112+
113+
if not name:
114+
raise MCPFuncError("Name cannot be inferred from the function. Please provide a custom name.")
115+
elif name == "<lambda>":
116+
raise MCPFuncError("Lambda functions must be named. Please provide a custom name.")
117+
118+
return name
119+
120+
async def execute(self, args: dict[str, Any] | None = None) -> Any:
121+
"""
122+
Validates and executes the function with the given arguments and returns the result.
123+
If the function is asynchronous, it will be awaited.
124+
125+
Args:
126+
args: The arguments to pass to the function.
127+
128+
Returns:
129+
The result of the function execution.
130+
131+
Raises:
132+
InvalidArgumentsError: If the arguments are not valid.
133+
"""
134+
135+
try:
136+
arguments_pre_parsed = self.meta.pre_parse_json(args or {})
137+
arguments_parsed_model = self.meta.arg_model.model_validate(arguments_pre_parsed)
138+
arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
139+
except ValidationError as e:
140+
raise InvalidArgumentsError(f"Invalid arguments: {e}") from e
141+
142+
if self.is_async:
143+
return await self.func(**arguments_parsed_dict)
144+
else:
145+
return self.func(**arguments_parsed_dict)
146+
147+
def _is_async_callable(self, obj: AnyFunction) -> bool:
148+
"""
149+
Determines if a function is awaitable.
150+
151+
Args:
152+
obj: The function to determine if it is asynchronous.
153+
154+
Returns:
155+
True if the function is asynchronous, False otherwise.
156+
"""
157+
while isinstance(obj, functools.partial):
158+
obj = obj.func
159+
160+
return inspect.iscoroutinefunction(obj) or (
161+
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
162+
)

0 commit comments

Comments
 (0)