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.

Advertisement

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.

  1. No protocol sessions. The Mcp-Session-Id header is gone, and list results such as tools/list no longer vary per connection. State that must span calls is carried in explicit, server-minted handles passed as ordinary tool arguments.
  2. No handshake. initialize and notifications/initialized are removed. Each request carries its protocol version and client capabilities in _meta under io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities. Servers must implement a new server/discover method that advertises supported versions, capabilities and identity.
  3. No server-initiated requests. Sampling, elicitation and roots requests are returned inside an InputRequiredResult under the Multi Round-Trip Requests pattern (MRTR); the client retries the original request with the answers.
  4. No GET stream and no resumability. Change notifications arrive on the response stream of a subscriptions/listen request. Last-Event-ID and SSE event ids are removed; a broken stream loses its request, which the client re-issues with a new id.
  5. Mirrored headers. Mcp-Method and Mcp-Name are required on POST requests so intermediaries can route without parsing JSON, and servers must reject mismatches.
  6. 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": {}}}}}
A stateless remote MCP server: every request is a self-describing POST that any replica can answerMCP clienthost app, agentGateway / LBroutes on Mcp-MethodPOST /mcpReplica Ano session stateReplica Bno session stateBackendsAPIs, databasesAuthorization serverissues audience-bound tokensOAuth, resource=requestState keyHMAC or AEADHandle storeserver-minted idsPer request: MCP-Protocol-Version, Mcp-Method, Mcp-Name,Authorization: Bearer, _meta with version and client capabilitiesResponse: application/json, or an SSE streamscoped to that one request, closed after the resultNo initialize handshake, no Mcp-Session-Id, no GET stream: cross-call state travels in requestState or in explicit handlesClosing a response stream cancels that request; a broken stream is retried as a new request with a new id
Because each request carries its own version, capabilities and credentials, a load balancer can send it to any replica. State that spans calls lives in requestState or in explicit handles, never in the connection.

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.

Advertisement

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

  1. The client POSTs tools/call for create_ticket with id 7, as shown above. Any replica receives it, validates headers against the body, and verifies the bearer token.
  2. The tool sees that project OPS requires a severity, which the arguments lack. The client declared elicitation support, so the server returns input_required with an inputRequests entry named severity holding a form-mode elicitation/create request, plus a sealed requestState recording that validation passed and the project's on-call rota was already looked up.
  3. The host shows the form; the user picks sev2. The client POSTs tools/call again with id 8, the same arguments, inputResponses carrying the answer, and the unchanged requestState.
  4. 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.
  5. 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

FailureSymptomFix
Proxy buffers SSEProgress arrives all at once at the endSend X-Accel-Buffering: no; disable buffering on the route
Header and body disagreeGateway policy bypassedValidate both, reject with -32020
Unsealed requestStateUser escalates privileges by editing itHMAC or AEAD, principal, expiry, digest
Stream drop on a write toolDuplicate side effects on retryIdempotency keys on every mutating tool
Token passthroughUpstream accepts tokens it never issuedAudience checks; own credentials upstream
Unordered tools/listClients re-cache, prompt caches missReturn 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

  1. Read the 2026-07-28 changelog and list every place your server relies on sessions, the GET stream, initialize or server-initiated requests.
  2. Implement server/discover, per-request _meta parsing and header validation with HeaderMismatch errors, or adopt an SDK release that does.
  3. Replace session state with server-minted handles in a shared store and sealed requestState for MRTR, bound to principal, expiry and request digest.
  4. Add idempotency keys to every mutating tool, because broken streams are retried as new requests.
  5. Publish Protected Resource Metadata, enforce token audience, never pass tokens through, and return complete scope challenges.
  6. 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.
Key takeaway: The 2026-07-28 revision makes remote MCP servers stateless: one POST endpoint, self-describing requests with mirrored headers that must match the body, MRTR instead of server-initiated requests, and no sessions, GET stream or resumability. Build servers that any replica can answer, carry cross-call state in sealed requestState or explicit handles, make mutating tools idempotent, act as a strict OAuth resource server, and handle 2025-era clients deliberately rather than by accident.