docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras.
18 KiB
The 2026-07-28 lifecycle
Protocol revision 2026-07-28 removed the initialize handshake and protocol-level sessions. Everything a
server needs to answer a request now travels in that request, which means any process can answer any
request and none of them need to share state.
This guide covers what changes for a server author. Tools, resources, prompts and their handlers are unaffected — the same registrations serve either lifecycle.
- The two eras
- Building a stateless server
- Per-request metadata
- Multi round-trip requests
- Progress and logging
- Caching
- Subscriptions
- Serving both eras
- What was removed
The two eras
Handshake era (2025-11-25 and earlier) |
Modern era (2026-07-28) |
|
|---|---|---|
| Opening | initialize / notifications/initialized |
none |
| Version | negotiated once, kept on the session | declared on every request |
| Capabilities | exchanged once | declared on every request |
| Discovery | initialize result |
server/discover |
| Sessions | Mcp-Session-Id |
removed |
| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) |
| Change notifications | HTTP GET stream, resources/subscribe |
subscriptions/listen |
| Dispatcher | Protocol |
StatelessProtocol |
| HTTP entry | StreamableHttpTransport — the same one, for both |
ProtocolVersion::isModern() tells the two apart, and Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION
is where the boundary sits.
Building a stateless server
There is nothing to build differently. Builder::build() produces a Server carrying a dispatcher for
each era, and StreamableHttpTransport decides per request which of them answers:
use Mcp\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
$server = Server::builder()
->setServerInfo('My Server', '1.0.0')
->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…')
->build();
(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request)));
That one endpoint answers initialize and server/discover alike. See
Serving both eras for how the decision is made and how to opt out of it.
Modern-era requests accept POST only; a GET or DELETE is a handshake-era session operation and is
routed as one.
A full example lives in examples/server/stateless-lifecycle/server.php.
Per-request metadata
Every request must carry two members in params._meta, and the HTTP layer mirrors some of them into
headers so an intermediary can route without parsing the body:
_meta key |
Required | Header |
|---|---|---|
io.modelcontextprotocol/protocolVersion |
yes | MCP-Protocol-Version |
io.modelcontextprotocol/clientCapabilities |
yes | — |
io.modelcontextprotocol/clientInfo |
no | — |
io.modelcontextprotocol/logLevel |
no | — |
progressToken |
no | — |
traceparent, tracestate, baggage |
no | — |
Plus Mcp-Method on every request, and Mcp-Name on tools/call, prompts/get and resources/read.
A header that disagrees with the body is refused with -32020; a missing required _meta member with
-32602; an unsupported version with -32022, carrying the supported set for the client to retry from.
Handlers read the metadata through RequestContext:
$context->getProtocolVersion(); // the revision serving this request
$context->getClientCapabilities(); // what this client declared, or null in the handshake era
$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim
ClientGateway's capability probes — supportsElicitation(), supportsSampling(), supportsRoots() and
the sub-capability variants — read the same declaration, so they work in both eras.
Mirroring a tool argument into a header
A tool parameter annotated with x-mcp-header is mirrored into Mcp-Param-{Name} by the client, and the
server checks that the two agree:
->addTool(
static fn (string $region, string $query): string => …,
name: 'execute_sql',
inputSchema: [
'type' => 'object',
'properties' => [
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
'query' => ['type' => 'string'],
],
'required' => ['region', 'query'],
],
)
The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a string, integer
or boolean property reachable through properties keys alone. Tool refuses a definition that breaks
any of those rather than letting it fail later as a header mismatch.
Multi round-trip requests
There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots returns the ask, and the client retries the original call carrying the answers.
This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the same ask over their connection instead. See What a handler forks on.
use Mcp\Schema\Result\CallToolResult;
use Mcp\Schema\Result\InputRequiredResult;
static function (RequestContext $context): CallToolResult|InputRequiredResult {
$answer = $context->getInputContext()?->elicitResult('who');
if (null === $answer) {
return new InputRequiredResult(
['who' => new ElicitRequest('Your name?', $schema)],
requestState: $context->mintRequestState(['asked' => 'who']),
);
}
return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]);
}
tools/call, prompts/get and resources/read may answer this way; nothing else may.
Reading the answers. InputContext hands them back typed — elicitResult(), samplingResult(),
rootsResult() — and returns null for an answer that is absent or malformed. Both mean the same thing
to a handler: ask again. response() is still there for the raw array.
requestState. Whatever the server needs to remember between rounds. It travels through the client, so
it is attacker-controlled on return; mintRequestState() seals it with an HMAC and a TTL, and a state that
fails verification never reaches a handler. Configure the key with Builder::setRequestState():
->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600)
The same key must reach every process that might serve the retry. A per-process random value works only for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted.
Capabilities. A server must not ask for input the client cannot provide. The SDK checks each ask against
the request's declared capabilities and answers -32021 — with the missing set in
data.requiredCapabilities — rather than sending an ask that could never be answered. Url-mode elicitation
needs its own elicitation.url declaration; a bare elicitation means form mode only.
What not to call. ClientGateway::sample(), elicit(), elicitUrl() and listRoots() belong to the
handshake era. Calling one under this revision raises a LogicException naming InputRequiredResult as the
replacement.
Progress and logging
Both travel on the request's own response stream, and both are opt-in by the client:
- Progress — the client sends
_meta.progressToken; without one,$gateway->progress()sends nothing. - Logging — the client sends
_meta["io.modelcontextprotocol/logLevel"]; without it the server must not emitnotifications/messageat all, and does not.
static function (RequestContext $context): string {
$client = $context->getClientGateway();
$client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3');
$client->progress(1, 3, 'Shard 1 of 3');
return 'done';
}
The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it does — so an error that has to carry a specific status still gets one, and a handler that talks gets a stream. Trace context from the request is echoed onto every notification it causes.
Caching
server/discover, the four list methods and resources/read must carry ttlMs and cacheScope. The
default is ttlMs: 0, cacheScope: "private" — conformant, and a flat refusal to let anything be cached.
Say what you actually mean:
use Mcp\Schema\Enum\CacheScope;
use Mcp\Server\Wire\CachePolicy;
->setCachePolicy(
CachePolicy::default(30_000)
->withMethod('tools/list', 3_600_000, CacheScope::Public)
->withMethod('server/discover', 3_600_000, CacheScope::Public),
)
public lets a shared proxy serve one caller's answer to another, so use it only for results that do not
vary by caller. A ReadResourceResult may set its own ttlMs/cacheScope, which win over the policy.
Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key.
Subscriptions
subscriptions/listen replaces the HTTP GET stream and resources/subscribe. The client opens a
long-lived POST whose response stream carries the notification types it asked for; the server acknowledges
first with notifications/subscriptions/acknowledged, reporting the subset it agreed to honour.
Delivery needs a bus, because the process that publishes and the process holding the stream open are often not the same one:
use Mcp\Server\Subscription\InMemoryNotificationBus;
use Mcp\Server\Subscription\Psr16NotificationBus;
// stdio, or a persistent runtime where the whole server is one process
->setNotificationBus(new InMemoryNotificationBus())
// PHP-FPM: the publisher and the stream are different workers
->setNotificationBus(new Psr16NotificationBus($cache))
Registry changes (registerTool(), unregisterPrompt(), …) are published automatically. Anything else —
notifications/resources/updated above all — is published by the application:
$bus->publish(new ResourceUpdatedNotification('file:///project/config.json'));
Builder::setSubscriptionLifetime() bounds how long a stream is held before the server closes it
gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive max_execution_time.
Pass 0 for "until the client or the runtime ends it".
Serving both eras
One endpoint serves both, and the client picks nothing. Every request is classified once, before anything else looks at it, and routed to the lifecycle it belongs to. The decision is body-primary:
| Evidence | Routed to |
|---|---|
params._meta names a modern revision |
modern era |
params._meta names a handshake revision |
handshake era |
| no such member | handshake era — initialize included |
| a notification with no member, under a modern header | modern era |
GET / DELETE |
handshake era |
The MCP-Protocol-Version header never decides. It is cross-checked against the body, and a request whose
header contradicts its _meta is refused with -32020 before either leg sees it — the check has to happen
at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its
own. A modern header on a request carrying no envelope is refused with -32602 naming the member it wants.
An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming the handshake ones.
Both legs come from one builder configuration — one registry, one set of handler instances, one session manager. A tool registered once is reachable from both, and a change made through one is visible to the other.
To serve the handshake era alone, say so:
$server = Server::builder()
->setServerInfo('My Server', '1.0.0')
->withoutModernEra()
->build();
That server refuses a modern claim with -32022, naming the handshake revisions it does serve.
setModernVersions() narrows the modern leg instead of removing it.
For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its
own and mount it on StatelessHttpTransport:
$protocol = Server::builder()
->setServerInfo('My Server', '1.0.0')
->buildStateless([ProtocolVersion::V2026_07_28]);
(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request));
What a handler forks on
Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called, and neither does the one thing that looks like it should: asking the user something.
Write it the 2026-07-28 way — return an InputRequiredResult naming what you need, read the answer off
RequestContext::getInputContext() when the call comes back. On a handshake-era connection the SDK's
input-required shim fulfils the same ask over that connection's own channel: each embedded request goes
out as the real elicitation/create / sampling/createMessage / roots/list, and the handler is
re-entered with the answers under the keys it asked for. It is on by default;
examples/server/elicitation and
examples/server/client-communication are written this way and
name no era anywhere.
Two things to know about it.
Re-entry is re-execution. The handler runs again from the top each round, so it has to re-derive where
it is from what came back rather than from anything it kept. That is already true of the modern era — the
client retries the whole call there — so a portable handler is written that way regardless. It is only new
if you were relying on ClientGateway::elicit() suspending mid-body and keeping your locals; that keeps
working untouched, since nothing here runs unless a handler returns an ask.
Each round holds the request open. The shim waits for the client's answer inside the originating
request, which on a process-per-request runtime means it holds a worker for as long as the user takes.
That is the same cost ClientGateway::elicit() already pays on that leg, but the shim makes it reachable
from handlers that never mention it — so size setInputRequiredLimits() against your pool.
$server = Server::builder()
->setServerInfo('My Server', '1.0.0')
// Re-entries per request, and seconds to wait for one answer.
->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120)
->build();
withoutInputRequiredShim() turns it off, so such a handler fails on a handshake-era connection instead of
being fulfilled behind your back.
Writing a client for this revision
One line selects the lifecycle; nothing else about the API changes.
$client = Client::builder()
->setClientInfo('my-client', '1.0.0')
->setProtocolVersion(ProtocolVersion::V2026_07_28)
->setCapabilities(new ClientCapabilities(elicitation: true))
->addRequestHandler($myElicitationHandler)
->build();
$client->connect(new HttpTransport('https://example.com/mcp'));
$client->callTool('greet', []);
What that changes underneath:
- No handshake.
connect()sends noinitialize. It asksserver/discoveronly for the server's identity, and a server that does not answer it still yields a usable connection — the method is optional. If discovery does reportsupportedVersionsand the configured revision is not among them, the client moves to a modern revision the server lists, or refuses the connection outright rather than talking past it. - An envelope on every request, carrying the revision, the declared capabilities and the client identity. The capabilities are what let a server decide, per request, whether it may ask for input.
- Headers on every POST —
MCP-Protocol-Version,Mcp-Method, andMcp-Namewhere the method addresses a subject. Arguments annotated withx-mcp-headerare mirrored intoMcp-Param-*, which requires the client to have listed the tool first;tools/listis what populates that knowledge. A tool whose annotations are malformed is dropped from the listing and refused if called, since the client cannot produce the headers it demands. - Multi round-trip calls are answered by the client. A result of
resultType: "input_required"is resolved through the same request handlers that served server-initiated requests in the handshake era, and the call is re-sent withinputResponsesand the server'srequestStateechoed back byte for byte, under a new JSON-RPC id. The caller sees one call and one result.
Headers are an HTTP concern, so a transport opts into them by implementing HeaderAwareTransportInterface;
HttpTransport does, StdioTransport has nothing to carry them on. Everything else — the envelope, the
skipped handshake, the round-trip loop — applies to both.
See examples/client/stateless_lifecycle_client.php for a runnable version.
What was removed
Answered with 404 and -32601 by a modern server:
initialize,notifications/initializedpinglogging/setLevel— replaced by_meta["io.modelcontextprotocol/logLevel"]resources/subscribe,resources/unsubscribe— replaced by theresourceSubscriptionsfilter ofsubscriptions/listennotifications/roots/list_changed
Also gone: Mcp-Session-Id, the HTTP GET stream, and SSE resumability (Last-Event-ID). A broken
response stream loses the request; the client re-issues it with a new id.
Error code -32002 (resource not found) is retired in favour of -32602, and must not be emitted by a
server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era
client still gets -32002.
Roots, sampling and logging are all deprecated as of this revision. They remain functional for at least
twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots,
integrate with an LLM provider directly instead of sampling, and log to stderr or OpenTelemetry instead of
notifications/message.