Skip to content

Commit 00eae9a

Browse files
committed
[minimcp] Add ToolManager for tool registration and execution
- Implement ToolManager class for managing MCP tool handlers - Add tool registration via decorator (@mcp.tool()) or programmatically - Support tool listing, calling, and removal operations - Add automatic schema inference from function signatures - Add error handling with special tool exceptions - Add comprehensive unit test suite
1 parent c745ebb commit 00eae9a

File tree

3 files changed

+1138
-0
lines changed

3 files changed

+1138
-0
lines changed

src/mcp/server/minimcp/managers/__init__.py

Whitespace-only changes.
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
import builtins
2+
import logging
3+
from collections.abc import Callable
4+
from functools import partial
5+
from typing import Any
6+
7+
from typing_extensions import TypedDict, Unpack
8+
9+
import mcp.types as types
10+
from mcp.server.lowlevel.server import CombinationContent, Server
11+
from mcp.server.minimcp.exceptions import (
12+
InvalidArgumentsError,
13+
MCPRuntimeError,
14+
PrimitiveError,
15+
ToolInvalidArgumentsError,
16+
ToolMCPRuntimeError,
17+
ToolPrimitiveError,
18+
)
19+
from mcp.server.minimcp.utils.mcp_func import MCPFunc
20+
21+
logger = logging.getLogger(__name__)
22+
23+
24+
class ToolDefinition(TypedDict, total=False):
25+
"""
26+
Type definition for tool parameters.
27+
28+
Attributes:
29+
name: Optional unique identifier for the tool. If not provided, the function name is used.
30+
Must be unique across all tools in the server.
31+
title: Optional human-readable name for display purposes. Shows in client UIs to help users
32+
understand which tools are being exposed to the AI model.
33+
description: Optional human-readable description of tool functionality. If not provided,
34+
the function's docstring is used.
35+
annotations: Optional annotations describing tool behavior. For trust & safety, clients must
36+
consider annotations untrusted unless from trusted servers.
37+
meta: Optional metadata dictionary for additional tool information.
38+
"""
39+
40+
name: str | None
41+
title: str | None
42+
description: str | None
43+
annotations: types.ToolAnnotations | None
44+
meta: dict[str, Any] | None
45+
46+
47+
class ToolManager:
48+
"""
49+
ToolManager is responsible for registration and execution of MCP tool handlers.
50+
51+
The Model Context Protocol (MCP) allows servers to expose tools that can be invoked by language
52+
models. Tools enable models to interact with external systems, such as querying databases, calling
53+
APIs, or performing computations. Each tool is uniquely identified by a name and includes metadata
54+
describing its schema.
55+
56+
The ToolManager can be used as a decorator (@mcp.tool()) or programmatically via the mcp.tool.add(),
57+
mcp.tool.list(), mcp.tool.call() and mcp.tool.remove() methods.
58+
59+
When a tool handler is added, its name and description are automatically inferred from the handler
60+
function. You can override these by passing explicit parameters. The inputSchema and outputSchema
61+
are automatically generated from function type annotations. Tools support both structured and
62+
unstructured content in results.
63+
64+
Tool results can contain multiple content types (text, image, audio, resource links, embedded
65+
resources) and support optional annotations. All content types support annotations for metadata
66+
about audience, priority, and modification times.
67+
68+
For more details, see: https://modelcontextprotocol.io/specification/2025-06-18/server/tools
69+
70+
Example:
71+
@mcp.tool()
72+
def get_weather(location: str) -> str:
73+
'''Get current weather information for a location'''
74+
return f"Weather in {location}: 72°F, Partly cloudy"
75+
76+
# With display title and annotations
77+
@mcp.tool(title="Weather Information Provider", annotations={"priority": 0.9})
78+
def get_weather(location: str) -> dict:
79+
'''Get current weather data for a location'''
80+
return {"temperature": 72, "conditions": "Partly cloudy"}
81+
82+
# Or programmatically:
83+
mcp.tool.add(get_weather, title="Weather Provider")
84+
"""
85+
86+
_tools: dict[str, tuple[types.Tool, MCPFunc]]
87+
88+
def __init__(self, core: Server):
89+
"""
90+
Args:
91+
core: The low-level MCP Server instance to hook into.
92+
"""
93+
self._tools = {}
94+
self._hook_core(core)
95+
96+
def _hook_core(self, core: Server) -> None:
97+
"""Register tool handlers with the MCP core server.
98+
99+
Args:
100+
core: The low-level MCP Server instance to hook into.
101+
"""
102+
core.list_tools()(self._async_list)
103+
104+
# Validation done by func_meta in call. Hence passing validate_input=False
105+
# TODO: Ensure only one validation is required
106+
core.call_tool(validate_input=False)(self._call)
107+
108+
def __call__(self, **kwargs: Unpack[ToolDefinition]) -> Callable[[types.AnyFunction], types.Tool]:
109+
"""Decorator to add/register a tool handler at the time of handler function definition.
110+
111+
Tool name and description are automatically inferred from the handler function. You can override
112+
these by passing explicit parameters (name, title, description, annotations, meta). The inputSchema
113+
and outputSchema are automatically generated from function type annotations.
114+
115+
Args:
116+
**kwargs: Optional tool definition parameters (name, title, description, annotations, meta).
117+
Parameters are defined in the ToolDefinition class.
118+
119+
Returns:
120+
A decorator function that adds the tool handler.
121+
122+
Example:
123+
@mcp.tool(title="Weather Information Provider")
124+
def get_weather(location: str) -> dict:
125+
return {"temperature": 72, "conditions": "Partly cloudy"}
126+
"""
127+
return partial(self.add, **kwargs)
128+
129+
def add(self, func: types.AnyFunction, **kwargs: Unpack[ToolDefinition]) -> types.Tool:
130+
"""To programmatically add/register a tool handler function.
131+
132+
This is useful when the handler function is already defined and you have a function object
133+
that needs to be registered at runtime.
134+
135+
If not provided, the tool name (unique identifier) and description are automatically inferred
136+
from the function's name and docstring. The title field should be provided for better display
137+
in client UIs. The inputSchema and outputSchema are automatically generated from function type
138+
annotations using Pydantic models for validation.
139+
140+
Handler functions can return various content types:
141+
- Unstructured content: str, bytes, list of content blocks
142+
- Structured content: dict (returned in structuredContent field)
143+
- Combination: tuple of (unstructured, structured)
144+
145+
Tool results support multiple content types per MCP specification:
146+
- Text content (type: "text")
147+
- Image content (type: "image") - base64-encoded
148+
- Audio content (type: "audio") - base64-encoded
149+
- Resource links (type: "resource_link")
150+
- Embedded resources (type: "resource")
151+
152+
Args:
153+
func: The tool handler function. Can be synchronous or asynchronous. Should return
154+
content that can be converted to tool result format.
155+
**kwargs: Optional tool definition parameters to override inferred values
156+
(name, title, description, annotations, meta). Parameters are defined in
157+
the ToolDefinition class.
158+
159+
Returns:
160+
The registered Tool object with unique identifier, inputSchema, optional outputSchema,
161+
and optional annotations.
162+
163+
Raises:
164+
PrimitiveError: If a tool with the same name is already registered
165+
MCPFuncError: If the function cannot be used as a MCP handler function
166+
"""
167+
168+
tool_func = MCPFunc(func, kwargs.get("name"))
169+
if tool_func.name in self._tools:
170+
raise PrimitiveError(f"Tool {tool_func.name} already registered")
171+
172+
tool = types.Tool(
173+
name=tool_func.name,
174+
title=kwargs.get("title", None),
175+
description=kwargs.get("description", tool_func.doc),
176+
inputSchema=tool_func.input_schema,
177+
outputSchema=tool_func.output_schema,
178+
annotations=kwargs.get("annotations", None),
179+
_meta=kwargs.get("meta", None),
180+
)
181+
182+
self._tools[tool_func.name] = (tool, tool_func)
183+
logger.debug("Tool %s added", tool_func.name)
184+
185+
return tool
186+
187+
def remove(self, name: str) -> types.Tool:
188+
"""Remove a tool by name.
189+
190+
Args:
191+
name: The name of the tool to remove.
192+
193+
Returns:
194+
The removed Tool object.
195+
196+
Raises:
197+
PrimitiveError: If the tool is not found.
198+
"""
199+
if name not in self._tools:
200+
# Raise INVALID_PARAMS as per MCP specification
201+
raise PrimitiveError(f"Unknown tool: {name}")
202+
203+
logger.debug("Removing tool %s", name)
204+
return self._tools.pop(name)[0]
205+
206+
async def _async_list(self) -> builtins.list[types.Tool]:
207+
"""Async wrapper for list().
208+
209+
Returns:
210+
A list of all registered Tool objects.
211+
"""
212+
return self.list()
213+
214+
def list(self) -> builtins.list[types.Tool]:
215+
"""List all registered tools.
216+
217+
Returns:
218+
A list of all registered Tool objects.
219+
"""
220+
return [tool[0] for tool in self._tools.values()]
221+
222+
async def _call(self, name: str, args: dict[str, Any]) -> CombinationContent:
223+
"""Execute a tool by name, as specified in the MCP tools/call protocol.
224+
225+
This method handles the MCP tools/call request, executing the tool handler function with
226+
the provided arguments. Arguments are validated against the tool's inputSchema, and the
227+
result is converted to the appropriate tool result format per the MCP specification.
228+
229+
Tools use two error reporting mechanisms per the spec:
230+
1. Protocol Errors: Raised as a ToolPrimitiveError, ToolInvalidArgumentsError or ToolMCPRuntimeErrors
231+
2. Tool Execution Errors: Returned in result with isError=true (handled by lowlevel server)
232+
233+
Errors raised are of SpecialToolErrors type. SpecialToolErrors inherit from BaseException (not Exception)
234+
to bypass the low-level server's default exception handler during tool execution. This allows
235+
the tool manager to implement custom error handling and response formatting.
236+
237+
The result can contain:
238+
- Unstructured content: Array of content blocks (text, image, audio, resource links, embedded resources)
239+
- Structured content: JSON object (if outputSchema is defined)
240+
- Combination: Both unstructured and structured content
241+
242+
Args:
243+
name: The unique identifier of the tool to call.
244+
args: Dictionary of arguments to pass to the tool handler. Must conform to the
245+
tool's inputSchema. Arguments are validated by MCPFunc.
246+
247+
Returns:
248+
CombinationContent containing either unstructured content, structured content, or both,
249+
per the MCP protocol.
250+
251+
Raises:
252+
ToolPrimitiveError: If the tool is not found (maps to -32602 Invalid params per spec).
253+
ToolInvalidArgumentsError: If the tool arguments are invalid.
254+
ToolMCPRuntimeError: If an error occurs during tool execution (maps to -32603 Internal error).
255+
Note: Tool execution errors (API failures, invalid input data, business logic errors)
256+
are handled by the lowlevel server and returned with isError=true.
257+
"""
258+
if name not in self._tools:
259+
# Raise INVALID_PARAMS as per MCP specification
260+
raise ToolPrimitiveError(f"Unknown tool: {name}")
261+
262+
tool_func = self._tools[name][1]
263+
264+
try:
265+
# Exceptions on execution are captured by the core and returned as part of CallToolResult.
266+
result = await tool_func.execute(args)
267+
logger.debug("Tool %s handled with args %s", name, args)
268+
except InvalidArgumentsError as e:
269+
raise ToolInvalidArgumentsError(str(e)) from e
270+
271+
try:
272+
return tool_func.meta.convert_result(result)
273+
except Exception as e:
274+
msg = f"Error calling tool {name}: {e}"
275+
logger.exception(msg)
276+
raise ToolMCPRuntimeError(msg) from e
277+
278+
async def call(self, name: str, args: dict[str, Any]) -> CombinationContent:
279+
"""
280+
Wrapper for _call so that the tools can be called manually by the user. It converts
281+
the SpecialToolErrors to the appropriate MiniMCPError.
282+
283+
SpecialToolErrors inherit from BaseException (not Exception) to bypass the low-level
284+
server's default exception handler during tool execution. This allows the tool manager
285+
to implement custom error handling and response formatting.
286+
287+
Args:
288+
name: The unique identifier of the tool to call.
289+
args: Dictionary of arguments to pass to the tool handler. Must conform to the
290+
tool's inputSchema. Arguments are validated by MCPFunc.
291+
292+
Returns:
293+
CombinationContent containing either unstructured content, structured content, or both,
294+
per the MCP protocol.
295+
296+
Raises:
297+
PrimitiveError: If the tool is not found.
298+
InvalidArgumentsError: If the tool arguments are invalid.
299+
MCPRuntimeError: If an error occurs during tool execution.
300+
"""
301+
302+
try:
303+
return await self._call(name, args)
304+
except ToolPrimitiveError as e:
305+
raise PrimitiveError(str(e)) from e
306+
except ToolInvalidArgumentsError as e:
307+
raise InvalidArgumentsError(str(e)) from e
308+
except ToolMCPRuntimeError as e:
309+
raise MCPRuntimeError(str(e)) from e

0 commit comments

Comments
 (0)