cd /news/developer-tools/try-the-mcp-python-sdk-v2-beta-today · home topics developer-tools article
[ARTICLE · art-75358] src=pydantic.dev ↗ pub= topic=developer-tools verified=true sentiment=↑ positive

Try the MCP Python SDK v2 beta today

The MCP Python SDK v2 beta is available today, supporting the July 28, 2026 revision of the MCP specification, which introduces a stateless core, extensions framework, MCP Apps, Tasks as an extension, and authorization hardening. The SDK, maintained by the author, renames FastMCP to MCPServer, moves wire types to a separate mcp-types distribution, and changes all fields to snake_case, among other updates. Users can install the beta with `uv add "mcp[cli]==2.0.0b2"` or `pip install "mcp[cli]==2.0.0b2"`.

read9 min views1 publishedJul 27, 2026
Try the MCP Python SDK v2 beta today
Image: Pydantic (auto-discovered)

Tomorrow the 2026-07-28 revision of the MCP specification lands. It's the largest revision of the protocol since launch: a stateless core, a first-class extensions framework, MCP Apps, Tasks as an extension, and authorization hardening.

The Python SDK beta already speaks it. I help maintain that SDK, so let's walk through what changed, and what you get for free when you point it at Pydantic Logfire.

Install

uv add "mcp[cli]==2.0.0b2"          # or: pip install "mcp[cli]==2.0.0b2"

The exact pin matters. pip

and uv

won't resolve to a pre-release unless you ask for one, so an unpinned install gives you the latest v1.x instead.

A server, and a client

Let's start with the smallest thing that works. Two type-hinted functions and a docstring:

from mcp.server import MCPServer

mcp = MCPServer("Demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers."""
    return a + b

@mcp.resource("greeting://{name}")
def greeting(name: str) -> str:
    """Greet someone by name."""
    return f"Hello, {name}!"

That's a complete MCP server. You don't write JSON Schema, because a: int, b: int

is the schema.

The same package is a full client. In v1 you had to stack three things: a transport context manager, a ClientSession

around it, and a hand-called await session.initialize()

. Now it's one object:

import asyncio

from mcp import Client

from server import mcp

async def main() -> None:
    async with Client(mcp) as client:
        result = await client.call_tool("add", {"a": 1, "b": 2})
        print(result.structured_content)  # {'result': 3}

asyncio.run(main())

Client

takes a server object (in memory, no transport at all, which is how you should test), a URL for Streamable HTTP, or any transport context manager. Swap mcp

for "http://localhost:8000/mcp"

and the same code talks to a remote server.

What changed in the SDK?

The renames are the first thing your v1 codebase hits, because the old import paths are gone rather than deprecated:

, and everything underFastMCP

is nowMCPServer

mcp.server.fastmcp.*

moved tomcp.server.mcpserver.*

. If you built your server with decorators, that rename is most of the port.The wire types moved to their own distribution,mcp-types

, imported asmcp_types

. It depends on nothing but Pydantic andtyping-extensions

, so a gateway or a proxy can consume MCP's wire shapes without installing an HTTP stack.Every field is snake_case:result.is_error

,tool.input_schema

,listing.next_cursor

. The JSON on the wire is still camelCase, only the Python attribute spelling changed.Transport configuration moved to and the app builders.run()

MCPServer

is about what your serveris, soMCPServer("x", port=9000)

is aTypeError

now.The low-level Handlers are constructor arguments with one uniform shape,Server

was rebuilt, not renamed.async (ctx, params) -> result

, and the ambientserver.request_context

ContextVar is gone.

Two changes don't announce themselves with an import error, so watch for them. Sync def

tools now run on a worker thread instead of blocking the event loop, which matters to thread-affine code. And the HTTP client is httpx2

, which verifies TLS through the operating system trust store instead of certifi

's bundle, so a minimal container with no system CA store can suddenly start failing handshakes.

What changed in the protocol?

v2 serves both revisions at once. The same streamable_http_app()

answers a 2025-era client's initialize

and a 2026-era client's requests, with no flag to flip and no separate deployment.

No handshake, no session. Every request carries its protocol version, client info, and capabilities in_meta

, and discovery is a plainserver/discover

request. Over Streamable HTTP there's noMcp-Session-Id

on the 2026 path, so nothing ties a modern request to a worker, and any replica behind a round-robin load balancer can answer.Roots, sampling, and MCP-level logging are deprecated(SEP-2577) on every protocol version, andping

is removed outright. Expect anMCPDeprecationWarning

on your firstctx.info(...)

after upgrading.Change notifications become one stream.subscriptions/listen

replaces the standalone GET stream andresources/subscribe

.Requests are routable without parsing bodies. Modern HTTP requests carryMcp-Method

and, for tool-ish calls,Mcp-Name

(SEP-2243), so gateways and rate limiters can route on headers alone.

And then there's the one that will actually change how you write tools.

The server can't call you back anymore

This is the big one, so let's take it slowly.

Sometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a credential. Before 2026-07-28, the server got it by calling back. In the middle of handling your tools/call

, it opened its own request to the client: an elicitation, a sampling call, a roots/list

.

The 2026-07-28 spec retires that back-channel. There is no channel for it, so ctx.elicit()

and ctx.session.create_message()

raise NoBackChannelError

on a modern connection.

Instead, the server returns. It answers tools/call

with an InputRequiredResult

carrying what it still needs plus an opaque request_state

token. The client fulfills the request, then calls the same tool again, with its answers and the token attached. The server now has what it was missing and returns a normal CallToolResult

.

That's the whole mechanism, and the nice part is that every leg is an ordinary client-to-server request. Nothing ever flows the other way.

Now, you rarely build that by hand. You declare a dependency instead, and the SDK does the round trips for you:

import asyncio
from typing import Annotated

from mcp_types import ElicitRequestParams, ElicitResult
from pydantic import BaseModel

from mcp import Client
from mcp.client import ClientRequestContext
from mcp.server import MCPServer
from mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve

mcp = MCPServer("Bookshop")

class Quantity(BaseModel):
    copies: int

async def ask_quantity() -> Elicit[Quantity]:
    """Resolver: ask the user how many copies to put aside."""
    return Elicit("How many copies?", Quantity)

@mcp.tool()
async def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:
    """Reserve copies of a book, asking the user how many."""
    if isinstance(quantity, AcceptedElicitation):
        return f"Reserved {quantity.data.copies} of {title!r}."
    return "Nothing reserved."

async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
    return ElicitResult(action="accept", content={"copies": 2})

async def main() -> None:
    async with (
        Client(mcp, mode="legacy", elicitation_callback=answer) as legacy,
        Client(mcp, elicitation_callback=answer) as modern,
    ):
        for client in (legacy, modern):
            result = await client.call_tool("reserve", {"title": "Dune"})
            print(client.protocol_version, result.structured_content)

asyncio.run(main())

If you've used FastAPI, Resolve(...)

is Depends

. Same move, same reason.

The quantity

parameter never appears in the tool's input schema, so the model is never told about it and can't invent it. A parameter the model can't supply is a parameter the model can't get wrong.

Run that file and both clients get the same answer:

2025-11-25 {'result': "Reserved 2 of 'Dune'."}
2026-07-28 {'result': "Reserved 2 of 'Dune'."}

One tool body, two protocol eras. That's the part I like: you don't write the fork.

Tracing is built in

Here's where it gets fun for me, because I work on Logfire too.

v2 depends on opentelemetry-api

directly and ships an OpenTelemetry middleware enabled by default. Every server emits a SERVER span per inbound message, and the client emits a CLIENT span per outbound request. It only depends on the API half of OpenTelemetry, so with no exporter installed a span is a no-op and costs you basically nothing.

To see them, call logfire.configure()

. That's the whole integration:

import logfire
import uvicorn
from mcp.server import MCPServer

logfire.configure(service_name="mcp-server", distributed_tracing=True)

mcp = MCPServer("logfire-demo")

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two integers."""
    with logfire.span("add {a} + {b}", a=a, b=b) as span:
        result = a + b
        span.set_attribute("result", result)
        return result

uvicorn.run(mcp.streamable_http_app(), host="127.0.0.1", port=8000)

And the client:

import asyncio

import logfire
from mcp import Client

logfire.configure(service_name="mcp-client")

async def main() -> None:
    with logfire.span("mcp session"):
        async with Client("http://127.0.0.1:8000/mcp") as client:
            tools = await client.list_tools()
            logfire.info("server exposes {names}", names=[t.name for t in tools.tools])

            result = await client.call_tool("add", {"a": 2, "b": 3})
            logfire.info("add(2, 3) -> {content}", content=result.content)

asyncio.run(main())

Those are two separate processes. In Logfire they arrive as one trace:

mcp session                    [mcp-client]
  MCP send server/discover     [mcp-client]
    server/discover            [mcp-server]
  MCP send tools/list          [mcp-client]
    tools/list                 [mcp-server]
  server exposes ['add']       [mcp-client]
  MCP send tools/call add      [mcp-client]
    tools/call add             [mcp-server]
      add 2 + 3                [mcp-server]
  add(2, 3) -> ...             [mcp-client]

The client injects W3C trace context into the request's _meta

and the server extracts it (SEP-414), so a tool call made by an agent on one machine and executed on another is a single connected tree, with your own add 2 + 3

span nested underneath. If an inbound message has no trace context, say from a client that isn't the SDK, the server span parents to whatever is current instead of starting an orphan trace.

Query that tools/call add

span back out and here's what it carries:

gen_ai.operation.name = execute_tool
gen_ai.tool.name      = add
jsonrpc.request.id    = 3
mcp.method.name       = tools/call
mcp.protocol.version  = 2026-07-28

mcp.method.name

and mcp.protocol.version

are on every span, jsonrpc.request.id

on every request, and tools/call

spans follow OpenTelemetry's GenAI semantic conventions. That last one is why your tool calls group in a tracing UI the way any other agent's do, without extra code. A handler that raises sets the span status to error, and so does a tool result with is_error=True

.

Now go back to that dual-era example and look at it in Logfire. The legacy client shows the old back-channel, with the server's elicitation nested inside the tool call:

MCP send tools/call reserve        req=2
  tools/call reserve               req=2   proto=2025-11-25
    MCP send elicitation/create    req=1

The modern client shows two tools/call reserve

spans instead, with different request ids:

tools/call reserve                 req=2   proto=2026-07-28
tools/call reserve                 req=3   proto=2026-07-28

That second one is the retry. No nested call back to the client, just the tool being asked again with the answer attached. Multi-round-trip requests are the kind of thing that's hard to reason about from the spec text alone, and much easier to believe when you can see both shapes side by side.

To preview spans locally without sending them anywhere, run with LOGFIRE_SEND_TO_LOGFIRE=false LOGFIRE_CONSOLE=true

.

Try it today, and tell us what breaks

The spec lands tomorrow, and the point of a beta is the feedback. If you maintain an MCP server or client in Python, the most useful thing you can do today is pin 2.0.0b2

, port something real, and tell us what hurt.

on theMCP Contributors Discord

If you want those traces in a real UI, Logfire has a free tier, and the getting started guide takes about two minutes.

── more in #developer-tools 4 stories · sorted by recency
── more on @mcp python sdk 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/try-the-mcp-python-s…] indexed:0 read:9min 2026-07-27 ·