MCP Just Made Its Biggest Breaking Change: Migrating A Financial Market-Data Server To The Stateless 2026-07-28 Spec, Line By Line
The 2026-07-28 Model Context Protocol revision is the largest breaking change since MCP appeared, and its headline is one sentence: MCP is no longer a stateful session protocol. The initialize handshake and Mcp-Session-Id are gone, every request carries its own version and capabilities in _meta, list results must declare ttlMs and cacheScope, server-initiated requests are replaced by Multi Round-Trip Requests, Mcp-Method and Mcp-Name headers let gateways route without parsing bodies, and OAuth clients must validate the issuer before redeeming a code. For the sixty-plus institutions already on LSEG's MCP servers and every firm that built one, this is a migration with a twelve-month clock. Here is the market-data server from our earlier playbook, moved to the new spec, with the finance-specific decisions each change forces.
AlchmAI Engineering16 min read
28 Jul
The 2026-07-28 revision: stateless core, MRTR, header routing, cacheable lists, auth hardening, formal extensions - the largest breaking change in MCP's history
0 sessions
initialize/initialized and Mcp-Session-Id are removed; version and capabilities travel in _meta on every request
12 months
Minimum deprecation window for Roots, Sampling, Logging, HTTP+SSE and Dynamic Client Registration under the new lifecycle policy
60+
Financial institutions already connected to LSEG's MCP servers - the population this migration lands on first
When we wrote the market-data MCP server playbook earlier this month, we built it on the assumption every MCP server had shared since 2024: a client opens a session, the server remembers it, and state lives in an Mcp-Session-Id header. The 2026-07-28 specification, published on 28 July, removes that assumption entirely. The protocol core is now stateless: no initialize handshake, no session header, and every request self-describes with its protocol version, client identity and capabilities in _meta fields. The stated goal is that the same request can be answered by any server instance behind an ordinary round-robin load balancer, with no shared storage.
That single change drags several others with it. Server-initiated requests - sampling, elicitation, roots - required a held-open stream, which a stateless server cannot have, so they are replaced by Multi Round-Trip Requests. List results that used to vary per session now have to declare how long they may be cached and by whom. HTTP requests must carry Mcp-Method and Mcp-Name headers so gateways can route and meter without parsing JSON. OAuth clients must validate the issuer under RFC 9207 before redeeming a code, and Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents. Tasks, MCP Apps and Enterprise Managed Authorization become formal extensions. All Tier 1 SDKs - TypeScript, Python, Go, C# - shipped updates, with Rust in beta.
Change One: Sessions Become Handles
The old server derived the acting user, the purpose and the quota from the session. The new server receives them on every call. Two patterns work. For identity, the acting user is asserted by the authorization layer on each request - which is where it should have been anyway. For anything that genuinely spans calls, the server mints an opaque handle, returns it, and accepts it back as a tool argument. The handle is a key into server-side state that can live in any shared store; the client treats it as a string.
import { z } from "zod";
/**
* 2026-07-28: no protocol sessions. Anything that must persist across calls is
* an explicit, server-minted handle carried as an ordinary tool argument.
* Here the handle is a "research context": a pinned as-of date, a declared
* purpose and a quota bucket, created once and referenced thereafter.
*/
interface ResearchContext {
id: string; // opaque to the client
actingUserId: string; // bound at creation from the authenticated caller
asOf: string; // point-in-time basis for every query in this context
purpose: string; // declared, logged, auditable
tokensBudget: number;
expiresAt: number;
}
server.registerTool(
"open_research_context",
{
title: "Open research context",
description:
"Create a pinned research context (as-of date, purpose, quota). Returns a " +
"handle to pass to every subsequent market-data call.",
inputSchema: {
asOf: z.string().date(),
purpose: z.string().min(8).max(200),
},
},
async ({ asOf, purpose }, extra) => {
// Identity comes from the per-request authorization, not from a session.
const actingUserId = extra.authInfo?.subject;
if (!actingUserId) throw new Error("UNAUTHENTICATED");
const ctx: ResearchContext = {
id: mintOpaqueId(),
actingUserId,
asOf,
purpose,
tokensBudget: await quota.allowanceFor(actingUserId),
expiresAt: Date.now() + 4 * 3600 * 1000,
};
// Any shared store: the point is that NO server instance owns it.
await contextStore.put(ctx.id, ctx);
await audit.write({ kind: "mcp.context_opened", ...ctx });
return {
resultType: "complete", // now REQUIRED on every result
structuredContent: { contextId: ctx.id, asOf, expiresAt: ctx.expiresAt },
content: [{ type: "text", text: "Context " + ctx.id + " opened, as-of " + asOf }],
};
},
);
server.registerTool(
"get_quotes",
{
title: "Get quotes",
description: "Quotes for entitled instruments, as at the context's pinned date.",
inputSchema: {
contextId: z.string(),
instruments: z.array(z.string().regex(/^[A-Z0-9.]{1,12}$/)).min(1).max(50),
depth: z.enum(["l1", "ohlc"]).default("l1"),
},
},
async ({ contextId, instruments, depth }, extra) => {
const ctx = await contextStore.get(contextId);
// The handle must belong to the caller. A handle is a capability;
// binding it to identity is what stops it becoming a shared secret.
if (!ctx || ctx.actingUserId !== extra.authInfo?.subject || ctx.expiresAt < Date.now()) {
throw new Error("CONTEXT_INVALID");
}
const decisions = await entitlements.checkBatch({ userId: ctx.actingUserId,
resources: instruments.map((i) => ({ type: "quote", instrument: i, depth })) });
const permitted = decisions.filter((d) => d.allowed).map((d) => d.instrument);
await quota.consume(ctx.actingUserId, permitted.length);
const rows = permitted.length ? await marketData.quotes({ instruments: permitted, asOf: ctx.asOf, depth }) : [];
await audit.write({ kind: "mcp.tool_call", tool: "get_quotes", contextId, actingUserId: ctx.actingUserId,
requested: instruments, permitted, asOf: ctx.asOf });
return {
resultType: "complete",
structuredContent: { asOf: ctx.asOf, rows, denied: decisions.filter((d) => !d.allowed) },
content: [{ type: "text", text: JSON.stringify({ asOf: ctx.asOf, rows }) }],
};
},
);Change Two: Cacheable Lists Are Now A Contract
tools/list, prompts/list, resources/list, resources/read and resources/templates/list must now return a CacheableResult with two fields: ttlMs, a freshness hint in milliseconds, and cacheScope, either public or private, which controls whether intermediaries may cache. Servers should also return tools in deterministic order so client-side caching works. For a financial server this is a governance question wearing a performance costume.
- Tool lists are public and long-lived. The set of tools does not depend on who is asking - the entitlement decision happens inside the call - so cacheScope public with a ttl of hours is correct, and it removes a per-turn round trip for every agent.
- Resource reads that contain reference data - instrument masters, venue calendars, policy text - are public and cacheable for as long as the data is stable. Declare it honestly; a stale venue calendar on the first day of a new session schedule is a real incident.
- Resource reads that contain anything entitlement-gated must be cacheScope private, and their ttl should not exceed the entitlement's own validity. A private read cached at a gateway is an entitlement leak with a header.
- Point-in-time data is immutable for a historical as-of date and should be cached aggressively; the same read for a live as-of date should carry a short ttl. The handle-based context from Change One makes this trivial: the as-of date is on the context, so the ttl can be derived rather than guessed.
// ttlMs and cacheScope are REQUIRED on list/read results in 2026-07-28.
// Make the cache policy a function of what the data IS, not a constant.
function cachePolicy(kind: "tools" | "reference" | "entitled" | "pit", asOf?: string) {
switch (kind) {
case "tools": return { ttlMs: 6 * 3600_000, cacheScope: "public" as const };
case "reference": return { ttlMs: 3600_000, cacheScope: "public" as const };
case "entitled": return { ttlMs: 60_000, cacheScope: "private" as const };
case "pit":
// A historical as-of date can never change; a live one changes constantly.
return asOf && asOf < today()
? { ttlMs: 30 * 24 * 3600_000, cacheScope: "private" as const }
: { ttlMs: 5_000, cacheScope: "private" as const };
}
}
server.setToolsListResultDecorator(() => ({ ...cachePolicy("tools"), resultType: "complete" }));
server.registerResource("venue-calendar", "alchmai://ref/venues/calendar", {}, async () => ({
resultType: "complete",
...cachePolicy("reference"),
contents: [{ uri: "alchmai://ref/venues/calendar", mimeType: "application/json",
text: JSON.stringify(await refdata.venueCalendar()) }],
}));Change Three: Multi Round-Trip Requests Replace Server-Initiated Calls
A stateless server cannot hold a stream open to ask the client a question, so the old server-initiated requests - roots/list, sampling/createMessage, elicitation/create - are replaced by the MRTR pattern. A server that needs more information returns a result with resultType input_required and an inputRequests array; the client retries the original call with inputResponses. The server must therefore be able to resume the call from scratch, which is exactly the property statelessness demands.
For financial tools this is a gift, because the one thing a consequential tool most often needs mid-call is confirmation. Under the old model an order-preview tool that wanted the human to approve had to either hold a stream or park state in a session. Under MRTR it returns input_required with the preview, and the retry carries the approval - bound, as in our earlier playbook, to a content hash of the exact arguments.
import { createHash } from "node:crypto";
const hashArgs = (a: unknown) => createHash("sha256").update(JSON.stringify(a)).digest("hex").slice(0, 16);
server.registerTool(
"submit_order_preview",
{
title: "Preview and submit an order (approval required)",
inputSchema: {
contextId: z.string(),
order: OrderSchema,
// Absent on the first call; supplied by the client on the MRTR retry.
approval: z.object({ approvedHash: z.string(), approverId: z.string() }).optional(),
},
},
async ({ contextId, order, approval }, extra) => {
const ctx = await requireContext(contextId, extra);
const verdict = await pretradeGate.evaluate(order, ctx); // deterministic, unchanged
if (!verdict.allowed) {
return { resultType: "complete", structuredContent: { status: "rejected", reasons: verdict.reasons },
content: [{ type: "text", text: "Rejected: " + verdict.reasons.join(", ") }] };
}
const expected = hashArgs(order);
if (!approval) {
// First round trip: nothing has happened. Ask for approval and stop.
// The server keeps NO state; the hash in the request is the contract.
return {
resultType: "input_required",
inputRequests: [{
id: "approve-" + expected,
type: "elicitation",
message: "Approve order " + expected + "? " + renderPreview(order),
requestedSchema: { type: "object", properties: {
approvedHash: { type: "string", const: expected },
approverId: { type: "string" } }, required: ["approvedHash", "approverId"] },
}],
content: [{ type: "text", text: "Awaiting approval for " + expected }],
};
}
// Second round trip: the retry carries the approval. Re-derive the hash from
// the arguments actually supplied - never trust the hash to prove the args.
if (approval.approvedHash !== expected) throw new Error("APPROVAL_HASH_MISMATCH");
const receipt = await oms.submit(order, { idempotencyKey: ctx.id + ":" + expected,
approverId: approval.approverId });
await audit.write({ kind: "order.submitted", contextId, hash: expected, approver: approval.approverId, receipt });
return { resultType: "complete", structuredContent: { status: "submitted", receipt },
content: [{ type: "text", text: "Submitted " + receipt.id }] };
},
);Change Four: Headers For The Gateway, Errors For The Spec
- Every Streamable HTTP POST must carry Mcp-Method and Mcp-Name. A gateway can now rate-limit submit_order_preview differently from get_quotes, route market-data calls to a different pool, and meter per tool - without parsing a body. For a financial deployment this is where per-tool quotas and per-tool WAF rules move to.
- Tool parameters can be promoted to custom HTTP headers via x-mcp-header, which is the right home for a tenant or desk identifier that an edge proxy needs to see.
- Error codes moved: -32000 to -32019 are implementation-defined, -32020 to -32099 reserved for the spec. HeaderMismatch is now -32020, MissingRequiredClientCapability -32021, UnsupportedProtocolVersion -32022, and resource-not-found is -32602. Any alerting keyed on the old numbers is silently broken.
- Trace context - traceparent, tracestate, baggage - now has documented _meta conventions, which lines up directly with the OpenTelemetry GenAI work: one trace from agent turn to tool call to OMS.
- SSE resumability, Last-Event-ID and event ids are removed. A broken stream is re-issued as a new request with a new id, which is fine for reads and precisely why writes need the idempotency key above.
Change Five: Authorization Hardening
Authorization servers should now include iss per RFC 9207 and clients must validate it against the recorded issuer before redeeming an authorization code. Client credentials are bound to the issuing authorization server, keyed by issuer, and must not be reused across servers. Dynamic Client Registration is deprecated in favour of Client ID Metadata Documents, and clients must declare an application_type so localhost redirects behave. Enterprise Managed Authorization becomes a formal extension.
The finance-specific reading: the issuer check closes a mix-up attack that mattered most to exactly the kind of multi-provider deployment a bank runs, where several authorization servers are legitimately in play. And CIMD means a client's identity is a document you host and can rotate, rather than a registration you performed once and forgot - which is the model a controls function can actually review.
The Migration Order That Works
- 01Add server/discover and _meta version handling first, alongside the old handshake. The new discovery RPC lets a client probe what you support, so you can serve both protocols during the transition.
- 02Move every piece of session state to an identity-bound handle, and check the binding on every use. This is the step with security consequences; do it before anything else and test it adversarially.
- 03Decorate every list and read with a cache policy derived from what the data is. Get resource reads with entitled content onto cacheScope private before a gateway caches them for you.
- 04Convert every server-initiated interaction to MRTR, with an idempotency key on any retry that has a side effect.
- 05Add Mcp-Method and Mcp-Name to your gateway rules and move per-tool quotas there. Renumber your error alerts.
- 06Adopt RFC 9207 issuer validation and CIMD in clients; retire Dynamic Client Registration inside the twelve-month window.
- 07Deprecate Roots, Sampling and Logging use in your own clients - log to stderr or OpenTelemetry, call the model API directly - and drop HTTP+SSE.
The Bottom Line
The 2026-07-28 specification turns MCP from a stateful session protocol into a stateless request/response one, and every financial server that leaned on sessions - which is most of them - has a migration to do inside a twelve-month window. The work is specific: re-home session state as identity-bound, server-minted handles; declare cache policy honestly on every list and read; convert server-initiated interactions to Multi Round-Trip Requests with idempotency keys on the retries; move per-tool controls to the Mcp-Method and Mcp-Name headers; adopt issuer validation and Client ID Metadata Documents; and retire the deprecated features on the clock the new lifecycle policy sets. Done in that order, the result is a server that is easier to deploy, cheaper to run and simpler to govern than the one it replaces. That is the integration engineering we do for financial firms in London, and with the largest data vendors already serving institutions over MCP, it is the migration every finance engineering team will be doing this year.
References & Further Reading
- Model Context Protocol blog - The 2026-07-28 specification. blog.modelcontextprotocol.io/posts/2026-07-28
- Model Context Protocol - 2026-07-28 specification changelog (GitHub). github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/specification/2026-07-28/changelog.mdx
- Model Context Protocol blog - The 2026-07-28 release candidate. blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate
- Model Context Protocol blog - The new MCP roadmap. blog.modelcontextprotocol.io/posts/mcp-roadmap
- Model Context Protocol - specification. modelcontextprotocol.io/specification/2026-07-28
- Markets Media - LSEG is 'more valuable in an AI world' (MCP partnerships, 60+ institutions connected). marketsmedia.com/lseg-more-valuable-in-an-ai-world
- IETF - RFC 9207: OAuth 2.0 Authorization Server Issuer Identification. rfc-editor.org/rfc/rfc9207
- OpenTelemetry - Semantic conventions for generative AI. opentelemetry.io/docs/specs/semconv/gen-ai
AlchmAI Engineering
Engineering, London
Written by the AlchmAI engineering team in Mayfair, London. We build trading platforms, real-time charts, market data pipelines and AI features for brokers, prop firms and fintech teams. The Playbook is where we explain how we approach these systems, with code you can run and sources you can check.
Code in this guide is illustrative and supplied without warranty. Review and test it before production use. Nothing here is investment advice. Important information