{"slug": "try-the-mcp-python-sdk-v2-beta-today", "title": "Try the MCP Python SDK v2 beta today", "summary": "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\"`.", "body_md": "Tomorrow the [2026-07-28 revision of the MCP specification](https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/)\nlands. It's the largest revision of the protocol since launch: a stateless core, a first-class extensions framework,\nMCP Apps, Tasks as an extension, and authorization hardening.\n\nThe Python SDK beta already speaks it. I help maintain that SDK, so let's walk through what changed, and what you get\nfor free when you point it at [Pydantic Logfire](https://pydantic.dev/logfire).\n\nInstall\n\n```\nuv add \"mcp[cli]==2.0.0b2\"          # or: pip install \"mcp[cli]==2.0.0b2\"\n```\n\nThe exact pin matters. `pip`\n\nand `uv`\n\nwon't resolve to a pre-release unless you ask for one, so an unpinned install\ngives you the latest v1.x instead.\n\nA server, and a client\n\nLet's start with the smallest thing that works. Two type-hinted functions and a docstring:\n\n``` python\nfrom mcp.server import MCPServer\n\nmcp = MCPServer(\"Demo\")\n\n@mcp.tool()\ndef add(a: int, b: int) -> int:\n    \"\"\"Add two numbers.\"\"\"\n    return a + b\n\n@mcp.resource(\"greeting://{name}\")\ndef greeting(name: str) -> str:\n    \"\"\"Greet someone by name.\"\"\"\n    return f\"Hello, {name}!\"\n```\n\nThat's a complete MCP server. You don't write JSON Schema, because `a: int, b: int`\n\n*is* the schema.\n\nThe same package is a full client. In v1 you had to stack three things: a transport context manager, a\n`ClientSession`\n\naround it, and a hand-called `await session.initialize()`\n\n. Now it's one object:\n\n``` python\nimport asyncio\n\nfrom mcp import Client\n\nfrom server import mcp\n\nasync def main() -> None:\n    async with Client(mcp) as client:\n        result = await client.call_tool(\"add\", {\"a\": 1, \"b\": 2})\n        print(result.structured_content)  # {'result': 3}\n\nasyncio.run(main())\n```\n\n`Client`\n\ntakes a server object (in memory, no transport at all, which is how you should test), a URL for Streamable\nHTTP, or any transport context manager. Swap `mcp`\n\nfor `\"http://localhost:8000/mcp\"`\n\nand the same code talks to a\nremote server.\n\nWhat changed in the SDK?\n\nThe renames are the first thing your v1 codebase hits, because the old import paths are gone rather than deprecated:\n\n, and everything under`FastMCP`\n\nis now`MCPServer`\n\n`mcp.server.fastmcp.*`\n\nmoved to`mcp.server.mcpserver.*`\n\n. If you built your server with decorators, that rename is most of the port.**The wire types moved to their own distribution**,`mcp-types`\n\n, imported as`mcp_types`\n\n. It depends on nothing but Pydantic and`typing-extensions`\n\n, 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`\n\n,`tool.input_schema`\n\n,`listing.next_cursor`\n\n. The JSON on the wire is still camelCase, only the Python attribute spelling changed.**Transport configuration moved to** and the app builders.`run()`\n\n`MCPServer`\n\nis about what your server*is*, so`MCPServer(\"x\", port=9000)`\n\nis a`TypeError`\n\nnow.**The low-level** Handlers are constructor arguments with one uniform shape,`Server`\n\nwas rebuilt, not renamed.`async (ctx, params) -> result`\n\n, and the ambient`server.request_context`\n\nContextVar is gone.\n\nTwo changes don't announce themselves with an import error, so watch for them. Sync `def`\n\ntools now run on a worker\nthread instead of blocking the event loop, which matters to thread-affine code. And the HTTP client is `httpx2`\n\n, which\nverifies TLS through the operating system trust store instead of `certifi`\n\n's bundle, so a minimal container with no\nsystem CA store can suddenly start failing handshakes.\n\nWhat changed in the protocol?\n\nv2 serves both revisions at once. The same `streamable_http_app()`\n\nanswers a 2025-era client's `initialize`\n\nand a\n2026-era client's requests, with no flag to flip and no separate deployment.\n\n**No handshake, no session.** Every request carries its protocol version, client info, and capabilities in`_meta`\n\n, and discovery is a plain`server/discover`\n\nrequest. Over Streamable HTTP there's no`Mcp-Session-Id`\n\non 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, and`ping`\n\nis removed outright. Expect an`MCPDeprecationWarning`\n\non your first`ctx.info(...)`\n\nafter upgrading.**Change notifications become one stream.**`subscriptions/listen`\n\nreplaces the standalone GET stream and`resources/subscribe`\n\n.**Requests are routable without parsing bodies.** Modern HTTP requests carry`Mcp-Method`\n\nand, for tool-ish calls,`Mcp-Name`\n\n(SEP-2243), so gateways and rate limiters can route on headers alone.\n\nAnd then there's the one that will actually change how you write tools.\n\nThe server can't call you back anymore\n\nThis is the big one, so let's take it slowly.\n\nSometimes a tool can't finish in one round trip. It needs something only the user has: a choice, a confirmation, a\ncredential. Before 2026-07-28, the server got it by *calling back*. In the middle of handling your `tools/call`\n\n, it\nopened its own request to the client: an elicitation, a sampling call, a `roots/list`\n\n.\n\nThe 2026-07-28 spec retires that back-channel. There is no channel for it, so `ctx.elicit()`\n\nand\n`ctx.session.create_message()`\n\nraise `NoBackChannelError`\n\non a modern connection.\n\nInstead, **the server returns**. It answers `tools/call`\n\nwith an `InputRequiredResult`\n\ncarrying what it still needs\nplus an opaque `request_state`\n\ntoken. The client fulfills the request, then calls the same tool *again*, with its\nanswers and the token attached. The server now has what it was missing and returns a normal `CallToolResult`\n\n.\n\nThat'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.\n\nNow, you rarely build that by hand. You declare a dependency instead, and the SDK does the round trips for you:\n\n``` python\nimport asyncio\nfrom typing import Annotated\n\nfrom mcp_types import ElicitRequestParams, ElicitResult\nfrom pydantic import BaseModel\n\nfrom mcp import Client\nfrom mcp.client import ClientRequestContext\nfrom mcp.server import MCPServer\nfrom mcp.server.mcpserver import AcceptedElicitation, Elicit, ElicitationResult, Resolve\n\nmcp = MCPServer(\"Bookshop\")\n\nclass Quantity(BaseModel):\n    copies: int\n\nasync def ask_quantity() -> Elicit[Quantity]:\n    \"\"\"Resolver: ask the user how many copies to put aside.\"\"\"\n    return Elicit(\"How many copies?\", Quantity)\n\n@mcp.tool()\nasync def reserve(title: str, quantity: Annotated[ElicitationResult[Quantity], Resolve(ask_quantity)]) -> str:\n    \"\"\"Reserve copies of a book, asking the user how many.\"\"\"\n    if isinstance(quantity, AcceptedElicitation):\n        return f\"Reserved {quantity.data.copies} of {title!r}.\"\n    return \"Nothing reserved.\"\n\nasync def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:\n    return ElicitResult(action=\"accept\", content={\"copies\": 2})\n\nasync def main() -> None:\n    async with (\n        Client(mcp, mode=\"legacy\", elicitation_callback=answer) as legacy,\n        Client(mcp, elicitation_callback=answer) as modern,\n    ):\n        for client in (legacy, modern):\n            result = await client.call_tool(\"reserve\", {\"title\": \"Dune\"})\n            print(client.protocol_version, result.structured_content)\n\nasyncio.run(main())\n```\n\nIf you've used FastAPI, `Resolve(...)`\n\nis `Depends`\n\n. Same move, same reason.\n\nThe `quantity`\n\nparameter never appears in the tool's input schema, so the model is never told about it and can't\ninvent it. A parameter the model can't supply is a parameter the model can't get wrong.\n\nRun that file and both clients get the same answer:\n\n```\n2025-11-25 {'result': \"Reserved 2 of 'Dune'.\"}\n2026-07-28 {'result': \"Reserved 2 of 'Dune'.\"}\n```\n\nOne tool body, two protocol eras. That's the part I like: you don't write the fork.\n\nTracing is built in\n\nHere's where it gets fun for me, because I work on [Logfire](https://pydantic.dev/logfire) too.\n\nv2 depends on `opentelemetry-api`\n\ndirectly and ships an OpenTelemetry middleware **enabled by default**. Every server\nemits a SERVER span per inbound message, and the client emits a CLIENT span per outbound request. It only depends on\nthe API half of OpenTelemetry, so with no exporter installed a span is a no-op and costs you basically nothing.\n\nTo see them, call `logfire.configure()`\n\n. That's the whole integration:\n\n``` python\nimport logfire\nimport uvicorn\nfrom mcp.server import MCPServer\n\nlogfire.configure(service_name=\"mcp-server\", distributed_tracing=True)\n\nmcp = MCPServer(\"logfire-demo\")\n\n@mcp.tool()\ndef add(a: int, b: int) -> int:\n    \"\"\"Add two integers.\"\"\"\n    with logfire.span(\"add {a} + {b}\", a=a, b=b) as span:\n        result = a + b\n        span.set_attribute(\"result\", result)\n        return result\n\nuvicorn.run(mcp.streamable_http_app(), host=\"127.0.0.1\", port=8000)\n```\n\nAnd the client:\n\n``` python\nimport asyncio\n\nimport logfire\nfrom mcp import Client\n\nlogfire.configure(service_name=\"mcp-client\")\n\nasync def main() -> None:\n    with logfire.span(\"mcp session\"):\n        async with Client(\"http://127.0.0.1:8000/mcp\") as client:\n            tools = await client.list_tools()\n            logfire.info(\"server exposes {names}\", names=[t.name for t in tools.tools])\n\n            result = await client.call_tool(\"add\", {\"a\": 2, \"b\": 3})\n            logfire.info(\"add(2, 3) -> {content}\", content=result.content)\n\nasyncio.run(main())\n```\n\nThose are two separate processes. In [Logfire](/logfire) they arrive as one trace:\n\n```\nmcp session                    [mcp-client]\n  MCP send server/discover     [mcp-client]\n    server/discover            [mcp-server]\n  MCP send tools/list          [mcp-client]\n    tools/list                 [mcp-server]\n  server exposes ['add']       [mcp-client]\n  MCP send tools/call add      [mcp-client]\n    tools/call add             [mcp-server]\n      add 2 + 3                [mcp-server]\n  add(2, 3) -> ...             [mcp-client]\n```\n\nThe client injects W3C trace context into the request's `_meta`\n\nand the server extracts it (SEP-414), so a tool call\nmade by an agent on one machine and executed on another is a single connected tree, with your own `add 2 + 3`\n\nspan\nnested underneath. If an inbound message has no trace context, say from a client that isn't the SDK, the server span\nparents to whatever is current instead of starting an orphan trace.\n\nQuery that `tools/call add`\n\nspan back out and here's what it carries:\n\n```\ngen_ai.operation.name = execute_tool\ngen_ai.tool.name      = add\njsonrpc.request.id    = 3\nmcp.method.name       = tools/call\nmcp.protocol.version  = 2026-07-28\n```\n\n`mcp.method.name`\n\nand `mcp.protocol.version`\n\nare on every span, `jsonrpc.request.id`\n\non every request, and\n`tools/call`\n\nspans follow OpenTelemetry's GenAI semantic conventions. That last one is why your tool calls group in a\ntracing UI the way any other agent's do, without extra code. A handler that raises sets the span status to error, and\nso does a tool result with `is_error=True`\n\n.\n\nNow go back to that dual-era example and look at it in Logfire. The legacy client shows the old back-channel, with the\nserver's elicitation nested *inside* the tool call:\n\n```\nMCP send tools/call reserve        req=2\n  tools/call reserve               req=2   proto=2025-11-25\n    MCP send elicitation/create    req=1\n```\n\nThe modern client shows two `tools/call reserve`\n\nspans instead, with different request ids:\n\n```\ntools/call reserve                 req=2   proto=2026-07-28\ntools/call reserve                 req=3   proto=2026-07-28\n```\n\nThat 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.\n\nTo preview spans locally without sending them anywhere, run with `LOGFIRE_SEND_TO_LOGFIRE=false LOGFIRE_CONSOLE=true`\n\n.\n\nTry it today, and tell us what breaks\n\nThe spec lands tomorrow, and the point of a beta is the feedback. If you maintain an MCP server or client in Python,\nthe most useful thing you can do today is pin `2.0.0b2`\n\n, port something real, and tell us what hurt.\n\n- Read\n[What's new in v2](https://py.sdk.modelcontextprotocol.io/v2/whats-new/)for the full tour, and the[migration guide](https://py.sdk.modelcontextprotocol.io/v2/migration/)for every breaking change - File feedback with the\n[v2 feedback template](https://github.com/modelcontextprotocol/python-sdk/issues/new?template=v2-feedback.yaml) - Or come argue with me in\n`#python-sdk-dev`\n\non the[MCP Contributors Discord](https://discord.gg/6CSzBmMkjX)\n\nIf you want those traces in a real UI, Logfire has a [free tier](https://pydantic.dev/pricing), and the\n[getting started guide](https://pydantic.dev/docs/logfire/get-started/) takes about two minutes.", "url": "https://wpnews.pro/news/try-the-mcp-python-sdk-v2-beta-today", "canonical_source": "https://pydantic.dev/articles/mcp-python-sdk-v2-beta", "published_at": "2026-07-27 09:00:00+00:00", "updated_at": "2026-07-27 13:03:34.625383+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence"], "entities": ["MCP Python SDK", "Pydantic Logfire", "MCPServer", "Client", "mcp-types", "httpx2", "certifi", "Streamable HTTP"], "alternates": {"html": "https://wpnews.pro/news/try-the-mcp-python-sdk-v2-beta-today", "markdown": "https://wpnews.pro/news/try-the-mcp-python-sdk-v2-beta-today.md", "text": "https://wpnews.pro/news/try-the-mcp-python-sdk-v2-beta-today.txt", "jsonld": "https://wpnews.pro/news/try-the-mcp-python-sdk-v2-beta-today.jsonld"}}