How to Connect a Pydantic AI Agent to MCP Servers with MCPServer

Learn how to use Pydantic AI's standard MCPServer clients: MCPServerStdio, MCPServerStreamableHTTP, and MCPServerSSE. Load multi-server configs, use tool prefixes, read resources, customize TLS, and identify your client cleanly.

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:

  • MCPServerStdio
  • MCPServerStreamableHTTP
  • MCPServerSSE
  • load_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 MCPServer client is the better fit than FastMCPToolset
  • 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:

  • MCPServerStdio
  • MCPServerStreamableHTTP
  • MCPServerSSE

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_prefix handling
  • process_tool_call hooks for metadata injection
  • direct access to server.instructions
  • direct resource discovery and reading
  • custom httpx.AsyncClient support for TLS, proxies, certs, and timeouts
  • client_info for 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 agent opens and closes all registered MCP connections for the lifetime of that block
  • async with server is 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.

References

Spread The Article

Share this guide

Send this article to your network or keep a copy of the direct link.

X Facebook LinkedIn Reddit Telegram

Discussion

Leave a comment

No comments yet

Be the first to start the conversation.