Skip to content

Commit 606643f

Browse files
strawgateDouweM
andauthored
Add FastMCPToolset (#2784)
Co-authored-by: Douwe Maan <[email protected]>
1 parent efa1e26 commit 606643f

File tree

12 files changed

+1205
-15
lines changed

12 files changed

+1205
-15
lines changed

docs/api/toolsets.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,5 @@
1414
- PreparedToolset
1515
- WrapperToolset
1616
- ToolsetFunc
17+
18+
::: pydantic_ai.toolsets.fastmcp

docs/install.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ pip/uv-add "pydantic-ai-slim[openai]"
5555
* `tavily` - installs `tavily-python` [PyPI ↗](https://pypi.org/project/tavily-python){:target="_blank"}
5656
* `cli` - installs `rich` [PyPI ↗](https://pypi.org/project/rich){:target="_blank"}, `prompt-toolkit` [PyPI ↗](https://pypi.org/project/prompt-toolkit){:target="_blank"}, and `argcomplete` [PyPI ↗](https://pypi.org/project/argcomplete){:target="_blank"}
5757
* `mcp` - installs `mcp` [PyPI ↗](https://pypi.org/project/mcp){:target="_blank"}
58+
* `fastmcp` - installs `fastmcp` [PyPI ↗](https://pypi.org/project/fastmcp){:target="_blank"}
5859
* `a2a` - installs `fasta2a` [PyPI ↗](https://pypi.org/project/fasta2a){:target="_blank"}
5960
* `ag-ui` - installs `ag-ui-protocol` [PyPI ↗](https://pypi.org/project/ag-ui-protocol){:target="_blank"} and `starlette` [PyPI ↗](https://pypi.org/project/starlette){:target="_blank"}
6061
* `dbos` - installs [`dbos`](durable_execution/dbos.md) [PyPI ↗](https://pypi.org/project/dbos){:target="_blank"}

docs/mcp/client.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ to use their tools.
55

66
## Install
77

8-
You need to either install [`pydantic-ai`](../install.md), or[`pydantic-ai-slim`](../install.md#slim-install) with the `mcp` optional group:
8+
You need to either install [`pydantic-ai`](../install.md), or [`pydantic-ai-slim`](../install.md#slim-install) with the `mcp` optional group:
99

1010
```bash
1111
pip/uv-add "pydantic-ai-slim[mcp]"

docs/mcp/fastmcp-client.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# FastMCP Client
2+
3+
[FastMCP](https://gofastmcp.com/) is a higher-level MCP framework that bills itself as "The fast, Pythonic way to build MCP servers and clients." It supports additional capabilities on top of the MCP specification like [Tool Transformation](https://gofastmcp.com/patterns/tool-transformation), [OAuth](https://gofastmcp.com/clients/auth/oauth), and more.
4+
5+
As an alternative to Pydantic AI's standard [`MCPServer` MCP client](client.md) built on the [MCP SDK](https://github.com/modelcontextprotocol/python-sdk), you can use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] [toolset](../toolsets.md) that leverages the [FastMCP Client](https://gofastmcp.com/clients/) to connect to local and remote MCP servers, whether or not they're built using [FastMCP Server](https://gofastmcp.com/servers/).
6+
7+
Note that it does not yet support integration elicitation or sampling, which are supported by the [standard `MCPServer` client](client.md).
8+
9+
## Install
10+
11+
To use the `FastMCPToolset`, you will need to install [`pydantic-ai-slim`](../install.md#slim-install) with the `fastmcp` optional group:
12+
13+
```bash
14+
pip/uv-add "pydantic-ai-slim[fastmcp]"
15+
```
16+
17+
## Usage
18+
19+
A `FastMCPToolset` can then be created from:
20+
21+
- A FastMCP Server: `#!python FastMCPToolset(fastmcp.FastMCP('my_server'))`
22+
- A FastMCP Client: `#!python FastMCPToolset(fastmcp.Client(...))`
23+
- A FastMCP Transport: `#!python FastMCPToolset(fastmcp.StdioTransport(command='uvx', args=['mcp-run-python', 'stdio']))`
24+
- A Streamable HTTP URL: `#!python FastMCPToolset('http://localhost:8000/mcp')`
25+
- An HTTP SSE URL: `#!python FastMCPToolset('http://localhost:8000/sse')`
26+
- A Python Script: `#!python FastMCPToolset('my_server.py')`
27+
- A Node.js Script: `#!python FastMCPToolset('my_server.js')`
28+
- A JSON MCP Configuration: `#!python FastMCPToolset({'mcpServers': {'my_server': {'command': 'uvx', 'args': ['mcp-run-python', 'stdio']}}})`
29+
30+
If you already have a [FastMCP Server](https://gofastmcp.com/servers) in the same codebase as your Pydantic AI agent, you can create a `FastMCPToolset` directly from it and save agent a network round trip:
31+
32+
```python
33+
from fastmcp import FastMCP
34+
35+
from pydantic_ai import Agent
36+
from pydantic_ai.toolsets.fastmcp import FastMCPToolset
37+
38+
fastmcp_server = FastMCP('my_server')
39+
@fastmcp_server.tool()
40+
async def add(a: int, b: int) -> int:
41+
return a + b
42+
43+
toolset = FastMCPToolset(fastmcp_server)
44+
45+
agent = Agent('openai:gpt-5', toolsets=[toolset])
46+
47+
async def main():
48+
result = await agent.run('What is 7 plus 5?')
49+
print(result.output)
50+
#> The answer is 12.
51+
```
52+
53+
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
54+
55+
Connecting your agent to a Streamable HTTP MCP Server is as simple as:
56+
57+
```python
58+
from pydantic_ai import Agent
59+
from pydantic_ai.toolsets.fastmcp import FastMCPToolset
60+
61+
toolset = FastMCPToolset('http://localhost:8000/mcp')
62+
63+
agent = Agent('openai:gpt-5', toolsets=[toolset])
64+
```
65+
66+
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_
67+
68+
You can also create a `FastMCPToolset` from a JSON MCP Configuration:
69+
70+
```python
71+
from pydantic_ai import Agent
72+
from pydantic_ai.toolsets.fastmcp import FastMCPToolset
73+
74+
mcp_config = {
75+
'mcpServers': {
76+
'time_mcp_server': {
77+
'command': 'uvx',
78+
'args': ['mcp-run-python', 'stdio']
79+
}
80+
}
81+
}
82+
83+
toolset = FastMCPToolset(mcp_config)
84+
85+
agent = Agent('openai:gpt-5', toolsets=[toolset])
86+
```
87+
88+
_(This example is complete, it can be run "as is" — you'll need to add `asyncio.run(main())` to run `main`)_

docs/mcp/overview.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# Model Context Protocol (MCP)
22

3-
Pydantic AI supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io) in two ways:
3+
Pydantic AI supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io) in multiple ways:
44

5-
1. [Agents](../agents.md) can connect to MCP servers and user their tools
6-
1. Pydantic AI can act as an MCP client and connect directly to local and remote MCP servers, [learn more …](client.md)
7-
2. Some model providers can themselves connect to remote MCP servers, [learn more …](../builtin-tools.md#mcp-server-tool)
8-
2. Agents can be used within MCP servers, [learn more …](server.md)
5+
1. [Agents](../agents.md) can connect to MCP servers and use their tools using three different methods:
6+
1. Pydantic AI can act as an MCP client and connect directly to local and remote MCP servers. [Learn more](client.md) about [`MCPServer`][pydantic_ai.mcp.MCPServer].
7+
2. Pydantic AI can use the [FastMCP Client](https://gofastmcp.com/clients/client/) to connect to local and remote MCP servers, whether or not they're built using [FastMCP Server](https://gofastmcp.com/servers). [Learn more](fastmcp-client.md) about [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset].
8+
3. Some model providers can themselves connect to remote MCP servers using a "built-in tool". [Learn more](../builtin-tools.md#mcp-server-tool) about [`MCPServerTool`][pydantic_ai.builtin_tools.MCPServerTool].
9+
2. Agents can be used within MCP servers. [Learn more](server.md)
910

1011
## What is MCP?
1112

docs/toolsets.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,7 +661,10 @@ If you want to reuse a network connection or session across tool listings and ca
661661

662662
### MCP Servers
663663

664-
See the [MCP Client](./mcp/client.md) documentation for how to use MCP servers with Pydantic AI.
664+
Pydantic AI provides two toolsets that allow an agent to connect to and call tools on local and remote MCP Servers:
665+
666+
1. `MCPServer`: the [MCP SDK-based Client](./mcp/client.md) which offers more direct control by leveraging the MCP SDK directly
667+
2. `FastMCPToolset`: the [FastMCP-based Client](./mcp/fastmcp-client.md) which offers additional capabilities like Tool Transformation, simpler OAuth configuration, and more.
665668

666669
### LangChain Tools {#langchain-tools}
667670

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ nav:
4949
- MCP:
5050
- Overview: mcp/overview.md
5151
- mcp/client.md
52+
- mcp/fastmcp-client.md
5253
- mcp/server.md
5354
- Multi-Agent Patterns: multi-agent-applications.md
5455
- Testing: testing.md
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
from __future__ import annotations
2+
3+
import base64
4+
from asyncio import Lock
5+
from contextlib import AsyncExitStack
6+
from dataclasses import KW_ONLY, dataclass
7+
from pathlib import Path
8+
from typing import TYPE_CHECKING, Any, Literal
9+
10+
from pydantic import AnyUrl
11+
from typing_extensions import Self, assert_never
12+
13+
from pydantic_ai import messages
14+
from pydantic_ai.exceptions import ModelRetry
15+
from pydantic_ai.tools import AgentDepsT, RunContext, ToolDefinition
16+
from pydantic_ai.toolsets import AbstractToolset
17+
from pydantic_ai.toolsets.abstract import ToolsetTool
18+
19+
try:
20+
from fastmcp.client import Client
21+
from fastmcp.client.transports import ClientTransport
22+
from fastmcp.exceptions import ToolError
23+
from fastmcp.mcp_config import MCPConfig
24+
from fastmcp.server import FastMCP
25+
from mcp.server.fastmcp import FastMCP as FastMCP1Server
26+
from mcp.types import (
27+
AudioContent,
28+
BlobResourceContents,
29+
ContentBlock,
30+
EmbeddedResource,
31+
ImageContent,
32+
ResourceLink,
33+
TextContent,
34+
TextResourceContents,
35+
Tool as MCPTool,
36+
)
37+
38+
from pydantic_ai.mcp import TOOL_SCHEMA_VALIDATOR
39+
40+
except ImportError as _import_error:
41+
raise ImportError(
42+
'Please install the `fastmcp` package to use the FastMCP server, '
43+
'you can use the `fastmcp` optional group — `pip install "pydantic-ai-slim[fastmcp]"`'
44+
) from _import_error
45+
46+
47+
if TYPE_CHECKING:
48+
from fastmcp.client.client import CallToolResult
49+
50+
51+
FastMCPToolResult = messages.BinaryContent | dict[str, Any] | str | None
52+
53+
ToolErrorBehavior = Literal['model_retry', 'error']
54+
55+
UNKNOWN_BINARY_MEDIA_TYPE = 'application/octet-stream'
56+
57+
58+
@dataclass(init=False)
59+
class FastMCPToolset(AbstractToolset[AgentDepsT]):
60+
"""A FastMCP Toolset that uses the FastMCP Client to call tools from a local or remote MCP Server.
61+
62+
The Toolset can accept a FastMCP Client, a FastMCP Transport, or any other object which a FastMCP Transport can be created from.
63+
64+
See https://gofastmcp.com/clients/transports for a full list of transports available.
65+
"""
66+
67+
client: Client[Any]
68+
"""The FastMCP client to use."""
69+
70+
_: KW_ONLY
71+
72+
tool_error_behavior: Literal['model_retry', 'error']
73+
"""The behavior to take when a tool error occurs."""
74+
75+
max_retries: int
76+
"""The maximum number of retries to attempt if a tool call fails."""
77+
78+
_id: str | None
79+
80+
def __init__(
81+
self,
82+
client: Client[Any]
83+
| ClientTransport
84+
| FastMCP
85+
| FastMCP1Server
86+
| AnyUrl
87+
| Path
88+
| MCPConfig
89+
| dict[str, Any]
90+
| str,
91+
*,
92+
max_retries: int = 1,
93+
tool_error_behavior: Literal['model_retry', 'error'] = 'model_retry',
94+
id: str | None = None,
95+
) -> None:
96+
if isinstance(client, Client):
97+
self.client = client
98+
else:
99+
self.client = Client[Any](transport=client)
100+
101+
self._id = id
102+
self.max_retries = max_retries
103+
self.tool_error_behavior = tool_error_behavior
104+
105+
self._enter_lock: Lock = Lock()
106+
self._running_count: int = 0
107+
self._exit_stack: AsyncExitStack | None = None
108+
109+
@property
110+
def id(self) -> str | None:
111+
return self._id
112+
113+
async def __aenter__(self) -> Self:
114+
async with self._enter_lock:
115+
if self._running_count == 0:
116+
self._exit_stack = AsyncExitStack()
117+
await self._exit_stack.enter_async_context(self.client)
118+
119+
self._running_count += 1
120+
121+
return self
122+
123+
async def __aexit__(self, *args: Any) -> bool | None:
124+
async with self._enter_lock:
125+
self._running_count -= 1
126+
if self._running_count == 0 and self._exit_stack:
127+
await self._exit_stack.aclose()
128+
self._exit_stack = None
129+
130+
return None
131+
132+
async def get_tools(self, ctx: RunContext[AgentDepsT]) -> dict[str, ToolsetTool[AgentDepsT]]:
133+
async with self:
134+
mcp_tools: list[MCPTool] = await self.client.list_tools()
135+
136+
return {
137+
tool.name: _convert_mcp_tool_to_toolset_tool(toolset=self, mcp_tool=tool, retries=self.max_retries)
138+
for tool in mcp_tools
139+
}
140+
141+
async def call_tool(
142+
self, name: str, tool_args: dict[str, Any], ctx: RunContext[AgentDepsT], tool: ToolsetTool[AgentDepsT]
143+
) -> Any:
144+
async with self:
145+
try:
146+
call_tool_result: CallToolResult = await self.client.call_tool(name=name, arguments=tool_args)
147+
except ToolError as e:
148+
if self.tool_error_behavior == 'model_retry':
149+
raise ModelRetry(message=str(e)) from e
150+
else:
151+
raise e
152+
153+
# If we have structured content, return that
154+
if call_tool_result.structured_content:
155+
return call_tool_result.structured_content
156+
157+
# Otherwise, return the content
158+
return _map_fastmcp_tool_results(parts=call_tool_result.content)
159+
160+
161+
def _convert_mcp_tool_to_toolset_tool(
162+
toolset: FastMCPToolset[AgentDepsT],
163+
mcp_tool: MCPTool,
164+
retries: int,
165+
) -> ToolsetTool[AgentDepsT]:
166+
"""Convert an MCP tool to a toolset tool."""
167+
return ToolsetTool[AgentDepsT](
168+
tool_def=ToolDefinition(
169+
name=mcp_tool.name,
170+
description=mcp_tool.description,
171+
parameters_json_schema=mcp_tool.inputSchema,
172+
metadata={
173+
'meta': mcp_tool.meta,
174+
'annotations': mcp_tool.annotations.model_dump() if mcp_tool.annotations else None,
175+
'output_schema': mcp_tool.outputSchema or None,
176+
},
177+
),
178+
toolset=toolset,
179+
max_retries=retries,
180+
args_validator=TOOL_SCHEMA_VALIDATOR,
181+
)
182+
183+
184+
def _map_fastmcp_tool_results(parts: list[ContentBlock]) -> list[FastMCPToolResult] | FastMCPToolResult:
185+
"""Map FastMCP tool results to toolset tool results."""
186+
mapped_results = [_map_fastmcp_tool_result(part) for part in parts]
187+
188+
if len(mapped_results) == 1:
189+
return mapped_results[0]
190+
191+
return mapped_results
192+
193+
194+
def _map_fastmcp_tool_result(part: ContentBlock) -> FastMCPToolResult:
195+
if isinstance(part, TextContent):
196+
return part.text
197+
elif isinstance(part, ImageContent | AudioContent):
198+
return messages.BinaryContent(data=base64.b64decode(part.data), media_type=part.mimeType)
199+
elif isinstance(part, EmbeddedResource):
200+
if isinstance(part.resource, BlobResourceContents):
201+
return messages.BinaryContent(
202+
data=base64.b64decode(part.resource.blob),
203+
media_type=part.resource.mimeType or UNKNOWN_BINARY_MEDIA_TYPE,
204+
)
205+
elif isinstance(part.resource, TextResourceContents):
206+
return part.resource.text
207+
else:
208+
assert_never(part.resource)
209+
elif isinstance(part, ResourceLink):
210+
# ResourceLink is not yet supported by the FastMCP toolset as reading resources is not yet supported.
211+
raise NotImplementedError(
212+
'ResourceLink is not supported by the FastMCP toolset as reading resources is not yet supported.'
213+
)
214+
else:
215+
assert_never(part)

pydantic_ai_slim/pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,8 @@ cli = [
8888
]
8989
# MCP
9090
mcp = ["mcp>=1.12.3"]
91+
# FastMCP
92+
fastmcp = ["fastmcp>=2.12.0"]
9193
# Evals
9294
evals = ["pydantic-evals=={{ version }}"]
9395
# A2A

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ requires-python = ">=3.10"
4646

4747
[tool.hatch.metadata.hooks.uv-dynamic-versioning]
4848
dependencies = [
49-
"pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,evals,ag-ui,retries,temporal,logfire]=={{ version }}",
49+
"pydantic-ai-slim[openai,vertexai,google,groq,anthropic,mistral,cohere,bedrock,huggingface,cli,mcp,fastmcp,evals,ag-ui,retries,temporal,logfire]=={{ version }}",
5050
]
5151

5252
[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]
@@ -232,6 +232,7 @@ filterwarnings = [
232232
"ignore:unclosed <socket:ResourceWarning",
233233
"ignore:unclosed event loop:ResourceWarning",
234234
]
235+
# addopts = ["--inline-snapshot=create,fix"]
235236

236237
# https://coverage.readthedocs.io/en/latest/config.html#run
237238
[tool.coverage.run]

0 commit comments

Comments
 (0)