
Protocols
A build guide written against the 2026-07-28 MCP specification: the stateless core, per-request capabilities, Extensions and Tasks, and what it takes to be a correct OAuth 2.1 resource server.
TL;DR
The 2026-07-28 MCP revision deletes protocol-level sessions, and most of the server-side work that follows is deleting code, not adding it. Version and capability negotiation now ride on every request instead of a one-time handshake, three client features are formally deprecated, and a server can run as interchangeable pods behind a plain round-robin load balancer with no session store anywhere.
The part that doesn't get simpler is authorization. A protected MCP server is now explicitly an OAuth 2.1 resource server, and the mistake we'd bet on is a server that validates a token's signature and expiry but never checks who the token was actually issued for.
Removing sessions removes a whole category of bugs. It doesn't reduce the authorization obligations at all: a handle still isn't proof of identity, and a token that verifies still needs its audience checked.
We'd start by deleting the session layer before touching anything else, and treat the audience check as the one line of authorization code worth reading twice.
Three weeks ago the Model Context Protocol shipped the largest revision since it launched. The 2026-07-28 specification removes the initialization handshake, removes protocol-level sessions, moves capability negotiation onto every individual request, deprecates three core features, introduces a formal Extensions framework, and tightens authorization into something a security team can actually review. If you built an MCP server against 2025-11-25 and it depends on session affinity, some of it no longer applies.
This is a build guide, not a critique. The existing post The MCP OAuth Permissioning Problem argues about whether MCP's authorization model is the right one; this post assumes you have to ship a server anyway and walks through doing it correctly under the current spec. OAuth fundamentals (the code flow, PKCE, refresh mechanics, token storage) are covered elsewhere on this site and are assumed. What's explained here is the part that is specific to MCP: what a server must implement to be a conforming OAuth 2.1 resource server, and why the audience check is the one you cannot skip.
We'll lean on one example throughout: a hypothetical supplier-invoice server for a procurement team. It exposes two read tools and one narrow write tool against an ERP, and it is deployed as three pods behind an ordinary load balancer. That last detail used to be the hard part. It is now the easy part, and understanding why is most of what changed.
The shape of the change: MCP is now a stateless protocol. Every request carries its own protocol version, client identity, and capability set, and any request may be handled by any server instance. State that used to hide in the transport now has to be modelled explicitly: as a handle the model passes back, or as a task the client polls.
An MCP server is a JSON-RPC 2.0 endpoint that advertises a set of capabilities and answers a fixed vocabulary of methods. It is not an agent, it holds no reasoning loop, and it makes no decisions about when its capabilities are used. It publishes what it can do and validates what it is asked to do.
Three server primitives survive the revision and remain the whole surface most implementers need:
name, an optional title for display, a description, a JSON Schema inputSchema, and optionally an outputSchema for structured results.Three client-side features are now deprecated by SEP-2577: Roots, Sampling, and Logging. The SEP is blunt about why. Roots were only ever "informational guidance" (servers were never required to respect them), and the feature support matrix showed few clients implementing it. Sampling, which let a server ask the client's model to generate text on its behalf, required human-in-the-loop approval, model selection logic, and tool-loop support to implement correctly, and almost nobody did. Logging duplicated stderr and OpenTelemetry with a worse interface. The recommended replacements are tool parameters or resource URIs for Roots, direct integration with an LLM provider API for Sampling, and OpenTelemetry for Logging.
Deprecated is not removed. Under the feature lifecycle policy adopted in SEP-2596, a deprecated feature must remain in the specification for a minimum of twelve months, measured from the release of the revision that first marks it deprecated, not from the date the SEP reaches Final, and not restarted by each subsequent revision that carries it. The feature becomes eligible for removal in the first revision released as Current on or after that window elapses, and features may sit Deprecated for much longer than the minimum. The floor can only be shortened for a feature with a published security advisory or documented in-the-wild exploitation for which no in-place mitigation exists, and even then the window must be at least ninety days. Wire behaviour during deprecation is unchanged: nothing breaks today. But new servers should not adopt any of the three, and the SEP notes that removing Sampling is a net security improvement on its own, since it gave servers a channel to drive the client's model.
A handful of methods are removed outright rather than deprecated, because they cannot exist in a stateless protocol: initialize and notifications/initialized, logging/setLevel, notifications/roots/list_changed, resources/subscribe and resources/unsubscribe, and ping in both directions. roots/list survives only as a Multi Round-Trip input request, not as a standalone server-to-client RPC. The HTTP GET stream endpoint is gone too. Everything is POST, and the server-to-client notification channel it used to carry is now opened by a subscriptions/listen request whose response is itself a long-lived SSE stream, with the client opting in to each notification type it wants. Resumable SSE streams via Last-Event-ID are gone. The 2024-11-05 HTTP+SSE transport is formally deprecated.
The previous protocol required a three-way handshake that negotiated protocol version, server capabilities, and client capabilities, and then expected that negotiated state to persist for the life of the connection. SEP-2575 removes it. The stated motivation is operational: a stateless load balancer cannot serve a stateful protocol, so operators were forced into sticky sessions, shared session stores, and resynchronisation logic after every disconnect.
What replaces the handshake is a per-request metadata block. Every request's _meta now carries protocol state under the io.modelcontextprotocol/ namespace: protocolVersion and clientCapabilities are required on every request, clientInfo is a SHOULD that clients are expected to send unless specifically configured not to, and logLevel is optional. A request missing a required field is malformed and the server MUST reject it with INVALID_PARAMS (JSON-RPC -32602) and, on HTTP, 400 Bad Request. Servers MUST NOT infer capabilities from prior requests: an empty capabilities object means the client supports no optional capabilities, on that request, full stop.
Version negotiation happens inline. If the server does not implement the version a request declares, it returns error code -32022 with a supported array and the client retries with something mutual. Alternatively the client calls server/discover, a new RPC that servers MUST implement and clients MAY call, returning supportedVersions, capabilities, serverInfo, and optional instructions. On stdio, where there is no per-request HTTP status to inspect, server/discover doubles as the probe a dual-version client uses to decide whether to fall back to initialize.
Figure 1 — Discovery and invocation under the stateless core. Three requests, three arbitrary pods, no shared state.
This also matters at a gateway. Selected body fields are mirrored into HTTP headers so intermediaries can route without parsing JSON: MCP-Protocol-Version is required on every POST, Mcp-Method mirrors method, and Mcp-Name mirrors params.name or params.uri for tools/call, resources/read, and prompts/get. Servers MUST reject any request where a header disagrees with the body, with 400 and error -32020 (HeaderMismatch). Otherwise a load balancer routing on the header and a server executing on the body would be reading different instructions. Servers MUST also attach ttlMs and cacheScope to every completed server/discover, list, and resources/read result, caching hints modelled on HTTP Cache-Control, so tools/list becomes cacheable and cacheScope: "private" keeps a per-user result out of a shared proxy.
sticky LB --> Pod A (holds session) --> session store --> Pod B = error, wrong instance AFTER: Client --self-describing--> round-robin LB --> Pod A | Pod B | Pod C all --> app state keyed by handle The state did not disappear. It moved out of the transport and into the application, where it is addressable by any instance. -->
Figure 2 — What "stateless" actually buys. The state moves from the connection to an explicit, shared, addressable place.
If your server genuinely needs continuity across calls (an open cart, a browser context, a multi-step reconciliation run), the spec's non-normative guidance is to mint an explicit handle from a creation tool and accept it as an ordinary argument on subsequent calls. The model carries the handle forward, and the spec is blunt about one requirement in particular: possession of a handle is not authentication. The security best practices document adds a new State Handle Hijacking section stating that servers implementing authorization MUST verify all inbound requests and MUST NOT treat a handle as proof of identity, and SHOULD bind handles server-side to the authenticated user, keying stored state as <user_id>:<handle> where the user ID comes from the verified token, not from the client. Handles should be opaque, high-entropy, and time-bounded, and the creation tool's description should state the retention policy so the model can see it.
The Python SDK for this revision is mcp 2.0.0b1, where FastMCP has been renamed MCPServer and the decorator API from v1 is preserved. Install it explicitly — the beta is not the default resolution.
uv add "mcp[cli]==2.0.0b1"We think tool design is where most server quality lives on an MCP server, and it rewards being deliberate. The invoice server exposes a narrow read pair and one write, and every description is written for a reader who has no other context.
from mcp.server import MCPServer
mcp = MCPServer("supplier-invoices")
@mcp.tool()
def search_invoices(supplier_id: str, status: str = "open", limit: int = 20) -> list[dict]:
"""Find invoices for one supplier, newest first.
Read-only. Never changes invoice state. `status` is one of open, paid,
disputed. Returns at most `limit` summaries (hard cap 100); call
get_invoice for line-item detail. Scoped to the caller's business unit.
"""
return erp.search(supplier_id=supplier_id, status=status, limit=min(limit, 100))That docstring is doing more work than it looks like. It says what the tool does not do, because a model choosing between three tools is reading descriptions as its only signal, and it names the enum values inline rather than leaving the model to guess. It also caps limit server-side rather than trusting the argument: the spec's tool security requirements say servers MUST validate all tool inputs, rate limit invocations, and sanitise outputs, and a limit parameter is exactly the kind of thing a poisoned prompt will try to set to 100,000.
The write tool gets an outputSchema so the client and the model both know the shape of what comes back. If a tool declares one, servers MUST return conforming structuredContent and clients SHOULD validate it. For backwards compatibility a tool returning structured content should also serialise it into a text block.
from pydantic import BaseModel, Field
class ReviewFlag(BaseModel):
invoice_id: str
flagged: bool
reviewer_queue: str = Field(description="Queue the invoice was routed to")
@mcp.tool()
def flag_invoice_for_review(invoice_id: str, reason: str) -> ReviewFlag:
"""Route one invoice to human review. Does not approve, reject, or pay.
Idempotent: flagging an already-flagged invoice returns the existing
flag. The only state-changing tool on this server.
"""
flag = erp.flag(invoice_id=invoice_id, reason=reason, actor=current_principal())
return ReviewFlag(invoice_id=invoice_id, flagged=True, reviewer_queue=flag.queue)The TypeScript SDK v2 splits the old monolithic package into @modelcontextprotocol/server and @modelcontextprotocol/client, is ESM-only, requires Node 20+, and replaces .tool() with registerTool. Schemas now use Standard Schema, so Zod, Valibot, or ArkType all work.
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod/v4";
const server = new McpServer({ name: "supplier-invoices", version: "1.0.0" });
server.registerTool(
"get_invoice",
{
description:
"Fetch one invoice with line items. Read-only. Use search_invoices to find an id.",
inputSchema: z.object({ invoiceId: z.string().describe("ERP invoice id") }),
},
async ({ invoiceId }) => ({
content: [{ type: "text", text: await renderInvoice(invoiceId) }],
}),
);There's also a genuinely useful new schema feature: a tool parameter may carry an x-mcp-header annotation, which mirrors that parameter's value into an Mcp-Param-{Name} HTTP header so a gateway can route or rate-limit on it without reading the body. It only applies to primitive types, only to properties statically reachable through properties keys, and the spec warns explicitly that sensitive parameters (passwords, API keys, tokens, PII) SHOULD NOT be annotated, because header values are visible to every intermediary on the path. Tenant IDs and regions are the intended use.
Authorization is OPTIONAL in MCP, and stdio servers SHOULD NOT implement this specification at all; they take credentials from the environment. That's the right call for a process running locally under a user's own shell. It stops being the right call the moment the server sits behind an HTTP endpoint holding an ERP connection, which is the case worth reading twice.
A protected MCP server is an OAuth 2.1 resource server. The MCP client is the OAuth client. The authorization server is a separate concern and may or may not be co-hosted. Four normative requirements define the MCP-specific part:
401 whose WWW-Authenticate header carries resource_metadata pointing at /.well-known/oauth-protected-resource, and SHOULD carry a scope parameter naming what the operation needs.resource parameter (the canonical URI of the target MCP server) in both the authorization request and the token request, regardless of whether the authorization server supports it.That last one is the load-bearing requirement, and the spec states its consequences unusually plainly. "MCP servers MUST only accept tokens specifically intended for themselves and MUST reject tokens that do not include them in the audience claim." And if the server calls an upstream API, "the MCP server MUST NOT pass through the token it received from the MCP client." The security best practices document names the anti-pattern: token passthrough, explicitly forbidden, because it circumvents rate limiting and request validation that depend on audience, destroys the audit trail on both sides, and turns the server into a proxy for data exfiltration for anyone holding a stolen token.
The one check you cannot skip: if your server validates a token's signature but not its audience, it will happily accept any token minted by the same issuer for any other service, and a compromised sibling service becomes a path into your ERP. Audience validation is what makes the rest of the flow mean anything.
Clients MUST now apply RFC 9207 issuer validation: record the issuer from the authorization server's validated metadata before redirecting, and compare the iss on the response using simple string comparison with no normalisation. A future revision is expected to upgrade authorization-server inclusion of iss from SHOULD to MUST, per the spec. Dynamic Client Registration is now deprecated too, retained only for authorization servers that do not support OAuth Client ID Metadata Documents, where the client's client_id is an HTTPS URL the authorization server fetches.
Server => 401 WWW-Authenticate: resource_metadata, scope Client --> /.well-known/oauth-protected-resource => authorization_servers Client --> AS metadata (RFC 8414 or OIDC) => endpoints + issuer Client --> /authorize + PKCE(S256) + resource=https://mcp.example.com AS --> code + iss (client validates iss, no normalisation) Client --> /token + code_verifier + resource AS --> access token with aud = MCP server Client --> Server + Bearer token => server validates signature AND audience -->
Figure 3 — The MCP authorization flow. The resource parameter and the audience check are the two MCP-specific obligations.
In the Python SDK the resource-server side is configuration plus a verifier. The beta API is still moving, so treat the shape below as illustrative and check the SDK's auth example before you copy it:
from mcp.server import MCPServer
from mcp.server.auth.settings import AuthSettings
mcp = MCPServer(
"supplier-invoices",
token_verifier=ErpTokenVerifier(), # validates signature, exp, and audience
auth=AuthSettings(
issuer_url="https://id.example.com",
resource_server_url="https://mcp.example.com/invoices",
required_scopes=["invoices:read"],
),
stateless_http=True,
json_response=True,
)resource_server_url is what gets published in Protected Resource Metadata and what the client will send as resource. It must be the canonical URI: scheme and host present, no fragment, and consistently without a trailing slash. Getting it wrong is the most common cause of a client that authenticates successfully and then gets rejected at the resource.
For scopes, the spec's model is progressive rather than up-front. scopes_supported is meant to represent the minimal set for basic functionality; anything more is requested incrementally. When a token is insufficient at runtime, the server SHOULD return 403 with error="insufficient_scope" and a scope parameter naming everything the operation needs. All of it goes in a single challenge, since incremental challenging forces multiple authorization round-trips for one operation. Clients then compute the union of previously requested and newly challenged scopes and re-authorize. The invoice server publishes invoices:read and challenges for invoices:flag only when flag_invoice_for_review is called.
Enterprises with an identity provider have a second option. The Enterprise-Managed Authorization extension went stable on 18 June 2026, built on the Identity Assertion JWT Authorization Grant: the client obtains an ID-JAG from the organisation's IdP during SSO and exchanges it for an access token at the MCP server's authorization server, so users never see a per-server consent screen. Okta is the first supported provider, with Anthropic's clients and VS Code shipping support alongside a handful of servers. If your server is internal and your users already sign in through an IdP, this is the deployment worth targeting.
SEP-2133 makes extensions a governed, first-class mechanism rather than a convention. Extensions carry reverse-DNS identifiers, live in their own ext-* repositories with delegated maintainers, version independently of the core specification, and are negotiated through an extensions map inside client and server capabilities. Because client capabilities now travel per request, extension support is declared per request too, inside io.modelcontextprotocol/clientCapabilities.
Tasks (io.modelcontextprotocol/tasks) graduated out of the experimental core into an official extension and was redesigned for statelessness. When a server decides a request will run long, it answers tools/call with resultType: "task" and a Task carrying a taskId, initial status, ttlMs, and a suggested pollIntervalMs; the task is durably created before the response is sent. The client then drives it with tasks/get, tasks/update, and tasks/cancel. Statuses are working, input_required, completed, failed, and cancelled, the last three terminal. Cancellation is cooperative: the server acknowledges the intent and is not obliged to stop. tasks/list was removed, because without sessions there is no safe way to scope "list my tasks."
Task creation is server-directed: the client opts in once via the extension capability and must handle whichever result shape arrives, and a server must not return a task to a client that did not declare support. For the invoice server, a quarter-end reconciliation run across four thousand invoices is a task; get_invoice is not.
The other official extension is MCP Apps, which shipped 26 January 2026. Tools declare a UI template ahead of time via _meta.ui.resourceUri pointing at a ui:// resource; the host renders it in a sandboxed iframe and the UI talks back over JSON-RPC on postMessage. Pre-declaring templates is what lets a host review the HTML before it renders anything, and hosts can require explicit approval for UI-initiated tool calls.
Server-initiated interaction no longer exists as an independent request, either. Multi Round-Trip Requests replaced server-to-client requests entirely. When a server needs an elicitation, it returns resultType: "input_required" with an inputRequests map and an opaque requestState, and the client re-issues the original call with inputResponses and the echoed state, under a different JSON-RPC id. Because the payload is self-contained, the retry can land on any instance.
If you have a server in production, the work is bounded and mostly mechanical. In rough order of how much it will hurt:
_meta on every request. Reject missing required fields with INVALID_PARAMS. Implement server/discover; it's a MUST, not a nicety.MCP-Protocol-Version. Validate Mcp-Method and Mcp-Name against the body and return -32020 on mismatch. If an intermediary enforces policy on mirrored headers, it should reject requests whose declared version predates header–body validation rather than trusting them.InputRequiredResult plus a resumable requestState.405. An Mcp-Session-Id header is ignored, never echoed. Last-Event-ID is ignored.A server that wants to support both eras may keep the old initialize RPC alongside the new stateless RPCs. Whether that is worth it depends entirely on your client population; for an internal server behind an IdP, it usually is not.
On version pinning: the era-detection dance (try a modern request, inspect the 400 body, fall back only if it is not a recognised modern error) exists because 400 is now a legitimate modern response for three different conditions. If you implement fallback without reading the body, you will downgrade clients that were talking to a perfectly modern server.
The 2026-07-28 revision is a bet that MCP's future is remote, multi-tenant, and horizontally scaled, and that the protocol should therefore look like HTTP rather than like a session-oriented RPC framework. For implementers the trade is explicit: you give up the convenience of hidden per-connection state, and you get a server you can run as three interchangeable pods behind a round-robin load balancer with cacheable tool lists and header-based routing.
We think the parts that need care are mostly the ones this revision left alone. Tool descriptions are the only signal a model has when choosing among your tools, and they get read as instructions. Write them as if a skeptical reader with no context has to act on them. Inputs need server-side validation regardless of what the schema says. A handle just names some stored state; treat it as identity and you've built an authorization bypass with extra steps. Check the audience claim, every time, even on a token whose signature checks out cleanly.
Build for the boundary, not the happy path. A conforming MCP server is mostly an exercise in refusing things: rejecting requests with missing metadata, rejecting header–body mismatches, rejecting tokens minted for someone else, rejecting handles presented by the wrong principal. The protocol got simpler this revision. The obligations did not.