Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
cffc38f
Add FastMCP Toolset w/o tests
strawgate Sep 3, 2025
456900e
Adding tests
strawgate Sep 3, 2025
9bac437
PR Clean-up and coverage
strawgate Sep 4, 2025
1cf320e
Merge branch 'main' into fastmcp-toolset
strawgate Sep 4, 2025
edd89f2
Fix import
strawgate Sep 4, 2025
9c4fe38
Fix module import error
strawgate Sep 4, 2025
a46222f
Merge branch 'main' into fastmcp-toolset
strawgate Sep 4, 2025
27592c7
Trying to fix tests
strawgate Sep 4, 2025
0362fd7
Lint
strawgate Sep 6, 2025
eaa45c8
Merge branch 'main' into fastmcp-toolset
strawgate Sep 6, 2025
4bd0334
Address most PR Feedback
strawgate Sep 10, 2025
533e879
Merge branch 'main' into fastmcp-toolset
strawgate Sep 11, 2025
f2be96d
Address PR Feedback
strawgate Sep 11, 2025
0fd6929
Merge branch 'main' into fastmcp-toolset
strawgate Sep 11, 2025
881f306
Merge branch 'main' into fastmcp-toolset
strawgate Sep 29, 2025
dfcad61
Address PR Feedback
strawgate Sep 29, 2025
8776a67
add't updates
strawgate Sep 29, 2025
a171272
Add transport tests
strawgate Sep 29, 2025
6ea1dd3
Simplify init creation
strawgate Oct 1, 2025
81d004d
Update lock
strawgate Oct 1, 2025
880f355
Merge remote-tracking branch 'origin/main' into fastmcp-toolset
strawgate Oct 1, 2025
aade6d5
Remove accidental test file commit
strawgate Oct 1, 2025
8baad2d
Merge branch 'main' into fastmcp-toolset
strawgate Oct 11, 2025
31a3179
Updates to docs and tests
strawgate Oct 12, 2025
1c3b9e2
Merge branch 'main' into fastmcp-toolset
strawgate Oct 20, 2025
cc559d7
Fix test when package is not installed
strawgate Oct 20, 2025
623524b
lint
strawgate Oct 20, 2025
3766ba8
fix coverage test
strawgate Oct 20, 2025
e46bafc
Merge branch 'main' into fastmcp-toolset
strawgate Oct 21, 2025
71ab5ad
Merge branch 'main' into fastmcp-toolset
strawgate Oct 21, 2025
99c5042
Merge branch 'main' into fastmcp-toolset
DouweM Oct 21, 2025
34d36b9
Merge branch 'main' into pr/strawgate/2784
DouweM Oct 24, 2025
1b3e0fe
simplification
DouweM Oct 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ pip/uv-add "pydantic-ai-slim[openai]"
* `tavily` - installs `tavily-python` [PyPI ↗](https://pypi.org/project/tavily-python){:target="_blank"}
* `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"}
* `mcp` - installs `mcp` [PyPI ↗](https://pypi.org/project/mcp){:target="_blank"}
* `fastmcp` - installs `fastmcp` [PyPI ↗](https://pypi.org/project/fastmcp){:target="_blank"}
* `a2a` - installs `fasta2a` [PyPI ↗](https://pypi.org/project/fasta2a){:target="_blank"}
* `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"}
* `dbos` - installs [`dbos`](durable_execution/dbos.md) [PyPI ↗](https://pypi.org/project/dbos){:target="_blank"}
Expand Down
43 changes: 43 additions & 0 deletions docs/toolsets.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,49 @@ If you want to reuse a network connection or session across tool listings and ca

See the [MCP Client](./mcp/client.md) documentation for how to use MCP servers with Pydantic AI.

### FastMCP Tools {#fastmcp-tools}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note to self: before merging this, see if it makes sense to move this to a separate doc that can be listed in the "MCP" section in the sidebar.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made some changes in this direction


If you'd like to use tools from a [FastMCP](https://fastmcp.dev) Server, Client, or JSON MCP Configuration with Pydantic AI, you can use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] [toolset](toolsets.md).

To use the `FastMCPToolset`, you will need to install `pydantic-ai-slim[fastmcp]`.

```python {test="skip"}
from fastmcp import FastMCP

from pydantic_ai import Agent
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

toolset = FastMCPToolset.from_fastmcp_server(fastmcp_server)

agent = Agent('openai:gpt-5', toolsets=[toolset])
```

You can also use the [`FastMCPToolset`][pydantic_ai.toolsets.fastmcp.FastMCPToolset] to create a toolset from a JSON MCP Configuration. FastMCP supports additional capabilities on top of the MCP specification, like Tool Transformation in the MCP configuration that you can take advantage of with the `FastMCPToolset`.

```python {test="skip"}
from pydantic_ai import Agent
from pydantic_ai.toolsets.fastmcp import FastMCPToolset

mcp_config = {
'mcpServers': {
'time_mcp_server': {
'command': 'uvx',
'args': ['mcp-server-time']
}
}
}

toolset = FastMCPToolset.from_mcp_config(mcp_config)

agent = Agent('openai:gpt-5', toolsets=[toolset])
```


### LangChain Tools {#langchain-tools}

If you'd like to use tools or a [toolkit](https://python.langchain.com/docs/concepts/tools/#toolkits) from LangChain's [community tool library](https://python.langchain.com/docs/integrations/tools/) with Pydantic AI, you can use the [`LangChainToolset`][pydantic_ai.ext.langchain.LangChainToolset] which takes a list of LangChain tools. Note that Pydantic AI will not validate the arguments in this case -- it's up to the model to provide arguments matching the schema specified by the LangChain tool, and up to the LangChain tool to raise an error if the arguments are invalid.
Expand Down
274 changes: 274 additions & 0 deletions pydantic_ai_slim/pydantic_ai/toolsets/fastmcp.py
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]):
"""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

_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:
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:
return call_tool_result.structured_content

return _map_fastmcp_tool_results(parts=call_tool_result.content)

@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,
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(
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
raise ValueError(msg) # pragma: no cover
2 changes: 2 additions & 0 deletions pydantic_ai_slim/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ cli = [
]
# MCP
mcp = ["mcp>=1.12.3"]
# FastMCP
fastmcp = ["fastmcp>=2.12.0"]
# Evals
evals = ["pydantic-evals=={{ version }}"]
# A2A
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ requires-python = ">=3.10"

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

[tool.hatch.metadata.hooks.uv-dynamic-versioning.optional-dependencies]
Expand Down Expand Up @@ -90,6 +90,7 @@ dev = [
"coverage[toml]>=7.10.3",
"dirty-equals>=0.9.0",
"duckduckgo-search>=7.0.0",
"fastmcp>=2.12.0",
"inline-snapshot>=0.19.3",
"pytest>=8.3.3",
"pytest-examples>=0.0.18",
Expand Down
Loading