The 2026-07-28 Model Context Protocol revision removes protocol sessions, so every request carries its own version and identity, and state that spans calls becomes a handle you must bind to a user. It also makes clients validate the RFC 9207 iss parameter, deprecates dynamic client registration for Client ID Metadata Documents, and binds client credentials to their issuer.
Each change lands on different code. Servers lose session IDs and gain header checks and state handling duties. Clients gain issuer checks, a new credential store layout and a new registration default. Authorization servers should emit iss and accept URL based client IDs. Token passthrough is still forbidden, and audience checks are still mandatory.
This post quotes the normative text from modelcontextprotocol.io and ends with an upgrade walkthrough and a review checklist. For earlier background, see our posts on the 2025-06-18 resource server model, the token passthrough anti-pattern and CIMD versus dynamic registration.
Key takeaways
- Sessions are gone, state is not. The
initializehandshake andMcp-Session-Idare removed. Cross call state now lives in handles passed as tool arguments, and possession of a handle must never count as authentication. requestStateis attacker controlled. The new Multi Round-Trip Requests pattern sends server state through the client, so servers must integrity protect it when it affects authorization or business logic.- Clients must check
iss. Before redeeming a code, a client compares a presentissvalue to the issuer it recorded before redirecting. This closes the mix-up attack described in RFC 9207. - CIMD is the default, DCR is deprecated. Dynamic Client Registration still works for backward compatibility, and clients still using it must send an
application_type. - Credentials are bound to their issuer. Clients must key stored credentials by issuer and re-register when the authorization server changes.
- Token passthrough is still forbidden. Servers accept only tokens issued for them and never forward the client's token upstream.
- Headers must match bodies. Servers must reject requests where the new
Mcp-MethodorMcp-Nameheader disagrees with the body. - The server is still software. Scan its dependency tree, track its SDK version and patch it like any other service.
What changed in 2026-07-28, in one table
The release post on the official MCP blog, dated 28 July 2026 and written by the lead maintainers, describes the revision as a move from "a bidirectional stateful protocol into a request/response stateless protocol." The changelog lists the changes since the previous revision, 2025-11-25. Here are the ones that matter for security, with the Specification Enhancement Proposal (SEP) numbers the changelog gives.
| Change | Changelog section | SEP or PR | Who must change code | Security effect |
|---|---|---|---|---|
Remove protocol sessions and Mcp-Session-Id | Major | SEP-2567 | Servers, clients | No session IDs to steal; state moves to handles you must protect |
Remove initialize handshake; version and capabilities in _meta on every request | Major | SEP-2575 | Servers, clients | Every request is evaluated on its own |
Add server/discover | Major | SEP-2575 | Servers (must implement) | Clean version probing without a session |
| Multi Round-Trip Requests replace server initiated requests | Major | SEP-2322 | Servers, clients | New requestState field that servers must treat as untrusted |
Remove SSE resumability and Last-Event-ID | Major | SEP-2575 | Servers, clients | No stream replay surface; broken streams mean a new request |
Require Mcp-Method and Mcp-Name headers | Minor | SEP-2243 | Clients send, servers validate | Gateways can enforce policy per tool; mismatches must be rejected |
Authorization servers should send iss; clients must validate a present iss | Minor | SEP-2468 | Clients, authorization servers | Stops authorization server mix-up |
application_type required in DCR | Minor | SEP-837 | Clients using DCR | Fewer broken native client registrations |
| Client credentials bound to issuer | Minor | SEP-2352 | Clients | Stops reuse of credentials across authorization servers |
| Deprecate DCR in favor of CIMD | Deprecated | PR 2858 | Clients, authorization servers | Shifts client identity to verifiable HTTPS URLs |
| Deprecate Roots, Sampling, Logging; HTTP+SSE reclassified as Deprecated | Deprecated | SEP-2577, SEP-2596 | Servers, clients | Less legacy surface over time |
| Feature lifecycle policy with a twelve month minimum deprecation window | Governance | SEP-2596 | Everyone | Predictable removal dates you can plan patching around |
The authorization changes sit under "Minor changes" in the changelog. Minor means they do not reshape the protocol, not that a client can skip them.
What does "stateless" change for session handling, replay and request authentication?
Statelessness changes three things a reviewer cares about: what an attacker can steal, what an attacker can replay, and how each request proves who sent it.
Sessions are gone, and so is the old session hijacking problem
Under 2025-11-25 and earlier, a server could assign a session through the Mcp-Session-Id header, and the security best practices page had a section on session hijacking. The 2026-07-28 changelog removes the mechanism: "Remove protocol-level sessions and the Mcp-Session-Id header from the Streamable HTTP transport." If an old client still sends one, a modern only server should ignore it, "and do not mint or echo session IDs." Fewer sticky session tables and shared session stores means one less class of secret moving around your infrastructure.
State handles are the new thing to protect
The release post is direct: "Dropping the protocol-level session doesn't force your application to be stateless." A tool mints a handle, such as a cart ID, returns it, and the model passes it back as an ordinary argument.
The best practices page replaces session hijacking with a new section, State Handle Hijacking: someone obtains or guesses a handle and calls your tools with it. The mitigation is explicit: "MCP servers MUST NOT treat possession of a state handle as authentication." Servers should use secure random handles, consider expiry, and bind handles to the authenticated user, for example by keying state as <user_id>:<handle> with the user ID taken from the verified token.
A handle is visible to the model, so it will appear in transcripts and logs. Treat it like a key that is guaranteed to leak, and authorize every access against the token's subject.
import secrets
STATE = {} # use your real datastore
def create_workflow(verified_sub: str, data: dict) -> str:
handle = secrets.token_urlsafe(32) # unguessable
STATE[f"{verified_sub}:{handle}"] = data # bound to the token subject
return handle
def load_workflow(verified_sub: str, handle: str) -> dict:
key = f"{verified_sub}:{handle}"
if key not in STATE:
# Same response for "missing" and "belongs to someone else"
raise PermissionError("unknown handle")
return STATE[key]
verified_sub here must come from the validated access token on the current request, never from a tool argument.
requestState under MRTR is attacker controlled input
The Multi Round-Trip Requests (MRTR) pattern replaces server initiated elicitation, sampling and roots requests; the MRTR page calls this "a breaking change." The server returns an InputRequiredResult with inputRequests and an optional opaque requestState, and the client retries the original request with inputResponses and the same requestState.
Because that state passes through the client, servers "MUST treat requestState as an attacker-controlled input." If it influences authorization, resource access or business logic, servers must protect its integrity (HMAC or AEAD are the examples) and reject state that fails verification. To limit replay, servers should bind it to the authenticated principal, a short expiry and the originating request. The spec warns these measures do not by themselves guarantee single use, so one time operations need a server side check.
Here is a minimal signed state in Python. It does not encrypt, so use AEAD if the state carries anything sensitive.
import base64, hashlib, hmac, json, time
KEY = b"load-from-your-secret-store" # rotate like any signing key
def _b64(b: bytes) -> str:
return base64.urlsafe_b64encode(b).decode().rstrip("=")
def _unb64(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
def mint_state(sub: str, method: str, params_digest: str, data: dict, ttl=300) -> str:
body = json.dumps(
{"sub": sub, "exp": int(time.time()) + ttl,
"req": f"{method}:{params_digest}", "data": data},
separators=(",", ":"), sort_keys=True).encode()
mac = hmac.new(KEY, body, hashlib.sha256).digest()
return f"{_b64(body)}.{_b64(mac)}"
def verify_state(token: str, sub: str, method: str, params_digest: str) -> dict:
body_b64, mac_b64 = token.split(".", 1)
body = _unb64(body_b64)
expected = hmac.new(KEY, body, hashlib.sha256).digest()
if not hmac.compare_digest(expected, _unb64(mac_b64)):
raise PermissionError("requestState failed verification")
claims = json.loads(body)
if claims["sub"] != sub:
raise PermissionError("requestState presented by a different principal")
if claims["exp"] < time.time():
raise PermissionError("requestState expired")
if claims["req"] != f"{method}:{params_digest}":
raise PermissionError("requestState used on a different request")
return claims["data"]
Clients must echo requestState exactly, must not inspect or modify it, and must not reuse it on parallel requests.
Replay and stream resumability
The 2025 era transport let clients resume a dropped SSE stream with Last-Event-ID. The transport page now says: "Resumable SSE streams via Last-Event-ID are not supported." A broken stream loses the in-flight request, and the client re-issues it with a new request ID. There is no event store for a stolen event ID to unlock, but retries are now normal, so tools with side effects need their own idempotency or one time token logic.
Header and body must agree
Every Streamable HTTP POST now carries MCP-Protocol-Version, Mcp-Method and, for tools/call, resources/read and prompts/get, an Mcp-Name header. Tools can also mirror chosen parameters into Mcp-Param-* headers. Gateways can now route and apply policy without parsing bodies, which creates an obvious risk: a harmless header in front of a dangerous body. The spec closes it. Servers that process the body "MUST reject requests where the values specified in the headers do not match the corresponding values in the request body," with 400 Bad Request and JSON-RPC error -32020 (HeaderMismatch).
For gateway authors, intermediaries that enforce policy on mirrored headers should reject requests whose protocol version predates header and body validation. Otherwise a downgraded request skips the check.
Request authentication: every request stands alone
The authorization page says authorization "MUST be included in every HTTP request from client to server," and never in the query string. That was already true, but some 2025 era servers validated the token at initialize and then trusted the session ID. That pattern is now impossible. Validate the bearer token on every request and authorize the specific tool and arguments. The DNS rebinding rules are unchanged: validate Origin and return 403 on an invalid value, and bind local servers to 127.0.0.1.
What is issuer (iss) validation and which mix-up attack does it stop?
The attack in plain language
An MCP client talks to many servers, and each can point it at a different authorization server. RFC 9207 (March 2022) describes the risk: an attacker who controls one authorization server tries to make the client send it a code or token that a different, honest server issued. A plain authorization response does not say which server produced it. The MCP security considerations page now covers this case, an attacker that "controls one of the authorization servers an MCP client interacts with." When anyone can publish an MCP server and choose its authorization server through protected resource metadata, that is a realistic setup.
RFC 9207's fix is one parameter. Its abstract begins: "This document specifies a new parameter called iss." The authorization server names itself in the response, and the client compares that to the issuer it expected.
What the 2026-07-28 spec now requires
The changelog: authorization servers "SHOULD include the iss parameter in authorization responses," and MCP clients "MUST validate a present iss against the recorded issuer before redeeming the authorization code" (SEP-2468). The authorization page adds that the client must record the issuer from validated metadata before redirecting, alongside the PKCE verifier and state; that servers sending iss must advertise authorization_response_iss_parameter_supported: true; and that clients apply this table before sending the code anywhere:
authorization_response_iss_parameter_supported | iss in response | Client action |
|---|---|---|
true | present | Compare to the recorded issuer with simple string comparison |
true | absent | Reject the response |
false or absent | present | Compare to the recorded issuer with simple string comparison |
false or absent | absent | Proceed |
Three details are easy to get wrong. First, no normalization: clients must not apply case folding, default port removal, trailing slash or percent encoding changes before comparing. Second, error responses count: on mismatch the client must not act on or display error, error_description or error_uri. Third, emission is a SHOULD today, and the page expects a future revision to make it a MUST.
Why PKCE and resource indicators do not cover it
The best practices page answers this directly. PKCE alone does not stop mix-up, because the client sends the code_verifier to the attacker's token endpoint along with the code. Resource indicators do not help when the attacker's server intercepts requests before they reach the honest one. And the fix depends on honest servers emitting iss, which is your argument for asking every identity provider you use with MCP to turn it on.
Client side validation, in code
This TypeScript sketch follows the table. URLSearchParams does the form decoding, and the comparison is plain string equality.
type Pending = { state: string; codeVerifier: string; expectedIssuer: string };
type AsMeta = { issuer: string; authorization_response_iss_parameter_supported?: boolean };
export function handleCallback(url: URL, pending: Pending, asMeta: AsMeta): string {
const p = url.searchParams;
const iss = p.get("iss");
const advertised = asMeta.authorization_response_iss_parameter_supported === true;
// 1. Issuer check first, before reading code OR error fields.
if (iss !== null) {
if (iss !== pending.expectedIssuer) {
throw new Error("Authorization response issuer mismatch; response discarded");
}
} else if (advertised) {
throw new Error("Issuer advertised but missing; response discarded");
}
// 2. State check.
if (p.get("state") !== pending.state) throw new Error("state mismatch");
// 3. Only now look at error or code.
const err = p.get("error");
if (err) throw new Error(`Authorization failed: ${err}`);
const code = p.get("code");
if (!code) throw new Error("no authorization code");
return code; // redeem at the token endpoint of pending.expectedIssuer only
}
Set pending.expectedIssuer from validated metadata before the redirect, never from the callback URL.
Why are Client ID Metadata Documents now preferred over dynamic client registration?
The three ways a client gets a client ID
The client registration page sets a priority order: pre-registered client information if available; then Client ID Metadata Documents if the authorization server advertises client_id_metadata_document_supported; then Dynamic Client Registration if it has a registration_endpoint; and finally asking the user.
| Mechanism | How identity works | Status in 2026-07-28 | Main security strength | Main security gap |
|---|---|---|---|---|
| Pre-registration | Client ID issued out of band for one authorization server | Supported, top priority | Known relationship, admin controlled | Does not scale to an open ecosystem; credentials tied to one issuer |
| Client ID Metadata Documents (CIMD) | The client ID is an HTTPS URL that serves a JSON metadata document | SHOULD support; preferred default | Identity anchored to a domain the client controls; no registration database to flood | Cannot prove which local process owns a localhost redirect; authorization server fetches attacker supplied URLs |
| Dynamic Client Registration (DCR, RFC 7591) | Client POSTs metadata to /register and receives a client ID | Deprecated, MAY support for backward compatibility | Works with servers that lack CIMD | Anyone can register anything; name and logo are self asserted; registration tables grow without bound |
The release post says DCR is "now formally deprecated in favor of CIMD." It remains for authorization servers without CIMD support, with at least twelve months under the new lifecycle policy.
Why CIMD is the better default
With DCR, a client describes itself and the authorization server cannot check the description, so a registration claiming to be a well known client looks like the real one. Open registration endpoints also collect rows forever. With CIMD, the client_id is an HTTPS URL with a path. The authorization server fetches it and must check that the document's client_id matches the URL exactly, that requested redirect URIs appear in it, and that it is valid JSON with client_id, client_name and redirect_uris. Identity is now tied to control of a domain.
A minimal CIMD document for a desktop client:
{
"client_id": "https://app.example.com/oauth/client-metadata.json",
"client_name": "Example MCP Client",
"client_uri": "https://app.example.com",
"redirect_uris": ["http://127.0.0.1:3000/callback"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}
CIMD itself is still an IETF draft (draft-ietf-oauth-client-id-metadata-document-00), not a published RFC.
What CIMD does not solve
Localhost impersonation. The security considerations page states: "Client ID Metadata Documents cannot prevent localhost URL impersonation by themselves." An attacker can present a real client's metadata URL, listen on a local port and use that as the redirect, and the user sees the real client's name. Authorization servers should warn on localhost only redirects and must clearly display the redirect URI hostname.
SSRF against the authorization server. The authorization server now fetches a URL an unknown client chose. Block private and link local ranges including 169.254.0.0/16, require HTTPS, validate each redirect hop and use an egress proxy. Domain based trust policies are allowed, from strict allowlists to accepting any HTTPS client ID.
application_type for clients still on DCR
SEP-837 adds: "MCP clients MUST specify an appropriate application_type during Dynamic Client Registration." Omitting it defaults to "web" under OpenID Connect, which can make servers reject localhost redirects. Desktop, mobile, CLI and locally hosted clients should send "native". It is an interoperability fix with a security payoff: developers who cannot register a native redirect tend to work around it with looser redirect rules.
What does binding client credentials to the issuer prevent?
SEP-2352 adds what the changelog calls a clarification, and it is a sharp one. Clients "MUST key persisted credentials by the issuer identifier, MUST NOT reuse them with a different authorization server, and MUST re-register when the authorization server changes."
The scenario: a client registers with authorization server A and stores a client ID and secret. Later the MCP server's protected resource metadata points at server B, through a migration or tampering. A careless client reuses the stored credentials with B. If B is hostile, it now holds a client secret that works at A. The client registration page says clients should surface an error rather than silently use mismatched credentials. CIMD client IDs are the exception: they are self hosted URLs and portable across authorization servers.
In code, it is a storage layout change:
// Before: one credential per MCP server URL. Wrong after 2026-07-28.
// creds["https://mcp.example.com/mcp"] = { client_id, client_secret }
// After: credentials keyed by the issuer that minted them.
type Creds = { client_id: string; client_secret?: string };
const store = new Map<string, Creds>(); // key = AS metadata "issuer", exact string
function credsFor(issuer: string): Creds | undefined {
return store.get(issuer); // never fall back to "any stored creds"
}
Issuer binding pairs naturally with iss validation. One stops a code from being sent to the wrong token endpoint; the other stops your client secret from being sent there.
Is token passthrough still forbidden, and what about audience checks (RFC 8707)?
Yes, on both counts, unchanged in substance from the 2025 revisions. Servers "MUST validate that access tokens were issued specifically for them as the intended audience" and "MUST NOT accept or transit any other tokens." For upstream APIs, the server uses a separate token and "MUST NOT pass through the token it received from the MCP client."
Clients must still send the RFC 8707 resource parameter in both the authorization and token requests, naming the MCP server's canonical URI, "regardless of whether authorization servers support it." RFC 8707 (February 2020) says the authorization server should audience restrict the token to that resource, which shows up as the JWT aud claim. Prefer the canonical form without a trailing slash, and never include a fragment.
A server side check with PyJWT:
import jwt # PyJWT
CANONICAL = "https://mcp.example.com/mcp"
ISSUER = "https://auth.example.com"
def verify_bearer(token: str, signing_key) -> dict:
# Raises on bad signature, expiry, wrong issuer, or wrong audience.
return jwt.decode(
token,
signing_key,
algorithms=["RS256", "ES256"],
audience=CANONICAL,
issuer=ISSUER,
options={"require": ["exp", "iss", "aud", "sub"]},
)
Passthrough stays banned because it bypasses audience based controls, breaks audit trails and turns one compromised service into access to every service that accepts the same token. Our token passthrough explainer goes deeper.
Which changes land on clients, which on servers, which on authorization servers?
"Update the server SDK" does not cover everything. This table maps each item to its owner.
| Item | MCP server (resource server) | MCP client | Authorization server |
|---|---|---|---|
Remove sessions; ignore Mcp-Session-Id; 405 on GET or DELETE | Must change | Must stop sending session IDs | Not affected |
_meta version, capabilities, client info on every request | Must read and validate | Must send | Not affected |
server/discover | Must implement | May call | Not affected |
| State handles bound to user | Must implement if stateful | Passes handles as arguments | Not affected |
requestState integrity and replay checks | Must implement if used | Must echo unchanged | Not affected |
Mcp-Method, Mcp-Name, Mcp-Param-* header checks | Must reject mismatches | Must send and encode | Not affected; gateways must validate or reject old versions |
Origin validation, localhost binding | Must (unchanged) | Not affected | Not affected |
| Audience validation, no passthrough | Must (unchanged) | Must send resource | Should audience restrict tokens |
iss in authorization responses | Not affected | Must validate when present | Should emit; must advertise if emitting |
| Issuer bound credential storage | Not affected | Must key by issuer; re-register on change | Not affected |
| CIMD support | Not affected | Should host a metadata document | Should fetch and validate; must guard against SSRF |
application_type in DCR | Not affected | Must send if using DCR | May enforce redirect rules by type |
| Protected Resource Metadata (RFC 9728) | Must serve (unchanged) | Must use for discovery | Not affected |
| PKCE with S256 | Not affected | Must (unchanged); must refuse if metadata lacks code_challenge_methods_supported | Must support |
If you run servers on a hosted identity provider, your work is the first eight rows plus asking the provider about iss and CIMD.
How do you upgrade a running MCP server without breaking older clients?
The versioning page defines "modern" (2026-07-28 and later, per request metadata), "legacy" (2025-11-25 and earlier, initialize handshake) and "dual-era" (both). A legacy client against a modern only server fails, and "Legacy clients have no fall-forward mechanism." Against a dual era server it works. So go dual era first and drop legacy on a date you announce.
Step by step
Step 1. Find every MCP SDK and its version. The release post says the TypeScript, Python, Go and C# SDKs supported 2026-07-28 at release, with Rust in beta. It gives no version numbers, so check each SDK's release notes and migration notes.
# TypeScript / JavaScript
npm ls @modelcontextprotocol/sdk --all
# Python
pip show mcp | grep -i '^version'
pip index versions mcp # what is available upstream
# Go
go list -m all | grep modelcontextprotocol
# C# / .NET
dotnet list package --include-transitive | grep -i modelcontextprotocol
# Container images that bundle a server
docker run --rm --entrypoint sh your-mcp-image:tag -c 'npm ls @modelcontextprotocol/sdk 2>/dev/null || pip show mcp'
Step 2. Upgrade the SDK on a branch and run your existing tests before touching protocol logic.
npm install @modelcontextprotocol/sdk@latest && npm test
# or
pip install --upgrade mcp && pytest
Step 3. Remove anything keyed on the session ID and replace it with handles bound to the token subject.
grep -rniE 'mcp-session-id|sessionid|session_id|last-event-id' src/ deploy/
Step 4. Replace server initiated requests with MRTR, and protect any requestState as shown earlier.
Step 5. Enforce the header rules in the server and in any gateway that routes on them.
Step 6. Keep legacy clients working on purpose. A dual era server answers initialize with legacy semantics for that session and serves modern requests statelessly, on the same endpoint if you like. Keep the legacy path's session protections until you remove it. If you go modern only, name your supported versions in any error you return to initialize.
Step 7. Smoke test the new behavior against your own server with a valid test token. These checks confirm defensive behavior only.
MCP=https://mcp.example.com/mcp
TOKEN=$(cat ./test-token) # a token issued for $MCP
# 7a. Old style GET stream endpoint should be gone: expect 405
curl -s -o /dev/null -w '%{http_code}\n' -X GET "$MCP" -H "Authorization: Bearer $TOKEN"
# 7b. Header and body disagree: expect 400 and error code -32020
curl -s -X POST "$MCP" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' \
-H 'Mcp-Name: list_files' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"delete_files","arguments":{},
"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28",
"io.modelcontextprotocol/clientCapabilities":{}}}}' | jq '.error.code'
# 7c. Bad Origin: expect 403
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$MCP" -H 'Origin: https://evil.invalid' \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{}'
# 7d. No token: expect 401 with a resource_metadata pointer
curl -s -D - -o /dev/null -X POST "$MCP" -H 'Content-Type: application/json' -d '{}' | grep -i www-authenticate
# 7e. Session header from an old client is ignored, never echoed
curl -s -D - -o /dev/null -X POST "$MCP" -H "Authorization: Bearer $TOKEN" \
-H 'Mcp-Session-Id: test' -H 'Content-Type: application/json' -d '{}' | grep -i mcp-session-id || echo "not echoed: good"
Step 8. Check what your authorization server advertises.
# Where does the MCP server say its authorization server is?
curl -s https://mcp.example.com/.well-known/oauth-protected-resource | jq '{resource, authorization_servers, scopes_supported}'
# What does that authorization server support?
curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq '{
issuer,
authorization_response_iss_parameter_supported,
client_id_metadata_document_supported,
code_challenge_methods_supported,
registration_endpoint
}'
If iss support is missing, ask your provider for a date. If code_challenge_methods_supported is missing, compliant clients must refuse to proceed.
Step 9. Gate it in CI against staging, so a later change cannot drop the header check.
# .github/workflows/mcp-conformance.yml
name: mcp-conformance
on: [pull_request]
jobs:
smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Header mismatch must be rejected
env:
MCP: ${{ vars.STAGING_MCP_URL }}
TOKEN: ${{ secrets.STAGING_MCP_TEST_TOKEN }}
run: |
code=$(curl -s -X POST "$MCP" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' \
-H 'Mcp-Method: tools/call' -H 'Mcp-Name: a' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"b","arguments":{},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}' \
| jq '.error.code')
test "$code" = "-32020"
Step 10. Announce the legacy cutoff, using the spec's twelve month deprecation minimum as a guide.
What should a security review checklist for an MCP server look like now?
Items marked "new" come from this revision.
Transport and request handling
- Validates
Originon every request and returns 403 on an invalid value. - Local servers bind to
127.0.0.1, or use stdio, not0.0.0.0. - (new) Rejects header and body mismatches with
400and-32020, after decoding Base64 encoded header values. - (new) Does not mint, echo or rely on
Mcp-Session-Id; returns 405 for GET and DELETE if modern only. - (new) Implements
server/discoverand returnsUnsupportedProtocolVersionErrorwith its supported versions. - (new) Tool side effects are idempotent or protected by one time tokens, because streams are no longer resumable and retries are normal.
State
- (new) State handles are random, expiring and keyed to the token subject; possession of a handle never authorizes anything.
- (new)
requestStateis HMAC or AEAD protected, bound to principal, expiry and originating request, and rejected on any failure. - (new) One time operations enforce single use on the server.
Authorization
- Serves Protected Resource Metadata (RFC 9728) with an accurate
resourceand minimalscopes_supported. - Validates every bearer token: signature, expiry, issuer and audience equal to the canonical server URI.
- Never forwards the client's token upstream; uses its own credentials or a token exchange for downstream APIs.
- Returns 401 for missing or invalid tokens and 403 with
insufficient_scopeand the needed scopes for step up. - Never accepts tokens in the query string.
- (new) The authorization server it points to emits
issand advertisesauthorization_response_iss_parameter_supported, or there is a dated plan for it. - (new) The authorization server supports CIMD, guards CIMD fetches against SSRF, and displays redirect hostnames during consent.
If the server is also a client or proxy
- Per client consent before forwarding to third party authorization servers, with exact redirect URI matching and single use
state. - (new) Validates
issbefore redeeming codes and keys stored client credentials by issuer. - Rejects non HTTPS authorization URLs except loopback, never opens URLs through a shell, and blocks private address ranges when fetching OAuth metadata.
Software supply chain
- MCP SDK is on a version that supports 2026-07-28 and has no known vulnerabilities that your code reaches.
- Full dependency tree, including transitive packages and the container base image, is scanned on every build.
- An SBOM exists for the deployed artifact, so you can answer "are we running the affected version" in minutes.
- Deprecated features in use (Roots, Sampling, Logging, HTTP+SSE, DCR) are listed with a removal date.
For the wider agent picture beyond protocol details, see our guide on how to secure AI agents and the MCP protocol.
Does the MCP server's own dependency tree need the same scanning as any other service?
Yes. None of the protocol work helps if the server runs a vulnerable HTTP framework, JWT library or base image. An MCP server is a little of your code on top of a lot of other people's: the SDK, a web framework, a schema validator, auth libraries, their transitive dependencies and often a container userland, each with its own release schedule.
This revision adds a reason to watch the SDK closely. Much of the new security behavior, from header and body validation to iss checks, lives inside the SDKs. A bug in how an SDK compares headers or decodes iss would undo a control the spec requires. So apply ordinary software composition analysis: an SBOM for every server and image, matching against vulnerability databases on every build and on a schedule, reachability to rank findings in code paths you actually call, and upgrade pull requests with tests so an SDK security release reaches production in days.
Safeguard does this for MCP servers the same way it does for any service. safeguard scan --dir . scans a repository, --image <name> scans a container image, and --sbom <file> scans an existing SBOM, with results written as JSON under ./output. The platform matches components against vulnerability databases, runs reachability analysis, shows which projects contain a given component and version, and evaluates policies and gates. When a fixed version of an MCP SDK or one of its dependencies exists, autonomous remediation can open a tested upgrade pull request; it ships switched off, so you choose what runs on its own. Safeguard also offers an MCP Guardrail that generates guardrail proxy URLs for MCP servers to govern agent tool traffic; it is in early access.
Safeguard will not implement the protocol for you. Adding iss validation to a client or HMAC to requestState are code changes your team makes.
Common mistakes
Treating "stateless" as "no state to secure." Handles and requestState replace the session ID and travel through the model and client. Unbound and unprotected, they move the hijacking problem instead of solving it.
Signing requestState but not binding it. An HMAC proves the server created the state, not that this user on this call should reuse it. Bind principal, expiry and originating request.
Checking iss after reading the error, or after normalizing it. Compare the exact string first, then look at code or error.
Taking the expected issuer from the callback. It must come from metadata your client validated before the redirect.
Keeping one credential per MCP server URL. The key is now the issuer. Re-register when it changes.
Trusting mirrored headers from older protocol versions at the gateway. That lets a downgraded request skip header and body validation.
Assuming CIMD solves impersonation or forgetting its SSRF risk. It cannot prove who owns a localhost redirect, and it makes your authorization server fetch attacker chosen URLs.
Upgrading the server and calling it done, or letting the SDK drift. Many rules land on clients and authorization servers, and the new controls live in SDK code that needs patching like anything else.
Fix it without waiting for upstream
An MCP server is a dependency tree with a network port, and the SDKs that implement this revision's controls will keep shipping fixes. These options keep the gap between an advisory and a patched server short, whether or not the upstream release has landed yet.
Whatever the vulnerability, most of the risk sits in two gaps. The first is the time between a fix being published and that fix actually running in your production builds. The second, and worse, is the time before any fix exists at all, while a maintainer works out a patch and a release schedule. Waiting through both gaps is a habit, not a requirement. Safeguard gives teams three ways to close them.
1. Autonomous remediation: pull requests, not tickets
When a fixed version exists, Safeguard's Griffin models open a tested, policy gated pull request that moves the affected dependency to the safe version. You can run it on one repository or in bulk across every project that carries the vulnerable version. Remediation covers eight categories, including dependencies, licenses, security posture and findings. Reachability analysis ranks the vulnerable code paths your application actually calls first, so the first pull requests you merge are the ones that matter most. Autonomous remediation ships switched off, with every category set to Manual, so your team decides how much runs on its own and what still needs a human approval.
2. Gold Open Source: the safe version in seconds, free
gold.safeguard.sh is free and needs no login. Search any CVE or package to see affected and fixed versions, CVSS, EPSS, CISA KEV status and a recommended safe version, or use the "Am I affected?" checker. The same data is available through a free read API, README status badges and RSS threat feeds. The safeguard-sh/gold-scan-action GitHub Action can fail a build on a known exploited vulnerability, on any known vulnerability, or never, so a version you have just removed cannot quietly come back in next week's merge.
3. Gold Registry: start from components that are already fixed
registry.safeguard.sh provides hardened, drop in replacements for upstream packages and container images. Every Gold artifact is held continuously to zero exploitable critical or high CVEs, zero malware and a completed license check, with SLSA L3 or higher provenance, a Sigstore signature and a public evidence page you can hand to an auditor. An artifact loses its Gold badge if it regresses. Teams can point their own package directory at the registry so hardened equivalents resolve without changing how developers install anything.
When upstream has no fix yet, or will not ship one, Safeguard first works to get the fix accepted upstream. If the issue is severe or upstream will not move, Safeguard builds a fork, fixes the vulnerability there and publishes a hardened Gold build, so you are not stuck on a maintainer's release schedule. You can also request a hardened, compatibility tested build of a specific package. This fork and patch flow is in early access today, so ask which packages it covers before you plan around it.
| Your situation | What to use |
|---|---|
| A fixed version exists | Autonomous remediation opens the upgrade pull request; Gold Open Source confirms the safe version |
| You need to know today whether you are affected | Gold Open Source lookup and "Am I affected?" checker, free and without login |
| You want vulnerable versions to stop shipping | The Gold scan GitHub Action in CI, plus Safeguard policies and gates |
| Upstream has no fix yet, or will not ship one | A custom Gold request; fork and patch (early access) |
| You want to start clean on new projects | Pull hardened components and images from Gold Registry |
One honest limit: when the vulnerable code lives in a vendor appliance, a hosted service or an operating system runtime you do not build yourself, the fix still has to come from that vendor or distribution. The steps above apply to the open source dependencies and container images you build and ship, which is where most teams carry the largest and least tracked share of their exposure.
FAQ
What is the latest MCP specification version?
As of this review, the current revision is 2026-07-28, released on 28 July 2026. The previous revision was 2025-11-25. The changelog on modelcontextprotocol.io lists every change between the two.
Is MCP really stateless now?
At the protocol level, yes. The initialize handshake and the Mcp-Session-Id header are removed, and each request carries its protocol version and capabilities in _meta. Your application can still keep state, but it does so through explicit handles passed as tool arguments, which you must bind to the authenticated user.
What is RFC 9207 and why does MCP require it?
RFC 9207 defines an iss parameter that an authorization server adds to its authorization response, so the client can confirm which server sent it. MCP 2026-07-28 says authorization servers should send it and clients must validate it when present, which stops an attacker controlled authorization server from tricking a client into handing over codes meant for an honest one.
Is Dynamic Client Registration removed from MCP?
No, it is deprecated. It remains available for authorization servers that do not support Client ID Metadata Documents, and the new lifecycle policy gives deprecated features at least twelve months. New clients should prefer pre-registration or CIMD, and any client that still uses DCR must send an application_type.
What is a Client ID Metadata Document?
It is a JSON document hosted at an HTTPS URL, and that URL is the client ID. The authorization server fetches it, checks that its client_id matches the URL exactly, and checks requested redirect URIs against the list in the document. It lets clients and servers with no prior relationship start OAuth without a registration step.
Can an MCP server forward the user's token to another API?
No. Token passthrough is still forbidden. The server must only accept tokens issued for itself and must not pass the client's token to upstream APIs; for downstream calls it acts as its own OAuth client with a separate token.
Will old MCP clients break when I upgrade my server?
They will if your server is modern only. The versioning page says legacy clients have no fall forward mechanism. Run a dual era server that answers initialize for legacy clients and serves modern requests statelessly, then remove legacy support on an announced date.
What is requestState and is it safe to put data in it?
It is an opaque string a server returns in an InputRequiredResult and the client echoes back on retry. The spec says servers must treat it as attacker controlled input and must integrity protect it if it affects authorization or business logic. Signing alone does not hide contents, so encrypt it if it carries anything sensitive.
Do I need to scan an MCP server for vulnerable dependencies?
Yes. An MCP server is a normal service with an SDK, a web framework, auth libraries and often a container image. Many of the new security controls are implemented in the SDK, so tracking its version and its advisories matters as much as the protocol changes themselves.
See how Safeguard would help
These demos show real product flows. None is specific to the 2026-07-28 revision, but each maps to a step above.
The first demo connects Claude to the Safeguard MCP server with OAuth and lets you choose which tools Claude can use. It shows the client side of an MCP authorization flow and least privilege tool selection, the same concerns the scope and audience rules in this post address.
Click through it at your own pace, or open it full screen.
The second demo is a real CLI session. You install the CLI, ask it to fix a lodash CVE in a repository, and it reads the finding and advisory, asks before each command, edits package.json, runs npm install and npm test with your approval, prints a summary and pushes nothing. The same flow applies when an MCP SDK security release lands: point it at your server's repository and ask it to move the SDK to the fixed version and run your tests.
Click through it at your own pace, or open it full screen.
The third demo sets up the Safeguard runner on your own machine, approves it with a device code, and then scans from the terminal. That is how you would scan an MCP server that lives in a private repository on your own hardware.
Click through it at your own pace, or open it full screen.
You can download the desktop app and CLI at safeguard.sh/download and read the CLI documentation at docs.safeguard.sh/docs/cli. For how the CLI assistant fits into everyday coding, see the Safeguard CLI as a security aware coding assistant.
Quick check
An authorization server's metadata sets authorization_response_iss_parameter_supported to true, but the callback has no iss. What must an MCP client do?
Reject the response and not redeem the code. Under 2026-07-28, when the authorization server advertises iss support and the parameter is absent, the client must reject it. If iss is present in any case, the client compares it to the recorded issuer with exact string comparison before doing anything else.
Your stateless MCP server returns a workflow handle from one tool and accepts it in another. Is an unguessable random handle enough?
No. The 2026-07-28 security best practices say servers must not treat possession of a state handle as authentication. Bind the handle to the user ID taken from the verified token, for example by keying state as user ID plus handle, and reject a handle presented by any other principal.
Sources
- Model Context Protocol blog: The 2026-07-28 release
- MCP specification 2026-07-28: Key Changes (changelog)
- MCP specification 2026-07-28: Authorization
- MCP specification 2026-07-28: Client Registration
- MCP specification 2026-07-28: Authorization Security Considerations
- MCP specification 2026-07-28: Streamable HTTP transport
- MCP specification 2026-07-28: Versioning and Compatibility
- MCP specification 2026-07-28: Multi Round-Trip Requests
- MCP documentation: Security Best Practices (2026-07-28)
- RFC 9207: OAuth 2.0 Authorization Server Issuer Identification
- RFC 8707: Resource Indicators for OAuth 2.0
- Appwrite: MCP goes stateless in the 2026-07-28 specification
Last reviewed: 23 October 2026. The MCP specification and its SDKs continue to change; check the linked specification pages for the current text.