Server::builder()->build() produces a server carrying a dispatcher for each
protocol era, and StreamableHttpTransport decides per request
which of them answers. You don't need to configure this. Handshake-era clients run
without sessions until you call setSession():
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Server\Server;
use Mcp\Server\Transport\StreamableHttpTransport;
$request = (new Psr17Factory())->createServerRequestFromGlobals();
$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. 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,
described in Examples.
There is no handshake to remember anything, so every request carries what the server needs to
answer it. Two members in params._meta are required, 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,
resources/read, tasks/get, tasks/update and tasks/cancel. 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.
The SDK only checks the Mcp-Name header of the tasks/* methods. It doesn't implement these methods.
What a handler can read off all this is Talking back to the client.
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. See Schema generation for where a hand-written
inputSchema fits.
The server refuses a tools/call with -32020 when the header and the argument don't match.
This includes an Mcp-Param-* header sent without the argument in the body, an argument sent
without its header, and a mirrored number outside the safe integer range of ±(2^53-1).
Every request is classified once, before anything else looks at it. 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 |
| a JSON array (a batch) | refused with -32600 |
A leg is the dispatcher of one 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.
server/discover is answered by DiscoverHandler, a request handler like the one answering
initialize. It reports the modern revisions the dispatcher serves, the builder's capabilities
and the instructions set with setInstructions(), which initialize reports too.
A server that needs a different answer per era, say instructions that leave out tools a
modern-era client cannot use, listens for the ResponseEvent of a DiscoverRequest and replaces
the member it wants to change. server/discover dispatches the same RequestEvent,
ResponseEvent and ErrorEvent as every other request. This example needs a PSR-14 dispatcher
(composer require symfony/event-dispatcher):
use Mcp\Server\Event\ResponseEvent;
use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\Request\DiscoverRequest;
use Mcp\Schema\Result\DiscoverResult;
use Mcp\Server\Server;
use Symfony\Component\EventDispatcher\EventDispatcher;
$dispatcher = new EventDispatcher();
$dispatcher->addListener(ResponseEvent::class, function (ResponseEvent $event) {
$result = $event->getResponse()->result;
if (!$event->getRequest() instanceof DiscoverRequest || !$result instanceof DiscoverResult) {
return;
}
$event->setResponse(new Response($event->getResponse()->getId(), new DiscoverResult(
$result->supportedVersions,
$result->capabilities,
'Call search_products to look up products.',
)));
});
$server = Server::builder()
->setInstructions('Enable the catalog toolset, then call search_products.')
->setEventDispatcher($dispatcher)
->build();The versions and capabilities stay the ones the server computed. To build the whole answer
yourself instead, add a handler supporting DiscoverRequest with addRequestHandler(): custom
handlers run before the defaults, so it answers instead of DiscoverHandler.
Either way the answer stays a plain JSON response, even to a client that accepts
text/event-stream, and the wire members resultType, the caching hints and serverInfo are
still added to it. The handshake era has no server/discover, so neither is reachable from it.
StdioTransport serves both eras too, but stdio carries one client per process, so the era is
settled once rather than per request: the client's first request decides it, by the same
body-primary rule as above.
| Opening request | The connection |
|---|---|
carries a modern revision in params._meta |
is served by the modern dispatcher from then on |
anything else — initialize above all |
runs the handshake, as before 2026-07-28 |
A request from the other era after that is refused rather than served: initialize on a modern
connection gets -32022 naming the modern revisions, an enveloped request on a handshake one
gets -32600. That is what a client that probed, gave up waiting and fell back to the handshake
needs to learn that the server settled on the modern era after all.
On a modern connection everything shares the one channel, and requests are served one at a
time: a request's progress and log messages are written as its handler emits them, ahead of its
result, and the next message is read once that result is out. A subscriptions/listen is the
long-lived exception: it stays open alongside other requests, each of its messages tagged with
the subscription id, until the client sends notifications/cancelled for it, since there is no
stream to close. setSubscriptionLifetime() does not apply here. stdio has no headers, so none
of the Mcp-* header rules apply.
A server built withoutModernEra() refuses a modern opening with -32022 naming the handshake
revisions, and still accepts the handshake that follows.
The default middleware stack runs for both eras.
ProtocolVersionMiddleware runs for handshake-era requests only, see
Protocol Version Validation.
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:
use Http\Discovery\Psr17Factory;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server\Server;
use Mcp\Server\Transport\StatelessHttpTransport;
$request = (new Psr17Factory())->createServerRequestFromGlobals();
$protocol = Server::builder()
->setServerInfo('My Server', '1.0.0')
->buildStateless([ProtocolVersion::V2026_07_28]);
(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request));This endpoint answers a bare initialize with -32022, naming the revisions it serves.
StatelessHttpTransport accepts these constructor arguments:
new StatelessHttpTransport(
$protocol, // the result of buildStateless()
$responseFactory, // PSR-17, auto-discovered if null
$streamFactory, // PSR-17, auto-discovered if null
$logger, // PSR-3, default: NullLogger
$maxBodyBytes, // default: 4 MiB, a larger body gets 413
$middleware, // null installs CorsMiddleware and DnsRebindingProtectionMiddleware
);An empty middleware list turns off all middleware, without a warning. The transport answers
OPTIONS with 204 and any other method except POST with 405.
An AuthorizationMiddleware in the middleware list has its own body limit, see
Request Body Size Limit.