MCP in 2026: stateless, standardised, and what got deprecated
The 2026-07-28 revision made MCP stateless and gave every deprecated feature a removal date. What changed, what is on the clock, and what your harness actually speaks today.
Why a spec revision is your problem
Most of the time it isn't. You install a server, your harness lists its tools, work happens — the protocol underneath is somebody else's concern.
It stops being somebody else's concern in three situations, and they're the reason this page exists:
If you wrote a server. The 2026-07-28 revision removed the handshake your server is built around, and newly deprecated four features with removal dates attached: Roots, Sampling, Logging and Dynamic Client Registration.
If you're choosing between servers or gateways. When people say something "supports MCP" they now mean one of at least three different things, and the difference shows up in what you can actually deploy.
If you hit an error mentioning a protocol version. Then you want a table, not an article.
The short version: MCP is now a stateless request/response protocol, governance moved to a foundation in December 2025, and there's finally a written policy saying how long a deprecated feature survives. Nothing broke on 28 July. Some of it now has an expiry date.
The stateless core
The headline change is that MCP went from a stateful two-way protocol to a request/response one, by deleting everything that made a connection mean something.
| Removed in 2026-07-28 | What takes over |
|---|---|
The initialize / notifications/initialized handshake | io.modelcontextprotocol/protocolVersion and io.modelcontextprotocol/clientCapabilities, in _meta, on every request |
Protocol-level sessions and the Mcp-Session-Id header | Handles minted by the server, passed as ordinary tool arguments |
The HTTP GET endpoint, resources/subscribe and resources/unsubscribe | subscriptions/listen — one long-lived POST-response stream you opt into per notification type |
ping | Nothing. The changelog names no replacement |
logging/setLevel | io.modelcontextprotocol/logLevel in a request's _meta |
notifications/roots/list_changed | Nothing, and Roots itself is deprecated anyway |
SSE resumability: the Last-Event-ID header and SSE event IDs | Re-send the request with a new request ID |
notifications/elicitation/complete and elicitationId | The client sends the original request again; a server that needs to correlate embeds its own identifier in requestState |
Tasks as an experimental core feature, including tasks/result and tasks/list | The io.modelcontextprotocol/tasks extension, with tasks/get polling and tasks/update |
One method arrives with all this, and servers MUST implement it: server/discover, which reports
the protocol versions and capabilities a server supports along with its identity. A client can call
it up front to pick a version, or use it to probe compatibility over stdio.
This is what a request looks like once the handshake is gone. The _meta key names are the
normative part:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "search_invoices",
"arguments": { "query": "overdue" },
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {},
"io.modelcontextprotocol/clientInfo": { "name": "your-client", "version": "1.0.0" }
}
}
}With nothing to pin a client to, an MCP server behaves like any other HTTP workload: round-robin
load balancing, no sticky sessions, no shared session store. The mandatory Mcp-Method and
Mcp-Name headers on Streamable HTTP POSTs address the same thing from the other side — a gateway
can route and authorise on headers without parsing the JSON-RPC body.
The pattern that replaced server-initiated calls
A stateless design leaves no channel for a request that starts at the server and targets the client,
which is what sat behind roots/list, sampling/createMessage and elicitation/create. All three
involved interrupting a client mid-flight.
Multi Round-Trip Requests is the answer, and it works like a retry rather than an interrupt. The
server returns a result with resultType: "input_required" and what it needs in inputRequests;
the client re-sends the original request with inputResponses filled in.
That's also why every result now carries a required resultType — "complete" for an ordinary one,
"input_required" for the interim kind. If a server on an older protocol omits the field, clients
MUST treat the result as "complete", and that's what keeps old servers working.
If you ever wrote a server that asks the client to sample from its model, this is your paragraph: the mechanism is deprecated, and the pattern replacing it has no sampling equivalent. The documented migration is to call an LLM provider's API directly.
What a 2026 server now has to do
Past the removals, the revision added obligations that are easy to miss because none of them announce themselves:
ttlMsandcacheScopeare mandatory in results fromtools/list,prompts/list,resources/list,resources/readandresources/templates/list, via a newCacheableResultinterface.ttlMsis a freshness hint in milliseconds;cacheScopeis"public"or"private"and says whether a shared intermediary may cache the response.tools/listshould return a deterministic order. The stated reason is client-side caching and prompt-cache hit rates — a cost line, not tidiness.The error code range got split.
-32000to-32019stays implementation-defined and existing SDK usage is grandfathered;-32020to-32099is reserved for the specification. Three codes moved with it:HeaderMismatchfrom-32001to-32020,MissingRequiredClientCapabilityfrom-32003to-32021,UnsupportedProtocolVersionfrom-32004to-32022. Separately, resource-not-found went from-32002to-32602, to line up with JSON-RPC's own Invalid Params.Schema constraints got more permissive.
inputSchemaandoutputSchemaaccept any JSON Schema 2020-12 keyword, andstructuredContentaccepts any JSON value.Authorization got tighter. Authorization servers should send
issper RFC 9207, and clients MUST validate a presentissagainst their record of the issuer before redeeming the code. Client credentials are bound to the authorization server that issued them: key them by issuer, don't reuse them elsewhere, re-register when the authorization server changes._metais now documented for trace context. OpenTelemetry propagation fortraceparent,tracestateandbaggage— the standard way to carry context through a gateway into a server.
What is deprecated, and when it can actually go
Bookmark this part, because "deprecated" used to be a label with no defined outcome and now it has one. The specification's own registry is the source of truth:
| Feature | Deprecated in | Migration | Earliest removal |
|---|---|---|---|
| Roots | 2026-07-28 | Provide files or directories through tool parameters, resource URIs or server configuration | First revision released on or after 2027-07-28 |
| Sampling | 2026-07-28 | Call LLM provider APIs directly | First revision released on or after 2027-07-28 |
| Logging | 2026-07-28 | Write to stderr under stdio; OpenTelemetry for observability | First revision released on or after 2027-07-28 |
| Dynamic Client Registration (RFC 7591) | 2026-07-28 | Client ID Metadata Documents | First revision released on or after 2027-07-28 |
includeContext values "thisServer" and "allServers" | 2025-11-25 | Omit the field or set it to "none" | Tied to Sampling's timeline |
| HTTP+SSE transport | 2025-03-26 | Streamable HTTP | Three months after SEP-2596 reaches Final |
Two things in that table are easy to read past.
Roots, Sampling and Logging are the interesting rows. They're not obscure corners — they're what people mean when they talk about the client-side capabilities that make MCP more than a tool schema. All three keep working throughout the deprecation window. If you're writing new code, just don't reach for them.
HTTP+SSE is on the shortest window. Deprecated since the 2025-03-26 revision, reclassified
under the formal policy in 2026-07-28, and eligible for removal three months after SEP-2596 reaches
Final rather than the twelve months everything else gets. So if you're configuring a remote server
today and the transport list offers SSE, that's the legacy path, not the modern one.
Nothing has actually been removed yet. The registry's "Removed" section is empty.
The rule that makes those dates mean something
The 2026-07-28 revision adopted a feature lifecycle policy, which is where every earliest-removal date above comes from. A feature sits in exactly one of three states:
Active — part of the current revision. Implement it.
Deprecated — still in the specification, removal scheduled, migration documented. New adopters should avoid it; existing ones should migrate before the earliest removal date.
Removed — dropped from the draft and absent from the next current revision, but still documented in the last final revision that contained it.
A deprecated feature can't be removed sooner than twelve months after the release of the revision that marked it Deprecated — counted from that release, not from the date the proposal reached Final. Eligibility isn't removal, either: the actual deletion is a maintainer decision taken during release preparation, and a feature can sit Deprecated far longer than the minimum.
There's one escape hatch. The twelve-month floor can be shortened for a feature with an active security risk — a published advisory, or documented exploitation with no in-place mitigation — and even then ninety days is as low as it goes.
The policy binds the SDKs too. Tier 1 SDKs must mark a deprecated surface with their language's native mechanism in their next release, and should warn at runtime when it's exercised; an SDK that consistently doesn't is subject to a tier relegation process. Which means your first warning about a deprecation should arrive in your build output rather than from a page like this one.
Who owns MCP now
On 9 December 2025 MCP became a founding project of the Agentic AI Foundation, a directed fund under the Linux Foundation, co-founded by Anthropic, Block and OpenAI with support from Google, Microsoft, AWS, Cloudflare and Bloomberg. The other two inaugural projects were Block's goose and OpenAI's AGENTS.md.
This is the "standardised" half of the picture, and it's the part that should change how you plan. A protocol owned by one vendor can turn on that vendor's product decision. A protocol with a written deprecation policy, a public SEP process and a registry of what's on its way out is one you can build a two-year roadmap against — which is exactly what the twelve-month window is for.
The announcement cited over 97 million monthly SDK downloads and 10,000 active servers. Those are vendor-supplied figures, so treat them as such; the direction is real.
Does any of this break what you run today?
No, and the compatibility story is better than the size of the changelog suggests.
Servers answer both. A v2 server handles
server/discoverand the oldinitializehandshake, so an older client keeps working unchanged.Clients fall back. A new client that reaches a server on
2025-11-25or earlier drops back to theinitializehandshake.Stateless is opt-in. The TypeScript and Go SDKs need explicit configuration for it; Python v2 servers answer both protocol revisions from a single endpoint.
Beta SDK support landed as Python mcp v2.0.0b1, TypeScript v2, Go v1.7.0-pre.1 and C#
v2.0.0-preview.1, with Rust also named in the release announcement.
So if you're asking when you have to rewrite, that's not really the shape of it. The question is narrower: do you use Roots, Sampling or Logging, and is your remote transport SSE? Those answers tell you whether you have work in the next twelve months.
Where the harnesses actually are
This is the part that ages fastest, so we checked it on the wire instead of in a docs page. We connected three CLIs to a stdio server that writes down everything it receives, on 3 August 2026:
Claude Code 2.1.193 initialize protocolVersion 2025-11-25
Cursor CLI 2026.07.23-e383d2b initialize protocolVersion 2025-11-25
Codex CLI 0.146.0 initialize protocolVersion 2025-06-18Every one of them opened with the handshake the 2026-07-28 revision deletes, and none asked for
2026-07-28. That's not a fault — the SDKs implementing the revision are still in beta, and the
compatibility rules above exist precisely so clients can migrate gradually. It does mean something
practical though: a server you write today still has to understand initialize, because that's what
every client we could observe sent it.
In Claude Code
Server registration is done with claude mcp add, and the transport flag is where this lesson lands.
Two add commands — one pointing at a remote Streamable HTTP server, one at a local subprocess with
an env var passed in:
# Streamable HTTP — the current remote transport
claude mcp add --transport http sentry https://mcp.sentry.dev/mcp
# stdio — the default, so the flag is optional
claude mcp add my-server -e API_KEY=xxx -- npx my-mcp-serverThe -t / --transport flag accepts three values — stdio, sse and http — and defaults to
stdio when you don't pass it. Keep in mind that sse is the deprecated HTTP+SSE transport, the
one on the shortest window in the registry above. If a vendor hands you an /sse endpoint, check
whether they publish Streamable HTTP as well — most do by now, and that's the one with a future.
The -s / --scope flag can be set to local, user or project. The project scope is backed by
the .mcp.json file, which is the one you put under version control, and which Claude Code holds
behind an approval before it will connect.
The gotcha: claude mcp list and claude mcp get create actual connections, which is why their
output distinguishes a server that passed its health check from an unapproved .mcp.json entry —
the latter shows as pending approval, with no connection established. That makes list the
cheapest way to find out whether a server actually works. Just FYI, when we pointed Claude Code
2.1.193 at a logging server, claude mcp list did the entire handshake for us — no approval prompt,
no model call, zero tokens — and what it sent was initialize with protocolVersion set to
2025-11-25.
In Codex CLI
Configuration is kept in ~/.codex/config.toml, in [mcp_servers.<name>] tables, and codex mcp add
writes them for you. Anything after -- is treated as the command to run, env variables go in via
--env, and for a remote server the URL is meant to point at a Streamable HTTP endpoint with the
token read from an environment variable you name:
# stdio — everything after `--` is the command
codex mcp add my-server --env API_KEY=xxx -- npx my-mcp-server
# remote — `--url` is documented as "URL for a streamable HTTP MCP server"
codex mcp add sentry --url https://mcp.sentry.dev/mcp --bearer-token-env-var SENTRY_TOKENWhich produces TOML simple enough to edit by hand:
[mcp_servers.my-server]
command = "npx"
args = ["my-mcp-server"]
[mcp_servers.my-server.env]
API_KEY = "xxx"The point is that Codex has no SSE escape hatch, and that's on purpose — --url means Streamable
HTTP, and there's no flag anywhere that points at the deprecated HTTP+SSE transport some vendors
still run. So if a vendor only publishes an /sse endpoint, Codex is where you find that out.
The gotcha: codex mcp list can report a server as enabled when it isn't working at all,
because it only reads configuration and never opens a connection. To actually exercise one you need
a run — and -c gets you there without touching your config file:
codex exec --skip-git-repo-check \
-c 'mcp_servers.probe={command="node",args=["/tmp/mcp-probe/mcp-probe.js"]}' \
'Reply with the single word: ok'--skip-git-repo-check is in there because a scratch directory isn't a repository, and codex exec
refuses to run outside one by default. That's how we captured Codex CLI 0.146.0's opening message:
initialize with protocolVersion set to 2025-06-18 — older than the revision before the current
one, and the oldest of every client we probed.
In GitHub Copilot CLI
There are three sources of configuration from Copilot's point of view, and copilot mcp --help
names all three:
User ~/.copilot/mcp-config.json
Workspace .mcp.json or .github/mcp.json
Plugin Installed plugins with MCP serversSo if you want configuration versioned alongside your codebase, create a .github/mcp.json. If you
want it local to your machine and not tied to a particular project, set it up in
~/.copilot/mcp-config.json — though if you've set COPILOT_HOME, that file lives at
$COPILOT_HOME/mcp-config.json instead. The .mcp.json files are searched for starting from the
current directory and going up to the root of the repository.
Structurally, a config file looks like this:
{
"mcpServers": {
"playwright": {
"type": "local",
"command": "npx",
"args": ["@playwright/mcp@latest"],
"env": {},
"tools": ["*"]
}
}
}Two properties are worth knowing about:
typetakeslocalorstdiofor a process running on your machine,httpfor a remote Streamable HTTP endpoint, andssefor Server-Sent Events — which GitHub's own documentation marks as deprecated but supported. That lines up with the specification: HTTP+SSE is on the shortest window in the registry above, so treat it as the legacy option rather than a choice.toolscontrols what the server is allowed to expose:"*"for everything, a comma-separated list to narrow it, an empty string for none. A crowded tool list costs you, and this is the cheapest place to cut one.
The gotcha: copilot mcp list and copilot mcp get show you what's defined in configuration and
nothing more — they don't run anything, so you won't see traffic from them. Our test server logs
every byte it receives and it logged nothing at all while we ran both commands. The "Enabled" label
next to a server is about its configuration status, not its availability. To observe traffic you
need an active session, which is why there's no wire capture for this harness in the table above:
getting one non-interactively needs a flag that opts out of the permission prompt, and that isn't a
habit worth building.
In Cursor
The Cursor CLI has two configuration sources:
a project-level file at
.cursor/mcp.jsona user-level file at
~/.cursor/mcp.json, holding servers you want in every project
The configuration looks like this:
{
"mcpServers": {
"my-server": {
"command": "npx",
"args": ["my-mcp-server"],
"env": { "API_KEY": "xxx" }
}
}
}It's a different kind of harness from the others here, in that it puts an explicit approval in front of MCP — so its subcommands are built around that approval rather than around the underlying job:
agent mcp enable my-server # adds it to the local approved list
agent mcp list # configured servers and their status
agent mcp list-tools my-server # tool names and argument names
agent mcp login my-server # authenticate against a configured server
agent mcp disable my-server # never loaded, never prompted forlist-tools is the interesting one, because it actually connects. It's the fastest way to check
what a server really provides, as opposed to what its README says it provides.
The gotcha: enable adds the server to a local approved list, and --approve-mcps approves
everything for a run. Fine in a throwaway directory; put it in a shell alias you carry into everyday
work and you've nullified the point of the gate.
We pointed Cursor CLI 2026.07.23-e383d2b at a server that logs what it receives, and saw two
things:
the first command called the server's
initializemethod withprotocolVersionset to2025-11-25every command after that did the whole handshake again, because each run of the CLI starts a fresh server process. Worth knowing if your server is slow to start.
In Antigravity CLI
There's no agy mcp subcommand — we checked, and the subcommand list has no MCP entry. To set MCP up
you create an mcp_config.json file, in one of three places:
~/.gemini/config/mcp_config.json— applies to every session.agents/mcp_config.json— applies only to the project you're currently working onplugins/<plugin_name>/mcp_config.json— applies only while that plugin is enabled
The global and plugin locations come from the documentation shipped inside the CLI itself; the
workspace one is on Google's website but isn't named in the copy that ships with version 1.1.9. The
practical consequence: if a server in .agents/ doesn't show up during a headless run, it may
simply be getting ignored — ours was, so that's the first thing to check.
Here's what the file looks like:
{
"mcpServers": {
"sqlite-helper": {
"command": "sqlite-mcp-server",
"args": ["/path/to/database.db"],
"env": { "DB_READONLY": "true" }
},
"remote-service": {
"serverUrl": "https://mcp.mycompany.com/sse"
}
}
}That covers both shapes: a local stdio server via command, args and env, and a remote one via
serverUrl.
This is the harness where the deprecation table needs the most care. The documentation shipped
inside version 1.1.9 says it supports exactly two transports — Stdio for local commands, and SSE for
remote services through serverUrl, with an example URL ending in /sse. SSE is the transport the
specification has now formally reclassified as Deprecated, on the shortest window in the registry
above. Google's public documentation is broader: it says servers can be stdio, SSE or HTTP, and
that serverUrl covers "remote SSE, Streamable HTTP, or websocket-based MCP connections". The two
disagree, so confirm what your build actually opens before you rely on a remote server here.
The gotcha: that third location means an MCP server can arrive as part of a plugin rather than
through a file you wrote — so a tool can show up in a session you never configured. agy plugin list
is where you look when that happens.
We have no wire capture for this harness, for two reasons: the workspace-local config we put in a scratch directory wasn't picked up during a headless run, and the other two locations are global or plugin-scoped, neither of which we were willing to write into on someone else's machine.
In Kimi Code CLI
The kimi mcp command group covers the whole lifecycle of a server, and the list of transports you
can pick from is a short one:
kimi mcp add my-server -- npx my-mcp-server # stdio, the default
kimi mcp add sentry --transport http https://mcp.sentry.dev/mcp
kimi mcp auth sentry # authorise an OAuth server
kimi mcp test sentry # connect and enumerate tools
kimi mcp list
kimi mcp remove sentry--transport supports stdio or http, and nothing else. There's no sse value, which puts Kimi
Code on the right side of the shortest deprecation window in the registry above without you having
to do anything about it. OAuth credentials are cached under ~/.kimi/mcp-oauth/; if a server starts
refusing you for no visible reason, kimi mcp reset-auth clears them and you authorise again.
The test command is worth calling out. Most of the other harnesses make you start a session before
you can find out whether a server works; here there's a command whose entire job is to connect and
list the available tools. Run it after every config change and you'll catch a broken server before
it costs you a turn.
The gotcha: there are two different programs called kimi — the old Python kimi-cli and the
rebuilt Kimi Code CLI. They're separate tools whose MCP subcommands happen to look alike, which is
exactly what makes it easy to believe you're driving one when you're driving the other. Search
results for one will mislead you about the other, so check which binary you have before you trust a
flag.
That's a thing we struggled with on our end: we couldn't get the Kimi Code binary to run on our
machine, so this variant is checked against Moonshot's documentation rather than a captured session.
Verify the transport flags yourself with kimi mcp add --help before relying on them.
Read it off the wire yourself
Everything in the section above came out of one small JavaScript file. The version numbers in this lesson will age faster than the shape of the answer will, so the durable move is to re-run the check rather than trust the table.
Save the artifact below, register it in your harness as a stdio server, and trigger anything that makes the harness connect — a health check, a tool listing, a one-line prompt. The log file gets the raw JSON-RPC in both directions.
mkdir -p /tmp/mcp-probe && cd /tmp/mcp-probe
# save mcp-probe.js here, then add it as a stdio server in your harness
tail -f /tmp/mcp-probe/probe.logThree things worth looking for.
The opening message. initialize means your client is on the old protocol. A first request
carrying io.modelcontextprotocol/protocolVersion in _meta, or a call to server/discover, means
it has moved.
The protocolVersion value. It tells you which revision's rules your client is playing by, which
is the fact you want when a server misbehaves.
Whether it reconnects. Some CLIs start a fresh server process per invocation, which shows up as a repeated handshake in the log and matters if your server is expensive to start.
Run it in a scratch directory rather than a repository you care about, and take the server out of your configuration afterwards.
The one-screen version
The current revision is 2026-07-28. The one before it was
2025-11-25.MCP is stateless: no handshake, no session header, no
ping. Version and capabilities travel in_metaon every request.Server-initiated requests are gone. The replacement is Multi Round-Trip Requests — the server returns
resultType: "input_required"and the client retries withinputResponses.Deprecated with a date: Roots, Sampling, Logging and Dynamic Client Registration, none of them removable before 2027-07-28. HTTP+SSE is on a shorter window than any of them.
The deprecation floor is twelve months, or ninety days where there's an active security risk.
Governance sits with the Agentic AI Foundation under the Linux Foundation, since 9 December 2025.
Nothing breaks today. New servers answer the old handshake, new clients fall back to it, and stateless mode is opt-in.
No harness we probed speaks 2026-07-28 yet. Check yours instead of assuming.
mcp-probe.js#!/usr/bin/env node
// A stdio MCP server that does nothing except record what your harness sends it.
//
// 1. Save this file somewhere disposable, e.g. /tmp/mcp-probe/mcp-probe.js
// 2. Add it to your harness as a stdio server: `node /tmp/mcp-probe/mcp-probe.js`
// 3. Make the harness connect (a health check, a tool listing, a one-line prompt)
// 4. Read /tmp/mcp-probe/probe.log
//
// It answers the minimum needed for a health check to pass, so nothing hangs. It advertises no
// tools, so the model cannot call anything. Remove it from your config when you are done.
const fs = require('fs');
const LOG = process.env.PROBE_LOG || '/tmp/mcp-probe/probe.log';
let buffer = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
buffer += chunk;
let newline;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
fs.appendFileSync(LOG, 'IN ' + line + '\n');
let message;
try {
message = JSON.parse(line);
} catch {
continue;
}
// No id means a notification: nothing to answer.
if (message.id === undefined || message.id === null) continue;
let result;
switch (message.method) {
// Pre-2026-07-28 clients open with this.
case 'initialize':
result = {
protocolVersion:
(message.params && message.params.protocolVersion) || '2025-06-18',
capabilities: { tools: {} },
serverInfo: { name: 'probe', version: '0.0.1' },
};
break;
// 2026-07-28 clients may open with this instead.
case 'server/discover':
result = {
protocolVersions: ['2026-07-28'],
capabilities: { tools: {} },
serverInfo: { name: 'probe', version: '0.0.1' },
};
break;
case 'tools/list':
result = { tools: [] };
break;
case 'resources/list':
result = { resources: [] };
break;
case 'prompts/list':
result = { prompts: [] };
break;
default:
result = {};
}
const response = JSON.stringify({ jsonrpc: '2.0', id: message.id, result });
fs.appendFileSync(LOG, 'OUT ' + response + '\n');
process.stdout.write(response + '\n');
}
});