The spec deleted sessions. Your tools never noticed.
MCP's
2026-07-28
revision removes protocol-level sessions, the GET stream, and
resumability. AgentBack
0.9.0 serves it by default, and 2025-era clients from the
same URL, without a single @tool changing.
A protocol revision that deletes a core mechanism tells you what your abstractions were worth. If your framework had threaded session identity through its middle, into handlers, per-session caches, a discovery step that ran once per connection, then "sessions are gone" means a rewrite.
The tool you wrote in 0.8 is byte-for-byte the tool that runs on the new revision:
@tool('forecast', {input: ForecastIn, output: ForecastOut})
async forecast(input: z.infer<typeof ForecastIn>) { … }
Nothing in that declaration mentions a session, because a tool is a projection of a schema rather than a participant in a transport. The transport changed underneath it and it kept working.
What the revision took away
The
Streamable HTTP binding
removes the
Mcp-Session-Id handshake, the standalone GET stream, and
resumable SSE via Last-Event-ID. Every message is its own
POST. Server-to-client interactions that used to be server-initiated
requests now come back embedded in results as input requests, under
the name Multi Round-Trip Requests, or MRTR.
With no session there is no affinity. An endpoint scales behind plain round-robin with no shared storage, because every request builds and tears down its own server. That helps anything running more than one instance.
Two eras, one endpoint, one line back
protocol: 'both', the default in 0.9.0 on both the stdio
and HTTP surfaces, serves the new revision and 2025-era traffic from
the same URL. A client that speaks the old handshake gets the old
handshake. A client that sends a per-request envelope gets the new
one. Neither is configured; both are detected.
We verified the dual-era path against three released 1.x SDKs, 1.11.0, 1.17.0 and 1.29.0, covering every 2025 revision, on top of the current client. The rollback is one line:
await installMcpHttp(app, {protocol: 'legacy'});
// or once, app-wide — the HTTP mount inherits it:
app.configure('servers.MCPServer').to({protocol: 'legacy'});
Setting it on the server config rolls back stdio and
/mcp together. An earlier cut rolled back only stdio and
left the endpoint on the new revision, which is a documented one-line
rollback doing half its job, and it fails worst when someone reaches
for it mid-incident.
One exception keeps the flip safe. Resumable SSE is a session feature
with no replacement in the new revision, so if you set an
eventStore and do not name a protocol, you stay on
sessions. A default should never silently delete a capability you
asked for by name.
Sessions were never in your code
The one place sessions were visible in userland was
perSession, the hook for per-user tool discovery. Under
the new revision it becomes per-request discovery. Same binder, same
signature, same security contract, running more often.
The DI container was already doing the load-bearing part. A
per-session server was a child Context resolved from the
application context; a per-request server is that same child context
with a shorter life. The seam never cared how long a request's worth
of state lasts.
That costs something. A binder doing an entitlement lookup now runs it on every request instead of once per connection, which arrives as a quiet load multiplier rather than an error. So the framework ships the answer instead of leaving each app to rediscover it:
perSession: cachedPerPrincipal(
principal => entitlements.toolsFor(principal?.extra?.sub), // cached
(ctx, classes) => classes.forEach(C => addTool(ctx, C)), // every request
{keyOf: p => `${p?.extra?.sub ?? 'anon'}`, ttlMs: 60_000},
)
Only the lookup is cached. apply runs fresh against that
request's own context, and a Context is never cached,
since it closes when its request ends.
keyOf is required, deliberately. The obvious choice,
AuthInfo.clientId, is wrong under OAuth, where it is the
client application id shared by every end user of that app.
Keying a cache on it hands one user's entitlement lookup to another.
Which claim identifies a subject varies by identity provider, so the
framework makes you name the security boundary rather than guessing
it.
The bug shape statelessness invites
Two bugs came out of this work and they had the same shape. When a request path moves from per-session to per-request construction, every value the session path computed once has to be re-derived per request. Here is how that mistake looks in a diff:
// session path
const scoped = authEnabled ? {scopes: authInfo?.scopes ?? []} : undefined;
// stateless factory, first cut: looks equivalent, is not
const scoped = ctx.authInfo ? {scopes: ctx.authInfo.scopes} : {};
The consumer treats undefined as "skip scope filtering
entirely." Under optional auth an anonymous caller had no
authInfo, fell through to {}, and every
@tool({scope}) became visible and callable.
@tool({scope}) is a visibility gate, so nothing
downstream re-checks it. The second bug was the same mistake in
different clothes: a confirmation store that lived per instance
vanished between the two requests of a confirm: round
trip, so every valid token was rejected.
Both are fixed and pinned by regression tests, and both are written into the framework's contributor docs as invariants, because the shape will come back. Anything that used to be computed once per session is now a candidate.
Confirmation gets a native face
confirm: tools have always used a token dance: the server
answers confirmation_required with a single-use token and
the caller repeats the call carrying it. MRTR lets a conformant host
render a real dialog instead, so confirm: now picks its
presentation per request.
A client on the new era that declared the elicitation
capability gets a native input_required result. Everyone
else gets the token dance: the 2025 era, a modern client that cannot
prompt, and every programmatic caller such as the CLI or an in-app
agent. Era alone is not the gate, and that detail cost us a bug. The
SDK raises a hard protocol error for an elicitation the client never
declared, so gating on era would have turned a confirmation into a
crash.
The confirmation store remains the sole authority on both paths.
MRTR's requestState only transports a token the
server already issued, because requestState is
client-echoed and the spec treats it as attacker-controlled. A round
trip having happened is not the same as a human having said yes.
A MUST we had never met
The revision also sent us back to a clause we had been failing since
long before it: servers MUST validate the
Origin header on incoming connections. That wording is
not new in 2026-07-28. It sits in
2025-06-18
and dates to 2025-03-26. Enabling the check only when
someone happened to configure an allowlist was never conformant on any
revision.
It is on by default now, and the allowlist comes from your
rest.cors config instead of being declared a second time,
since those origins already are your statement of which browsers may
call the app. Each entry is matched at the precision it was declared
at: a CORS origin string exactly, scheme and port included; a CORS
regex by testing it; an explicitly configured
allowedOrigins by hostname, because that is its shipped
behaviour and narrowing it would break callers who already set it.
Defaulting this on is safe because a missing
Origin passes. Only browsers send the header, so no MCP
client, no curl, no stdio bridge is affected. The only
request that can newly fail is a browser one, which is the case the
guard exists for. The full posture, bearer auth and framework
strategies and rate limiting alongside this guard, is in
securing MCP over HTTP.
What is left
Resumable SSE has no replacement in the new revision, so retiring the
session machinery for good is a breaking capability removal, and it
stays gated on that. Nothing is on the clock: the 2025 era has a
12-month deprecation window, and protocol: 'legacy' keeps
working the whole time.
Upgrading from 0.8 changes which era your endpoint serves by default
and nothing in your tool code. If a browser client starts getting
403s, set allowedOrigins. If you need sessions back, set
protocol: 'legacy'.