-
Notifications
You must be signed in to change notification settings - Fork 812
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
Changes from all commits
c4f1726
bfc4be6
46c0f17
0e51d39
a7181e4
7e487c1
22483ad
0cae308
ae4dadd
76b8759
80ba00f
faf6309
0f9799b
03f7955
6f7b724
f51cc37
521124b
602134c
2b3ebea
1def930
d23a9a0
131af07
7f32d05
1cf04ac
8648622
21b98ce
0518681
75fff0f
8770d0c
fc9ef87
62f5436
abb4d39
dd8f0c5
9467c97
2238bc1
a5c8985
549d514
5805745
4c27c44
94d733c
5418e33
46cf989
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 @@ | ||
::: pydantic_ai.mcp |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,122 @@ | ||
# MCP Servers | ||
|
||
**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) | ||
Viicos marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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). |
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -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', | ||
|
@@ -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 | ||
|
@@ -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( | ||
|
@@ -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 | ||
|
@@ -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: | ||
Kludex marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""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}: | ||
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. Do we need to do anything to ensure there aren’t naming conflicts between servers? 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 should I do? Error? 🤔 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 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]], | ||
|
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.
I think this should be called "MCP Client" since pydantic-ai is acting as a 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.
I'm using the same notation everybody is tho. Claude Desktop, Cursor, and Cline use the "MCP Server" terminology even on the STDIO.
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.
right, but we're building an MCP client, so we should call it that.
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.
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.
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.
Maybe rephrase the introduction phrase as something like:
maybe link with https://modelcontextprotocol.io/introduction#general-architecture.
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.
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 serversmcp/client.md
- this page - describing how to use PydanticAI as an MCP clientmcp/run-python.md
- docs for MCP server to run Python code in a sandbox #1140