A remote MCP server is one a client reaches over the network rather than launching as a local subprocess. For most of 2025, building one meant managing sessions: an initialize handshake, an Mcp-Session-Id header, a long-lived GET stream for server-initiated messages, and sticky routing so follow-up requests reached the replica that held the session. The 2026-07-28 revision of the specification removed all of that. Every request now describes itself, any replica can answer it, and server-to-client questions travel inside results rather than as separate requests.
This article explains what a remote server has to do under that revision, from first principles: what goes on the wire, how to keep state without sessions, how to act as an OAuth resource server, what gateways may and may not trust, and how to keep serving clients that still speak the 2025 revisions. Every protocol detail was checked against the published 2026-07-28 specification; products, hosting providers and client support levels change quickly and are deliberately not named.
What changed in the 2026-07-28 revision
The changelog lists the changes as a set of specification enhancement proposals. For someone operating a server, they reduce to six facts.
- No protocol sessions. The
Mcp-Session-Idheader is gone, and list results such astools/listno longer vary per connection. State that must span calls is carried in explicit, server-minted handles passed as ordinary tool arguments. - No handshake.
initializeandnotifications/initializedare removed. Each request carries its protocol version and client capabilities in_metaunderio.modelcontextprotocol/protocolVersionandio.modelcontextprotocol/clientCapabilities. Servers must implement a newserver/discovermethod that advertises supported versions, capabilities and identity. - No server-initiated requests. Sampling, elicitation and roots requests are returned inside an
InputRequiredResultunder the Multi Round-Trip Requests pattern (MRTR); the client retries the original request with the answers. - No GET stream and no resumability. Change notifications arrive on the response stream of a
subscriptions/listenrequest.Last-Event-IDand SSE event ids are removed; a broken stream loses its request, which the client re-issues with a new id. - Mirrored headers.
Mcp-MethodandMcp-Nameare required on POST requests so intermediaries can route without parsing JSON, and servers must reject mismatches. - Deprecations. Roots, Sampling and Logging are deprecated, as are Dynamic Client Registration (in favour of Client ID Metadata Documents) and the old HTTP+SSE transport. Deprecated features keep working for a minimum twelve-month window.
Pages on this site that describe MCP sessions and the HTTP+SSE transport document the earlier revisions; they remain relevant because your server will meet clients that speak them, as the compatibility section below explains.
The wire: one endpoint, one POST per message
The server exposes a single MCP endpoint, for example https://tools.example.com/mcp, that accepts POST. Each JSON-RPC request is its own POST with an Accept header listing both application/json and text/event-stream. The server answers each request either with a single JSON object or with an SSE stream scoped to that request, which may carry progress notifications before the final response and should end when the response is sent.
POST /mcp HTTP/1.1
Host: tools.example.com
Authorization: Bearer eyJhbGciOi...
Content-Type: application/json
Accept: application/json, text/event-stream
MCP-Protocol-Version: 2026-07-28
Mcp-Method: tools/call
Mcp-Name: create_ticket
{"jsonrpc": "2.0", "id": 7, "method": "tools/call",
"params": {"name": "create_ticket",
"arguments": {"project": "OPS", "title": "Disk full on db-3"},
"_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "ops-agent", "version": "4.2.0"},
"io.modelcontextprotocol/clientCapabilities": {"elicitation": {}}}}}Several transport rules are easy to miss. Servers must validate the Origin header and return 403 when it is present and invalid, which protects against DNS rebinding. SSE responses should include X-Accel-Buffering: no so reverse proxies such as nginx do not buffer events. Closing the response stream is the cancellation signal on this transport: the server should stop work and send nothing further for that request. Long-lived listen streams should emit periodic SSE comment lines, lines that begin with a colon, as keep-alives so idle timeouts in intermediaries do not cut them.
Mirrored headers and what a gateway may trust
The header MCP-Protocol-Version must match the version in the body's _meta. Mcp-Method mirrors the JSON-RPC method, and Mcp-Name mirrors params.name or params.uri for tools/call, resources/read and prompts/get. Tool authors can also mark primitive parameters with x-mcp-header in the input schema; clients then mirror them as Mcp-Param-{Name} headers, so a gateway can route a query to a region without opening the body. Values that are not plain ASCII are sent in a Base64 sentinel form, =?base64?...?=.
Any component that processes the body must check that headers and body agree and reject a mismatch with HTTP 400 and JSON-RPC error -32020, HeaderMismatch. This matters because a load balancer that rate-limits or authorises by header while the server executes by body is a classic confused-deputy setup. An unsupported version gets 400 with UnsupportedProtocolVersionError listing the versions the server supports; an unknown method gets 404 with -32601.
SUPPORTED = {"2026-07-28"}
def validate(req) -> None:
body = req.json()
meta = body.get("params", {}).get("_meta", {})
version = req.headers.get("MCP-Protocol-Version")
if version != meta.get("io.modelcontextprotocol/protocolVersion"):
raise HttpError(400, rpc_error(body, -32020, "MCP-Protocol-Version does not match _meta"))
if version not in SUPPORTED:
raise HttpError(400, unsupported_version(body, sorted(SUPPORTED)))
if req.headers.get("Mcp-Method") != body.get("method"):
raise HttpError(400, rpc_error(body, -32020, "Mcp-Method does not match body"))
if body["method"] in ("tools/call", "prompts/get", "resources/read"):
expected = body["params"].get("name") or body["params"].get("uri")
if decode_sentinel(req.headers.get("Mcp-Name", "")) != expected:
raise HttpError(400, rpc_error(body, -32020, "Mcp-Name does not match body"))This is framework-neutral pseudocode, not an SDK API; official SDKs that support the revision perform these checks for you, so use them where available and treat this as a description of what must happen. The specification also tells intermediaries that enforce policy on mirrored headers to reject requests whose version is older than one requiring validation, rather than trusting headers that nobody checked. Aggregation and namespacing across many servers are covered in MCP gateway architecture.
State without sessions: handles and MRTR
Removing sessions does not remove the need for state; it makes the state explicit. There are two mechanisms.
For state that spans several tool calls, such as an open database transaction or an uploaded file, the server mints a handle and returns it in a tool result, and the client passes it back as an ordinary argument. The handle lives in a store every replica can reach, is bound to the authenticated principal, and expires.
For a single operation that needs input midway, the server uses MRTR. Instead of sending an elicitation request, it returns a result with "resultType": "input_required", an inputRequests map whose values are elicitation, sampling or roots requests, and optionally an opaque requestState string. The client gathers the answers and retries the original request, with a new JSON-RPC id, adding inputResponses and echoing requestState unchanged. Only tools/call, prompts/get and resources/read may return this result, and a server must not ask for input types the client did not declare in its capabilities.
Because requestState round-trips through the client, the specification requires servers to treat it as attacker-controlled. If it influences authorization or business logic, protect its integrity with an HMAC or AEAD and reject anything that fails verification. Include the principal, a short expiry and a digest of the originating method and parameters so state cannot be replayed by another user or attached to a different request. Even then, it is not single-use: an operation that must happen at most once needs a server-side record.
import hmac, hashlib, json, time, base64
def request_digest(method: str, params: dict) -> str:
# name or uri plus arguments only: _meta (traceparent) changes on every retry
target = params.get("name") or params.get("uri")
return hashlib.sha256(json.dumps([method, target, params.get("arguments")],
sort_keys=True).encode()).hexdigest()
def seal(principal: str, method: str, params: dict, data: dict, key: bytes) -> str:
digest = request_digest(method, params)
payload = json.dumps({"sub": principal, "exp": int(time.time()) + 300,
"req": digest, "data": data}).encode()
mac = hmac.new(key, payload, hashlib.sha256).digest()
return base64.urlsafe_b64encode(mac + payload).decode()
def unseal(token: str, principal: str, method: str, params: dict, key: bytes) -> dict:
raw = base64.urlsafe_b64decode(token)
mac, payload = raw[:32], raw[32:]
if not hmac.compare_digest(mac, hmac.new(key, payload, hashlib.sha256).digest()):
raise PermissionError("requestState failed verification")
st = json.loads(payload)
digest = request_digest(method, params)
if st["sub"] != principal or st["exp"] < time.time() or st["req"] != digest:
raise PermissionError("requestState is expired, foreign or for another request")
return st["data"]The digest covers the tool, prompt or resource name and the arguments, excluding _meta, inputResponses and requestState, so a legitimate retry matches. Rotate the key by accepting the previous key for one expiry window.
Worked example: a ticket tool that asks for confirmation
- The client POSTs
tools/callforcreate_ticketwith id 7, as shown above. Any replica receives it, validates headers against the body, and verifies the bearer token. - The tool sees that project OPS requires a severity, which the arguments lack. The client declared elicitation support, so the server returns
input_requiredwith aninputRequestsentry namedseverityholding a form-modeelicitation/createrequest, plus a sealedrequestStaterecording that validation passed and the project's on-call rota was already looked up. - The host shows the form; the user picks sev2. The client POSTs
tools/callagain with id 8, the same arguments,inputResponsescarrying the answer, and the unchangedrequestState. - A different replica receives the retry. It unseals the state, confirms principal, expiry and digest, skips the rota lookup, creates the ticket using an idempotency key derived from the digest, and returns a complete result.
- If the network breaks before step 4's response arrives, the client re-issues the request with a new id. The idempotency key makes the backend return the existing ticket instead of opening a second one.
No replica held anything in memory between steps, which is the point: you can deploy, scale in or lose a node in the middle of this exchange without breaking it.
Authorization: acting as a resource server
Authorization is optional in the protocol, but any server reachable on the internet with user data needs it. Under the HTTP authorization specification the server is an OAuth 2.1 resource server and must publish OAuth 2.0 Protected Resource Metadata (RFC 9728). An unauthenticated request gets 401 with a WWW-Authenticate header whose resource_metadata parameter points to that document, for example https://tools.example.com/.well-known/oauth-protected-resource, ideally with a scope parameter naming the scopes needed.
Clients must send the resource parameter (RFC 8707) with the server's canonical URI in both authorization and token requests, and the server must verify that every token was issued for it as the audience. It must not accept tokens minted for other services, and must not pass the client's token through to upstream APIs; use its own credentials or a token exchange for those. When a token lacks a scope at runtime, return 403 with error="insufficient_scope" and all the scopes the operation needs in one challenge, so the client can step up once. The revision also deprecates Dynamic Client Registration in favour of Client ID Metadata Documents and requires clients to validate the iss parameter in authorization responses when present. The full flows are in MCP authorization architecture and the choice between user-delegated and machine credentials in MCP authentication models.
Serving clients that still speak 2025
Clients upgrade slowly, so a public server in late 2026 will receive both kinds of traffic. A server that supports only the new revision should return 405 to GET and DELETE on the endpoint, ignore any Mcp-Session-Id header without minting or echoing one, and ignore Last-Event-ID. A client that speaks both eras may send a modern request first; a 400 whose body is a recognised modern error means the server is modern, while an empty or unrecognised body means it should fall back to initialize.
If your users depend on older clients, run a dual-stack endpoint: dispatch on whether the request carries the modern _meta version or is an initialize call, and keep the legacy path's session state in a shared store so you do not reintroduce sticky routing for modern traffic. Measure the share of legacy traffic by client name and set a retirement date for that path.
Operational guidance and failure modes
| Failure | Symptom | Fix |
|---|---|---|
| Proxy buffers SSE | Progress arrives all at once at the end | Send X-Accel-Buffering: no; disable buffering on the route |
| Header and body disagree | Gateway policy bypassed | Validate both, reject with -32020 |
| Unsealed requestState | User escalates privileges by editing it | HMAC or AEAD, principal, expiry, digest |
| Stream drop on a write tool | Duplicate side effects on retry | Idempotency keys on every mutating tool |
| Token passthrough | Upstream accepts tokens it never issued | Audience checks; own credentials upstream |
| Unordered tools/list | Clients re-cache, prompt caches miss | Return tools in a deterministic order |
List and read results now carry required ttlMs and cacheScope fields. Set a realistic freshness hint so clients stop polling, and use "private" whenever the list depends on the caller's permissions, so shared caches never hand one user's tools to another. Propagate OpenTelemetry context through the traceparent key in _meta, and log method, name, client name, version and outcome per request; with no sessions, those fields are your only way to group a client's activity.
What to do next
- Read the 2026-07-28 changelog and list every place your server relies on sessions, the GET stream, initialize or server-initiated requests.
- Implement server/discover, per-request _meta parsing and header validation with HeaderMismatch errors, or adopt an SDK release that does.
- Replace session state with server-minted handles in a shared store and sealed requestState for MRTR, bound to principal, expiry and request digest.
- Add idempotency keys to every mutating tool, because broken streams are retried as new requests.
- Publish Protected Resource Metadata, enforce token audience, never pass tokens through, and return complete scope challenges.
- Decide your legacy policy: 405 and ignore, or a dual-stack path with a measured retirement date, then set ttlMs, cacheScope and deterministic tool ordering.