Skip to content

Add support for MCP servers #1100

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

Merged
merged 42 commits into from
Mar 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
c4f1726
Add support for MCP servers
Kludex Mar 11, 2025
bfc4be6
Add MCP support
Kludex Mar 12, 2025
46c0f17
Fix MCP Server
Kludex Mar 12, 2025
0e51d39
Done
Kludex Mar 12, 2025
a7181e4
fix docs
Kludex Mar 12, 2025
7e487c1
fix import
Kludex Mar 12, 2025
22483ad
Add test
Kludex Mar 12, 2025
0cae308
Add last test
Kludex Mar 12, 2025
ae4dadd
Add tests
Kludex Mar 12, 2025
76b8759
Add tests
Kludex Mar 12, 2025
80ba00f
drop cast
Kludex Mar 12, 2025
faf6309
Update pydantic_ai_slim/pydantic_ai/agent.py
Kludex Mar 12, 2025
0f9799b
try now
Kludex Mar 12, 2025
03f7955
docs
Kludex Mar 12, 2025
6f7b724
Pass pipeline
Kludex Mar 14, 2025
f51cc37
Use my branch on MCP server
Kludex Mar 14, 2025
521124b
pass pipeline
Kludex Mar 14, 2025
602134c
Update the code
Kludex Mar 14, 2025
2b3ebea
use mcp 1.4.1
Kludex Mar 14, 2025
1def930
Update docs
Kludex Mar 14, 2025
d23a9a0
Update docs
Kludex Mar 14, 2025
131af07
bump to 1.4.1
Kludex Mar 14, 2025
7f32d05
Correct the uv lock
Kludex Mar 14, 2025
1cf04ac
Apply suggestions from code review
Kludex Mar 14, 2025
8648622
Update pydantic_ai_slim/pydantic_ai/mcp.py
Kludex Mar 14, 2025
21b98ce
Change test
Kludex Mar 14, 2025
0518681
Change test
Kludex Mar 14, 2025
75fff0f
apply more comments
Kludex Mar 14, 2025
8770d0c
docstring
Kludex Mar 14, 2025
fc9ef87
Add server instructions
Kludex Mar 14, 2025
62f5436
only 3.10 more
Kludex Mar 14, 2025
abb4d39
Add links
Kludex Mar 14, 2025
dd8f0c5
Add name main
Kludex Mar 14, 2025
9467c97
test only on 3.10+
Kludex Mar 14, 2025
2238bc1
Only the MCPRemoteServer test is failling
Kludex Mar 16, 2025
a5c8985
fix example tests
samuelcolvin Mar 16, 2025
549d514
fix for 3.9
samuelcolvin Mar 16, 2025
5805745
Add 100% coverage
Kludex Mar 17, 2025
4c27c44
Add note
Kludex Mar 17, 2025
94d733c
Merge branch 'main' into mcp-support
Kludex Mar 17, 2025
5418e33
Apply suggestions from code review
Kludex Mar 17, 2025
46cf989
Add Self import
Kludex Mar 17, 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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ jobs:

# this must run last as it modifies the environment!
- name: test lowest versions
if: matrix.python-version != '3.9'
run: |
unset UV_FROZEN
uv run --all-extras --resolution lowest-direct coverage run -m pytest
Expand Down
1 change: 1 addition & 0 deletions docs/api/mcp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: pydantic_ai.mcp
122 changes: 122 additions & 0 deletions docs/mcp_servers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# MCP Servers
Copy link
Member

Choose a reason for hiding this comment

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

I think this should be called "MCP Client" since pydantic-ai is acting as a client

Copy link
Member Author

Choose a reason for hiding this comment

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

I'm using the same notation everybody is tho. Claude Desktop, Cursor, and Cline use the "MCP Server" terminology even on the STDIO.

Copy link
Member

Choose a reason for hiding this comment

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

right, but we're building an MCP client, so we should call it that.

Copy link
Member Author

Choose a reason for hiding this comment

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

But the chapter is about MCP Servers. It far better for SEO. No one uses the term client, even when you are configuring the client: Cursor, Claude Desktop, Cline.

Copy link
Member

Choose a reason for hiding this comment

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

Maybe rephrase the introduction phrase as something like:

PydanticAI supports integration with MCP Servers and act as a MCP client...

maybe link with https://modelcontextprotocol.io/introduction#general-architecture.

Copy link
Member

Choose a reason for hiding this comment

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

we should have a new section in docs:

  • mcp/index.md - a general introduction saying PydanticAI can be used as an MCP client or to build servers, and comes with some servers
  • mcp/client.md - this page - describing how to use PydanticAI as an MCP client
  • mcp/run-python.md - docs for MCP server to run Python code in a sandbox #1140
  • ... more


**PydanticAI** supports integration with
[MCP (Model Control Protocol) Servers](https://modelcontextprotocol.io/introduction),
allowing you to extend agent capabilities through external services. This integration enables
dynamic tool discovery.

## Install

To use MCP servers, you need to either install [`pydantic-ai`](install.md), or install
[`pydantic-ai-slim`](install.md#slim-install) with the `mcp` optional group:

```bash
pip/uv-add 'pydantic-ai-slim[mcp]'
```

!!! note
MCP integration requires Python 3.10 or higher.

## Running MCP Servers

Before diving into how to use MCP servers with PydanticAI, let's look at how to run MCP servers
with different transports.

To run MCP servers, you'll need to install the MCP CLI package:

```bash
pip/uv-add 'mcp[cli]'
```

Here's a simple MCP server that provides a temperature conversion tool. We will later assume this is the server we connect to from our agent:

```python {title="temperature_mcp_server.py" py="3.10"}
from mcp.server.fastmcp import FastMCP

mcp = FastMCP('Temperature Conversion Server')


@mcp.tool()
async def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert Celsius to Fahrenheit.

Args:
celsius: Temperature in Celsius
"""
return (celsius * 9 / 5) + 32


if __name__ == '__main__':
mcp.run('stdio') # (1)!
```

1. Run with stdio transport (for subprocess communication).

The same server can be run with [SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse)
for HTTP communication:

```python {title="temperature_mcp_server_sse.py" py="3.10"}
from temperature_mcp_server import mcp

if __name__ == '__main__':
mcp.run('sse', port=8000) # (1)!
```

1. Run with SSE transport on port 8000.

## Usage

PydanticAI comes with two ways to connect to MCP servers:

- [`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE] which connects to an MCP server using the [HTTP SSE](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse) transport
- [`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio] which runs the server as a subprocess and connects to it using the [stdio](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio) transport

Examples of both are shown below.

### MCP Remote Server

You can have a MCP server running on a remote server. In this case, you'd use the
[`MCPServerSSE`][pydantic_ai.mcp.MCPServerSSE] class:

```python {title="mcp_remote_server.py" py="3.10"}
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerSSE

server = MCPServerSSE(url='http://localhost:8005/sse')
agent = Agent('openai:gpt-4o', mcp_servers=[server])


async def main():
async with agent.run_mcp_servers():
result = await agent.run('Can you convert 30 degrees celsius to fahrenheit?')
print(result.data)
#> 30 degrees Celsius is equal to 86 degrees Fahrenheit.
```

This will connect to the MCP server at the given URL and use the
[SSE transport](https://modelcontextprotocol.io/docs/concepts/transports#server-sent-events-sse).

### MCP Subprocess Server

We also have a subprocess-based server that can be used to run the MCP server in a separate process.
In this case, you'd use the [`MCPServerStdio`][pydantic_ai.mcp.MCPServerStdio] class,
when using `MCPServerStdio` you need to run the server with the [`run_mcp_servers`][pydantic_ai.Agent.run_mcp_servers]
context manager before running the server.

```python {title="mcp_subprocess_server.py" py="3.10"}
from pydantic_ai.agent import Agent
from pydantic_ai.mcp import MCPServerStdio

server = MCPServerStdio('python', ['-m', 'pydantic_ai_examples.mcp_server'])
agent = Agent('openai:gpt-4o', mcp_servers=[server])


async def main():
async with agent.run_mcp_servers():
result = await agent.run('Can you convert 30 degrees celsius to fahrenheit?')
print(result.data)
#> 30 degrees Celsius is equal to 86 degrees Fahrenheit.
```

This will start the MCP server in a separate process and connect to it using the
[stdio transport](https://modelcontextprotocol.io/docs/concepts/transports#standard-input%2Foutput-stdio).
37 changes: 37 additions & 0 deletions examples/pydantic_ai_examples/mcp_server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Simple MCP Server that can be used to test the MCP protocol.

Run with:

uv run -m pydantic_ai_examples.mcp_server --transport <TRANSPORT>

TRANSPORT can be either `sse` or `stdio`.
"""

import argparse

from mcp.server.fastmcp import FastMCP

mcp = FastMCP('PydanticAI MCP Server', port=8005)


@mcp.tool()
async def celsius_to_fahrenheit(celsius: float) -> float:
"""Convert Celsius to Fahrenheit.

Args:
celsius: Temperature in Celsius

Returns:
Temperature in Fahrenheit
"""
return (celsius * 9 / 5) + 32


if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument(
'--transport', type=str, default='stdio', choices=('sse', 'stdio')
)
args = parser.parse_args()

mcp.run(transport=args.transport)
1 change: 1 addition & 0 deletions examples/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies = [
"uvicorn>=0.32.0",
"devtools>=0.12.2",
"gradio>=5.9.0; python_version>'3.9'",
"mcp[cli]>=1.4.1; python_version >= '3.10'"
]

[tool.hatch.build.targets.wheel]
Expand Down
2 changes: 2 additions & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ nav:
- multi-agent-applications.md
- graph.md
- input.md
- mcp_servers.md
- cli.md
- Examples:
- examples/index.md
Expand Down Expand Up @@ -64,6 +65,7 @@ nav:
- api/models/function.md
- api/models/fallback.md
- api/providers.md
- api/mcp.md
- api/pydantic_graph/graph.md
- api/pydantic_graph/nodes.md
- api/pydantic_graph/persistence.md
Expand Down
68 changes: 61 additions & 7 deletions pydantic_ai_slim/pydantic_ai/_agent_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from contextlib import asynccontextmanager, contextmanager
from contextvars import ContextVar
from dataclasses import field
from typing import Any, Generic, Literal, Union, cast
from typing import TYPE_CHECKING, Any, Generic, Literal, Union, cast

from opentelemetry.trace import Span, Tracer
from typing_extensions import TypeGuard, TypeVar, assert_never
Expand All @@ -27,11 +27,10 @@
from .models.instrumented import InstrumentedModel
from .result import ResultDataT
from .settings import ModelSettings, merge_model_settings
from .tools import (
RunContext,
Tool,
ToolDefinition,
)
from .tools import RunContext, Tool, ToolDefinition

if TYPE_CHECKING:
from .mcp import MCPServer

__all__ = (
'GraphAgentState',
Expand Down Expand Up @@ -94,6 +93,7 @@ class GraphAgentDeps(Generic[DepsT, ResultDataT]):
result_validators: list[_result.ResultValidator[DepsT, ResultDataT]]

function_tools: dict[str, Tool[DepsT]] = dataclasses.field(repr=False)
mcp_servers: Sequence[MCPServer] = dataclasses.field(repr=False)

run_span: Span
tracer: Tracer
Expand Down Expand Up @@ -219,7 +219,17 @@ async def add_tool(tool: Tool[DepsT]) -> None:
if tool_def := await tool.prepare_tool_def(ctx):
function_tool_defs.append(tool_def)

await asyncio.gather(*map(add_tool, ctx.deps.function_tools.values()))
async def add_mcp_server_tools(server: MCPServer) -> None:
if not server.is_running:
raise exceptions.UserError(f'MCP server is not running: {server}')
tool_defs = await server.list_tools()
# TODO(Marcelo): We should check if the tool names are unique. If not, we should raise an error.
function_tool_defs.extend(tool_defs)

await asyncio.gather(
*map(add_tool, ctx.deps.function_tools.values()),
*map(add_mcp_server_tools, ctx.deps.mcp_servers),
)

result_schema = ctx.deps.result_schema
return models.ModelRequestParameters(
Expand Down Expand Up @@ -594,6 +604,21 @@ async def process_function_tools(
yield event
call_index_to_event_id[len(calls_to_run)] = event.call_id
calls_to_run.append((tool, call))
elif mcp_tool := await _tool_from_mcp_server(call.tool_name, ctx):
if stub_function_tools:
# TODO(Marcelo): We should add coverage for this part of the code.
output_parts.append( # pragma: no cover
_messages.ToolReturnPart(
tool_name=call.tool_name,
content='Tool not executed - a final result was already processed.',
tool_call_id=call.tool_call_id,
)
)
else:
event = _messages.FunctionToolCallEvent(call)
yield event
call_index_to_event_id[len(calls_to_run)] = event.call_id
calls_to_run.append((mcp_tool, call))
elif result_schema is not None and call.tool_name in result_schema.tools:
# if tool_name is in _result_schema, it means we found a result tool but an error occurred in
# validation, we don't add another part here
Expand Down Expand Up @@ -641,6 +666,35 @@ async def process_function_tools(
output_parts.append(results_by_index[k])


async def _tool_from_mcp_server(
tool_name: str,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
) -> Tool[DepsT] | None:
"""Call each MCP server to find the tool with the given name.

Args:
tool_name: The name of the tool to find.
ctx: The current run context.

Returns:
The tool with the given name, or `None` if no tool with the given name is found.
"""

async def run_tool(ctx: RunContext[DepsT], **args: Any) -> Any:
# There's no normal situation where the server will not be running at this point, we check just in case
# some weird edge case occurs.
if not server.is_running: # pragma: no cover
raise exceptions.UserError(f'MCP server is not running: {server}')
result = await server.call_tool(tool_name, args)
return result

for server in ctx.deps.mcp_servers:
tools = await server.list_tools()
if tool_name in {tool.name for tool in tools}:
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to do anything to ensure there aren’t naming conflicts between servers?

Copy link
Member Author

Choose a reason for hiding this comment

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

What should I do? Error? 🤔

Copy link
Contributor

Choose a reason for hiding this comment

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

I guess so, or namespace the tools by server or something

return Tool(name=tool_name, function=run_tool, takes_ctx=True)
return None


def _unknown_tool(
tool_name: str,
ctx: GraphRunContext[GraphAgentState, GraphAgentDeps[DepsT, NodeRunEndT]],
Expand Down
27 changes: 25 additions & 2 deletions pydantic_ai_slim/pydantic_ai/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import dataclasses
import inspect
from collections.abc import AsyncIterator, Awaitable, Iterator, Sequence
from contextlib import AbstractAsyncContextManager, asynccontextmanager, contextmanager
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager, contextmanager
from copy import deepcopy
from types import FrameType
from typing import Any, Callable, ClassVar, Generic, cast, final, overload
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, cast, final, overload

from opentelemetry.trace import NoOpTracer, use_span
from typing_extensions import TypeGuard, TypeVar, deprecated
Expand Down Expand Up @@ -47,6 +47,9 @@
ModelRequestNode = _agent_graph.ModelRequestNode
UserPromptNode = _agent_graph.UserPromptNode

if TYPE_CHECKING:
from pydantic_ai.mcp import MCPServer

__all__ = (
'Agent',
'AgentRun',
Expand Down Expand Up @@ -129,6 +132,7 @@ class Agent(Generic[AgentDepsT, ResultDataT]):
repr=False
)
_function_tools: dict[str, Tool[AgentDepsT]] = dataclasses.field(repr=False)
_mcp_servers: Sequence[MCPServer] = dataclasses.field(repr=False)
_default_retries: int = dataclasses.field(repr=False)
_max_result_retries: int = dataclasses.field(repr=False)
_override_deps: _utils.Option[AgentDepsT] = dataclasses.field(default=None, repr=False)
Expand All @@ -148,6 +152,7 @@ def __init__(
result_tool_description: str | None = None,
result_retries: int | None = None,
tools: Sequence[Tool[AgentDepsT] | ToolFuncEither[AgentDepsT, ...]] = (),
mcp_servers: Sequence[MCPServer] = (),
defer_model_check: bool = False,
end_strategy: EndStrategy = 'early',
instrument: InstrumentationSettings | bool | None = None,
Expand All @@ -173,6 +178,8 @@ def __init__(
result_retries: The maximum number of retries to allow for result validation, defaults to `retries`.
tools: Tools to register with the agent, you can also register tools via the decorators
[`@agent.tool`][pydantic_ai.Agent.tool] and [`@agent.tool_plain`][pydantic_ai.Agent.tool_plain].
mcp_servers: MCP servers to register with the agent. You should register a [`MCPServer`][pydantic_ai.mcp.MCPServer]
for each server you want the agent to connect to.
defer_model_check: by default, if you provide a [named][pydantic_ai.models.KnownModelName] model,
it's evaluated to create a [`Model`][pydantic_ai.models.Model] instance immediately,
which checks for the necessary environment variables. Set this to `false`
Expand Down Expand Up @@ -215,6 +222,7 @@ def __init__(

self._default_retries = retries
self._max_result_retries = result_retries if result_retries is not None else retries
self._mcp_servers = mcp_servers
for tool in tools:
if isinstance(tool, Tool):
self._register_tool(tool)
Expand Down Expand Up @@ -461,6 +469,7 @@ async def main():
result_tools=self._result_schema.tool_defs() if self._result_schema else [],
result_validators=result_validators,
function_tools=self._function_tools,
mcp_servers=self._mcp_servers,
run_span=run_span,
tracer=tracer,
)
Expand Down Expand Up @@ -1253,6 +1262,20 @@ def is_end_node(
"""
return isinstance(node, End)

@asynccontextmanager
async def run_mcp_servers(self) -> AsyncIterator[None]:
"""Run [`MCPServerStdio`s][pydantic_ai.mcp.MCPServerStdio] so they can be used by the agent.

Returns: a context manager to start and shutdown the servers.
"""
exit_stack = AsyncExitStack()
try:
for mcp_server in self._mcp_servers:
await exit_stack.enter_async_context(mcp_server)
yield
finally:
await exit_stack.aclose()


@dataclasses.dataclass(repr=False)
class AgentRun(Generic[AgentDepsT, ResultDataT]):
Expand Down
Loading