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'.

Build an MCP server Read the design notes