If FastMCPToolset is the convenience path, MCPServer is the control path.
That is the cleanest way I know to frame the difference.
When people first wire MCP into Pydantic AI, they often gravitate toward the FastMCP route because it is flexible and ergonomic. Fair enough. But there is another path built right into Pydantic AI: the standard MCPServer client family. It is lower-level in the right places, more explicit about transports, and much better when you care about external configuration, custom TLS, tool-call metadata, resources, or future interactive features like sampling and elicitation.
As of March 28, 2026, the official Pydantic AI docs still position the standard MCPServer client as the richer control surface. That makes it a strong default when you want to own the MCP connection rather than just make it work.
This guide focuses on that standard client path:
MCPServerStdioMCPServerStreamableHTTPMCPServerSSEload_mcp_servers()
If you want the broader architectural map first, read our comparison of MCPServer, FastMCPToolset, and MCPServerTool. If you want the FastMCP-specific route instead, the companion piece on connecting Pydantic AI to MCP servers with FastMCPToolset covers that side.
What you’ll learn:
- when the standard
MCPServerclient is the better fit thanFastMCPToolset - how to connect over stdio, Streamable HTTP, and legacy SSE
- how to load multiple MCP servers from JSON config with environment variables
- how to avoid tool collisions, surface server instructions, and read resources
- how to handle custom TLS, client identification, and metadata injection cleanly
Time required: 30-40 minutes
Difficulty level: Intermediate
Step 1: Know What the Standard Client Is Good At
Pydantic AI ships with three ways to connect to MCP servers:
MCPServerStdioMCPServerStreamableHTTPMCPServerSSE
These are all part of the same standard client family. The docs also make clear that every MCP server instance is a toolset, so you register them directly on the agent with toolsets=[...].
What makes this route worth learning is not just transport coverage. It is everything around the transport:
- config-driven loading with
load_mcp_servers() - explicit
tool_prefixhandling process_tool_callhooks for metadata injection- direct access to
server.instructions - direct resource discovery and reading
- custom
httpx.AsyncClientsupport for TLS, proxies, certs, and timeouts client_infofor server-side identification and feature negotiation
If you are building something that may eventually need sampling or elicitation, this is also the safer path to start from. We covered those two features separately in our sampling and elicitation guide, but it is worth saying the quiet part out loud here: standard MCPServer is not the “boring” route. It is the route with more levers.
Step 2: Install the mcp Extra, Not the fastmcp Extra
For this path, you want the standard MCP client support:
uv init pydantic-ai-mcp-client-demo
cd pydantic-ai-mcp-client-demo
uv add "pydantic-ai-slim[mcp]"
Or with pip:
python -m venv .venv
source .venv/bin/activate
pip install "pydantic-ai-slim[mcp]"
You will still need a model provider configured for your agent:
export OPENAI_API_KEY="your_api_key_here"
That part is unchanged. The interesting work starts when you decide how the agent will reach the MCP server.
Step 3: Start Local with MCPServerStdio
If the server lives on the same machine, stdio is usually the most straightforward setup.
Pydantic AI runs the server as a subprocess and connects over stdin / stdout. That keeps the whole flow local, explicit, and easy to debug. It is also a nice default for internal scripts, prototypes, and anything you plan to run behind the same deployment unit.
import asyncio
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStdio
weather_server = MCPServerStdio(
"python",
args=["weather_server.py"],
timeout=10,
)
agent = Agent(
"openai:gpt-5.2",
toolsets=[weather_server],
)
async def main() -> None:
async with agent:
result = await agent.run("What is the weather in Seoul right now?")
print(result.output)
asyncio.run(main())
Two details are worth keeping in your head:
async with agentopens and closes all registered MCP connections for the lifetime of that blockasync with serveris also available if you want to share one server instance across multiple agents
Pydantic AI can lazily open a connection when it needs one, but for anything beyond a toy demo, the context manager route is cleaner and cheaper.
When stdio is the better choice
Use it when:
- the server is local
- you want process isolation without network plumbing
- you need easy access to
cwd,env, and subprocess-level settings - you want the least surprising local dev setup
If a server needs a specific working directory or environment variables, set them explicitly. Hidden shell assumptions are where stdio setups start to rot.
Step 4: Use Streamable HTTP for Modern Remote MCP
For remote servers or production deployments, MCPServerStreamableHTTP is the more modern path.
The docs are pretty direct here: Streamable HTTP is the preferred HTTP transport. It also maps well to how most teams already expose services.
import asyncio
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP
docs_server = MCPServerStreamableHTTP("https://mcp.example.com/mcp")
agent = Agent(
"openai:gpt-5.2",
toolsets=[docs_server],
)
async def main() -> None:
async with agent:
result = await agent.run("Find the SDK page that explains retry policies.")
print(result.output)
asyncio.run(main())
This is the transport I would choose by default for any new remote MCP endpoint unless I had a strong reason not to.
Step 5: Only Use MCPServerSSE When the Server Is Already There
Pydantic AI still supports MCPServerSSE, but the docs also note that SSE transport in MCP is deprecated and that you should prefer Streamable HTTP instead.
That does not mean SSE is unusable. It means you should treat it as a compatibility path, not the recommendation for new systems.
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerSSE
legacy_server = MCPServerSSE("http://localhost:3001/sse")
agent = Agent(
"openai:gpt-5.2",
toolsets=[legacy_server],
)
If you already have an SSE endpoint in production, fine. Keep it moving while you plan a migration. I would not build a fresh one in 2026 unless the rest of your stack forces the issue.
Step 6: Load Multiple Servers from JSON Instead of Hard-Coding Them
This is one of the strongest reasons to learn the standard client.
If you have more than one MCP server, or you want ops to change transports without touching Python code, load_mcp_servers() is a real quality-of-life improvement.
Here is the shape the docs describe:
{
"mcpServers": {
"python-runner": {
"command": "${PYTHON_CMD:-python3}",
"args": ["-m", "${MCP_MODULE}", "stdio"],
"env": {
"API_KEY": "${MY_API_KEY}"
}
},
"weather-api": {
"url": "http://localhost:3001/sse"
},
"docs-api": {
"url": "http://localhost:8000/mcp"
}
}
}
And here is the client side:
import asyncio
from pydantic_ai import Agent
from pydantic_ai.mcp import load_mcp_servers
async def main() -> None:
servers = load_mcp_servers("mcp_config.json")
agent = Agent("openai:gpt-5.2", toolsets=servers)
async with agent:
result = await agent.run("Use whichever server has the right tool and summarize what you found.")
print(result.output)
asyncio.run(main())
This setup gets better once you learn the environment variable rules:
${VAR}means the variable must exist${VAR:-default}means use the variable if present, otherwise fall back to the default
If a required ${VAR} is missing, the docs say load_mcp_servers() raises ValueError. That is a good failure mode. Silent config drift is worse.
Step 7: Use tool_prefix Before Tool Names Start Fighting
The standard client gives you a simple answer to MCP tool naming collisions: tool_prefix.
That becomes relevant as soon as two servers expose anything like search, lookup, get_data, or status.
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP
weather_server = MCPServerStreamableHTTP(
"http://localhost:3001/mcp",
tool_prefix="weather",
)
ops_server = MCPServerStreamableHTTP(
"http://localhost:3002/mcp",
tool_prefix="ops",
)
agent = Agent(
"openai:gpt-5.2",
toolsets=[weather_server, ops_server],
)
That gives you weather_* and ops_* tool names without inventing a custom naming system later.
This sounds small. It is not. Naming conflicts are one of those problems that look harmless in an early demo and become maddening once the tool surface grows.
Step 8: Inject Metadata with process_tool_call
This is where the standard client starts feeling less like a connector and more like infrastructure.
Pydantic AI lets you provide process_tool_call, which means you can intercept a tool call, attach extra metadata, and then pass it on. The docs show this as a way to carry run context into MCP calls.
from dataclasses import dataclass
from typing import Any
from pydantic_ai import Agent, RunContext
from pydantic_ai.mcp import CallToolFunc, MCPServerStdio, ToolResult
@dataclass
class TenantDeps:
tenant_id: str
async def process_tool_call(
ctx: RunContext[TenantDeps],
call_tool: CallToolFunc,
name: str,
tool_args: dict[str, Any],
) -> ToolResult:
return await call_tool(name, tool_args, {"tenant_id": ctx.deps.tenant_id})
tenant_server = MCPServerStdio(
"python",
args=["tenant_server.py"],
process_tool_call=process_tool_call,
)
agent = Agent(
"openai:gpt-5.2",
deps_type=TenantDeps,
toolsets=[tenant_server],
)
If you run multi-tenant systems, internal permission checks, or audit-heavy workflows, that hook is the difference between “the tool got called” and “the tool got called with the context it actually needed.”
Step 9: Pull Server Instructions and Read Resources Deliberately
MCP servers can expose more than tools.
The standard client docs call out two especially useful pieces:
server.instructions- server-hosted resources
Server instructions
If a server returns initialization instructions, you can surface them directly into the agent:
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP
server = MCPServerStreamableHTTP("http://localhost:8000/mcp")
agent = Agent("openai:gpt-5.2", toolsets=[server])
@agent.instructions
async def add_mcp_server_context() -> str:
return server.instructions or ""
The docs note that by the time this instruction function runs, the server connection is already established, so server.instructions is available.
Resources
Resources are even more interesting because they are not automatically injected into the model. You choose when to read them and how to use them.
import asyncio
from pydantic_ai.mcp import MCPServerStdio
async def main() -> None:
server = MCPServerStdio("python", args=["-m", "mcp_resource_server"])
async with server:
resources = await server.list_resources()
for resource in resources:
print(resource.name, resource.uri, resource.mime_type)
handbook = await server.read_resource("resource://ops-handbook.txt")
print(handbook)
asyncio.run(main())
I like this design a lot. It keeps resource access explicit. The model only sees what your app decides to pass along.
Step 10: Handle TLS and Client Identity Like You Mean It
This is another place where the standard client shines.
For HTTP-based MCP clients, Pydantic AI lets you pass a custom httpx.AsyncClient. That means your MCP traffic can inherit whatever network policy your environment needs: custom CA roots, mTLS, proxies, tighter timeouts, or local dev exceptions.
import asyncio
import ssl
import httpx
from mcp import types as mcp_types
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPServerStreamableHTTP
ssl_ctx = ssl.create_default_context(cafile="/etc/ssl/private/internal_ca.pem")
http_client = httpx.AsyncClient(
verify=ssl_ctx,
timeout=httpx.Timeout(10.0),
)
server = MCPServerStreamableHTTP(
"https://mcp.internal.example.com/mcp",
http_client=http_client,
client_info=mcp_types.Implementation(
name="PyrastraOpsBot",
version="1.4.0",
),
)
agent = Agent("openai:gpt-5.2", toolsets=[server])
async def main() -> None:
async with agent:
result = await agent.run("Check the deployment checklist for the billing service.")
print(result.output)
asyncio.run(main())
The client_info piece is easy to skip, but I would not. The docs explicitly call out why it matters:
- better server logs
- client-specific behavior
- debugging and monitoring
- version-aware negotiation
Once you have more than one agent or more than one environment, named clients stop being optional in practice.
Step 11: Common Mistakes That Make This Feel Harder Than It Is
These are the ones that keep popping up:
Treating SSE as the default
It is still supported. It is not the preferred transport.
Hard-coding every server in Python
Once you have multiple servers, config-driven loading usually ages better.
Skipping tool_prefix
You may get away with it for a while. Then two search tools show up and ruin your afternoon.
Forgetting that resources are opt-in
They are not automatically added to the LLM context. That is your job.
Ignoring client identity and TLS until production
These are the things that always feel “extra” right up until the first internal CA or proxy requirement lands.
Step 12: When to Choose This Path Over FastMCPToolset
I would choose the standard MCPServer client when:
- I want config-driven server loading
- I need explicit control over transport behavior
- I care about metadata injection with
process_tool_call - I want first-class access to server instructions and resources
- I know I may need sampling or elicitation later
- I am wiring MCP into an environment with real network rules
I would still choose FastMCPToolset when the main problem is convenience and I want FastMCP’s client ergonomics.
The mistake is pretending they are interchangeable. They are close enough to look similar, but they reward different priorities.
Discussion
Leave a comment
No comments yet
Be the first to start the conversation.