Skip to content
Open
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}

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)
Copy link
Collaborator

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.

Copy link
Contributor Author

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

Copy link
Collaborator

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 various MCPServer subclasses are constructed? That'd be a simple wrapper method around from_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.


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]):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we make this a @dataclass?

"""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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Once this is a dataclass, we can reorder this like this and may not need __init__ anymore. We can add the argument descriptions as field docstrings, and move things like setting the enter lock to a new __post_init__.

Suggested change
tool_error_behavior: Literal['model_retry', 'error']
fastmcp_client: Client[Any]
max_retries: int
fastmcp_client: Client[Any]
_: KW_ONLY
max_retries: int
tool_error_behavior: Literal['model_retry', 'error']


_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:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should this not take priority over non-text content?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Collaborator

Choose a reason for hiding this comment

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

@strawgate Hmm, we also have a PR open to have our MCPServer prefer structuredContent over regular unstructured content: #2854, to address #2742, which quotes MCP Specification section 5.2.6 Structured Content:

For backwards compatibility, a tool that returns structured content SHOULD also return the serialized JSON in a TextContent block.

That suggests to me that structuredContent should always be preferred, but from what you're saying there are MCP servers in the wild that wouldn't work well with that... Your logic here, to only prefer content if it has non-TextParts seems reasonable though -- I'll ask for the same to be implemented in that PR.

return call_tool_result.structured_content

return _map_fastmcp_tool_results(parts=call_tool_result.content)
Comment on lines +136 to +144
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
# 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)
if call_tool_result.structured_content and not any(not isinstance(part, TextContent) for part in call_tool_result.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(
Copy link
Collaborator

Choose a reason for hiding this comment

The 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

What are the other types?

In mcp.py, we have to specifically account for embedded resources or resource links (which we're possibly doing incorrectly: #2288)

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"]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please also add it to the main pyproject.toml, and as it says above "if you add optional groups, please update docs/install.md"

# 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