-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add FastMCPToolset #2784
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add FastMCPToolset #2784
Changes from all commits
cffc38f
456900e
9bac437
1cf320e
edd89f2
9c4fe38
a46222f
27592c7
0362fd7
eaa45c8
4bd0334
533e879
f2be96d
0fd6929
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,274 @@ | ||||||||||||||||||||||||||||
from __future__ import annotations | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
import base64 | ||||||||||||||||||||||||||||
from asyncio import Lock | ||||||||||||||||||||||||||||
from contextlib import AsyncExitStack | ||||||||||||||||||||||||||||
from typing import TYPE_CHECKING, Any, Literal | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
from typing_extensions import Self | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
from pydantic_ai import messages | ||||||||||||||||||||||||||||
from pydantic_ai.exceptions import ModelRetry | ||||||||||||||||||||||||||||
from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition | ||||||||||||||||||||||||||||
from pydantic_ai.toolsets import AbstractToolset | ||||||||||||||||||||||||||||
from pydantic_ai.toolsets.abstract import ToolsetTool | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||
from fastmcp.client import Client | ||||||||||||||||||||||||||||
from fastmcp.client.transports import FastMCPTransport, MCPConfigTransport | ||||||||||||||||||||||||||||
from fastmcp.exceptions import ToolError | ||||||||||||||||||||||||||||
from fastmcp.mcp_config import MCPConfig | ||||||||||||||||||||||||||||
from fastmcp.server.server import FastMCP | ||||||||||||||||||||||||||||
from mcp.types import ( | ||||||||||||||||||||||||||||
AudioContent, | ||||||||||||||||||||||||||||
ContentBlock, | ||||||||||||||||||||||||||||
ImageContent, | ||||||||||||||||||||||||||||
TextContent, | ||||||||||||||||||||||||||||
Tool as MCPTool, | ||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
except ImportError as _import_error: | ||||||||||||||||||||||||||||
raise ImportError( | ||||||||||||||||||||||||||||
'Please install the `fastmcp` package to use the FastMCP server, ' | ||||||||||||||||||||||||||||
'you can use the `fastmcp` optional group — `pip install "pydantic-ai-slim[fastmcp]"`' | ||||||||||||||||||||||||||||
) from _import_error | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
if TYPE_CHECKING: | ||||||||||||||||||||||||||||
from fastmcp import FastMCP | ||||||||||||||||||||||||||||
from fastmcp.client.client import CallToolResult | ||||||||||||||||||||||||||||
from fastmcp.client.transports import FastMCPTransport | ||||||||||||||||||||||||||||
from fastmcp.mcp_config import MCPServerTypes | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
FastMCPToolResult = messages.BinaryContent | dict[str, Any] | str | None | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
ToolErrorBehavior = Literal['model_retry', 'error'] | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
class FastMCPToolset(AbstractToolset[AgentDepsT]): | ||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we make this a |
||||||||||||||||||||||||||||
"""A toolset that uses a FastMCP client as the underlying toolset.""" | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
tool_error_behavior: Literal['model_retry', 'error'] | ||||||||||||||||||||||||||||
fastmcp_client: Client[Any] | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
max_retries: int | ||||||||||||||||||||||||||||
Comment on lines
+54
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Once this is a
Suggested change
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
_id: str | None | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
_enter_lock: Lock | ||||||||||||||||||||||||||||
_running_count: int | ||||||||||||||||||||||||||||
_exit_stack: AsyncExitStack | None | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
def __init__( | ||||||||||||||||||||||||||||
self, | ||||||||||||||||||||||||||||
fastmcp_client: Client[Any], | ||||||||||||||||||||||||||||
*, | ||||||||||||||||||||||||||||
max_retries: int = 2, | ||||||||||||||||||||||||||||
tool_error_behavior: ToolErrorBehavior | None = None, | ||||||||||||||||||||||||||||
id: str | None = None, | ||||||||||||||||||||||||||||
): | ||||||||||||||||||||||||||||
"""Build a new FastMCPToolset. | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
Args: | ||||||||||||||||||||||||||||
fastmcp_client: The FastMCP client to use. | ||||||||||||||||||||||||||||
max_retries: The maximum number of retries for each tool during a run. | ||||||||||||||||||||||||||||
tool_error_behavior: The behavior to take when a tool error occurs. | ||||||||||||||||||||||||||||
id: An optional unique ID for the toolset. A toolset needs to have an ID in order to be used in a durable execution environment like Temporal, | ||||||||||||||||||||||||||||
in which case the ID will be used to identify the toolset's activities within the workflow. | ||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||
self.max_retries = max_retries | ||||||||||||||||||||||||||||
self.fastmcp_client = fastmcp_client | ||||||||||||||||||||||||||||
self._enter_lock = Lock() | ||||||||||||||||||||||||||||
self._running_count = 0 | ||||||||||||||||||||||||||||
self._id = id | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
self.tool_error_behavior = tool_error_behavior or 'error' | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
super().__init__() | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
@property | ||||||||||||||||||||||||||||
def id(self) -> str | None: | ||||||||||||||||||||||||||||
DouweM marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
return self._id | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
async def __aenter__(self) -> Self: | ||||||||||||||||||||||||||||
async with self._enter_lock: | ||||||||||||||||||||||||||||
if self._running_count == 0 and self.fastmcp_client: | ||||||||||||||||||||||||||||
self._exit_stack = AsyncExitStack() | ||||||||||||||||||||||||||||
await self._exit_stack.enter_async_context(self.fastmcp_client) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
self._running_count += 1 | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return self | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
async def __aexit__(self, *args: Any) -> bool | None: | ||||||||||||||||||||||||||||
async with self._enter_lock: | ||||||||||||||||||||||||||||
self._running_count -= 1 | ||||||||||||||||||||||||||||
if self._running_count == 0 and self._exit_stack: | ||||||||||||||||||||||||||||
await self._exit_stack.aclose() | ||||||||||||||||||||||||||||
self._exit_stack = None | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]: | ||||||||||||||||||||||||||||
async with self: | ||||||||||||||||||||||||||||
mcp_tools: list[MCPTool] = await self.fastmcp_client.list_tools() | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return { | ||||||||||||||||||||||||||||
tool.name: _convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self.max_retries) | ||||||||||||||||||||||||||||
for tool in mcp_tools | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
async def call_tool( | ||||||||||||||||||||||||||||
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT] | ||||||||||||||||||||||||||||
) -> Any: | ||||||||||||||||||||||||||||
async with self: | ||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||
call_tool_result: CallToolResult = await self.fastmcp_client.call_tool(name=name, arguments=tool_args) | ||||||||||||||||||||||||||||
except ToolError as e: | ||||||||||||||||||||||||||||
if self.tool_error_behavior == 'model_retry': | ||||||||||||||||||||||||||||
raise ModelRetry(message=str(e)) from e | ||||||||||||||||||||||||||||
else: | ||||||||||||||||||||||||||||
raise e | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
# If any of the results are not text content, let's map them to Pydantic AI binary message parts | ||||||||||||||||||||||||||||
if any(not isinstance(part, TextContent) for part in call_tool_result.content): | ||||||||||||||||||||||||||||
return _map_fastmcp_tool_results(parts=call_tool_result.content) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
# Otherwise, if we have structured content, return that | ||||||||||||||||||||||||||||
if call_tool_result.structured_content: | ||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this not take priority over non-text content? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Structured content was always populated for mcp types like AudioContent and ImageContent with dictionary representation of the models so if you prefer structured content then you can never get an BinaryMessage out of the tool call. I just refactored this in FastMCP jlowin/fastmcp#1773 and so can clean this up as soon as it lands in a release in the next couple of days There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @strawgate Hmm, we also have a PR open to have our
That suggests to me that |
||||||||||||||||||||||||||||
return call_tool_result.structured_content | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return _map_fastmcp_tool_results(parts=call_tool_result.content) | ||||||||||||||||||||||||||||
Comment on lines
+136
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this code structure slightly better, but this'll need a comment explaining why we can't always just return the structured content: (basically as you did in your PR comment)
Suggested change
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
@classmethod | ||||||||||||||||||||||||||||
def from_fastmcp_server( | ||||||||||||||||||||||||||||
cls, | ||||||||||||||||||||||||||||
fastmcp_server: FastMCP[Any], | ||||||||||||||||||||||||||||
*, | ||||||||||||||||||||||||||||
tool_error_behavior: ToolErrorBehavior | None = None, | ||||||||||||||||||||||||||||
tool_retries: int = 2, | ||||||||||||||||||||||||||||
) -> Self: | ||||||||||||||||||||||||||||
"""Build a FastMCPToolset from a FastMCP server. | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
Example: | ||||||||||||||||||||||||||||
```python | ||||||||||||||||||||||||||||
from fastmcp import FastMCP | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
from pydantic_ai.toolsets.fastmcp import FastMCPToolset | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
fastmcp_server = FastMCP('my_server') | ||||||||||||||||||||||||||||
@fastmcp_server.tool() | ||||||||||||||||||||||||||||
async def my_tool(a: int, b: int) -> int: | ||||||||||||||||||||||||||||
return a + b | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
FastMCPToolset.from_fastmcp_server(fastmcp_server=fastmcp_server) | ||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||
transport = FastMCPTransport(fastmcp_server) | ||||||||||||||||||||||||||||
fastmcp_client: Client[FastMCPTransport] = Client[FastMCPTransport](transport=transport) | ||||||||||||||||||||||||||||
return cls(fastmcp_client=fastmcp_client, max_retries=tool_retries, tool_error_behavior=tool_error_behavior) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
@classmethod | ||||||||||||||||||||||||||||
def from_mcp_server( | ||||||||||||||||||||||||||||
cls, | ||||||||||||||||||||||||||||
name: str, | ||||||||||||||||||||||||||||
mcp_server: MCPServerTypes | dict[str, Any], | ||||||||||||||||||||||||||||
*, | ||||||||||||||||||||||||||||
tool_error_behavior: ToolErrorBehavior | None = None, | ||||||||||||||||||||||||||||
strawgate marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||
tool_retries: int = 2, | ||||||||||||||||||||||||||||
) -> Self: | ||||||||||||||||||||||||||||
"""Build a FastMCPToolset from an individual MCP server configuration. | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
Example: | ||||||||||||||||||||||||||||
```python | ||||||||||||||||||||||||||||
from pydantic_ai.toolsets.fastmcp import FastMCPToolset | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
time_mcp_server = { | ||||||||||||||||||||||||||||
'command': 'uv', | ||||||||||||||||||||||||||||
'args': ['run', 'mcp-run-python', 'stdio'], | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
FastMCPToolset.from_mcp_server(name='time_server', mcp_server=time_mcp_server) | ||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||
mcp_config: MCPConfig = MCPConfig.from_dict(config={name: mcp_server}) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return cls.from_mcp_config( | ||||||||||||||||||||||||||||
mcp_config=mcp_config, tool_error_behavior=tool_error_behavior, max_retries=tool_retries | ||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
@classmethod | ||||||||||||||||||||||||||||
def from_mcp_config( | ||||||||||||||||||||||||||||
cls, | ||||||||||||||||||||||||||||
mcp_config: MCPConfig | dict[str, Any], | ||||||||||||||||||||||||||||
*, | ||||||||||||||||||||||||||||
tool_error_behavior: ToolErrorBehavior | None = None, | ||||||||||||||||||||||||||||
max_retries: int = 2, | ||||||||||||||||||||||||||||
) -> Self: | ||||||||||||||||||||||||||||
"""Build a FastMCPToolset from an MCP json-derived / dictionary configuration object. | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
Example: | ||||||||||||||||||||||||||||
```python | ||||||||||||||||||||||||||||
from pydantic_ai.toolsets.fastmcp import FastMCPToolset | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
mcp_config = { | ||||||||||||||||||||||||||||
'mcpServers': { | ||||||||||||||||||||||||||||
'first_server': { | ||||||||||||||||||||||||||||
'command': 'uv', | ||||||||||||||||||||||||||||
'args': ['run', 'mcp-run-python', 'stdio'], | ||||||||||||||||||||||||||||
}, | ||||||||||||||||||||||||||||
'second_server': { | ||||||||||||||||||||||||||||
'command': 'uv', | ||||||||||||||||||||||||||||
'args': ['run', 'mcp-run-python', 'stdio'], | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
FastMCPToolset.from_mcp_config(mcp_config) | ||||||||||||||||||||||||||||
``` | ||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||
transport: MCPConfigTransport = MCPConfigTransport(config=mcp_config) | ||||||||||||||||||||||||||||
fastmcp_client: Client[MCPConfigTransport] = Client[MCPConfigTransport](transport=transport) | ||||||||||||||||||||||||||||
return cls(fastmcp_client=fastmcp_client, max_retries=max_retries, tool_error_behavior=tool_error_behavior) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
def _convert_mcp_tool_to_toolset_tool( | ||||||||||||||||||||||||||||
toolset: FastMCPToolset[AgentDepsT], | ||||||||||||||||||||||||||||
mcp_tool: MCPTool, | ||||||||||||||||||||||||||||
retries: int, | ||||||||||||||||||||||||||||
) -> ToolsetTool[AgentDepsT]: | ||||||||||||||||||||||||||||
"""Convert an MCP tool to a toolset tool.""" | ||||||||||||||||||||||||||||
return ToolsetTool[AgentDepsT]( | ||||||||||||||||||||||||||||
tool_def=ToolDefinition( | ||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You'll want to keep an eye on #2880, we're going to expose the MCP tool metadata on ToolDefinition so it can be used when filtering etc. |
||||||||||||||||||||||||||||
name=mcp_tool.name, | ||||||||||||||||||||||||||||
description=mcp_tool.description, | ||||||||||||||||||||||||||||
parameters_json_schema=mcp_tool.inputSchema, | ||||||||||||||||||||||||||||
), | ||||||||||||||||||||||||||||
toolset=toolset, | ||||||||||||||||||||||||||||
max_retries=retries, | ||||||||||||||||||||||||||||
args_validator=TOOL_SCHEMA_VALIDATOR, | ||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult] | FastMCPToolResult: | ||||||||||||||||||||||||||||
"""Map FastMCP tool results to toolset tool results.""" | ||||||||||||||||||||||||||||
mapped_results = [_map_fastmcp_tool_result(part) for part in parts] | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
if len(mapped_results) == 1: | ||||||||||||||||||||||||||||
return mapped_results[0] | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
return mapped_results | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult: | ||||||||||||||||||||||||||||
if isinstance(part, TextContent): | ||||||||||||||||||||||||||||
return part.text | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
if isinstance(part, ImageContent | AudioContent): | ||||||||||||||||||||||||||||
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType) | ||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||
msg = f'Unsupported/Unknown content block type: {type(part)}' # pragma: no cover | ||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What are the other types? In |
||||||||||||||||||||||||||||
raise ValueError(msg) # pragma: no cover |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -88,6 +88,8 @@ cli = [ | |
] | ||
# MCP | ||
mcp = ["mcp>=1.12.3"] | ||
# FastMCP | ||
fastmcp = ["fastmcp>=2.12.0"] | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please also add it to the main |
||
# Evals | ||
evals = ["pydantic-evals=={{ version }}"] | ||
# A2A | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How common is this compared to creating it from a FastMCP client? In a hypothetical world where there's only Pydantic AI, this code snippet would just register the tool directly on the agent. I suppose this is useful in a monorepo? I think it'd be good to explain a bit more clearly what specific scenarios this functionality is meant for. And to have an example of using a FastMCP client.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The most common will be providing a FastMCP Client for sure. Ultimately this is just a helper that wraps the server in a client so we can remove it and just document how can you make a client for an in memory fastmcp server
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Having talked about this with Jeremiah, I wouldn't mind eventually making this the officially supported way to use MCP with Pydantic AI and handing over the responsibility to keep up with the MCP spec to them. That'd be a v2 thing, but in the meantime we can at least recommend people try this out if they want features we don't have on
MCPServer
yet, like MCP resources, prompts, progress...So what do you think about making it easier to build an
FastMCPToolset
from a URL + optional headers, or command + args + env, similar to how the variousMCPServer
subclasses are constructed? That'd be a simple wrapper method aroundfrom_mcp_config
, which then could be the primary use case documented here, with the FastMCP client, server, and mcp.json options secondary. We can document FastMCP as an alternative Pydantic AI + MCP implementation with broader feature support, rather than something specific to FastMCP servers.