Server::builder()->build() produces a server carrying a dispatcher for each
protocol era, and StreamableHttpTransport decides per request
which of them answers. There is nothing to configure:
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. 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 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.
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.
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 |
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.
The default middleware stack runs at the edge, before the
request's era is known, because what it enforces is true of both. ProtocolVersionMiddleware
is not in that stack: the MCP-Protocol-Version header rule belongs to the handshake era, so
the transport applies it only to requests it classified as handshake-era traffic, and the
modern leg answers for its own revisions. It is available as
StreamableHttpTransport::handshakeMiddleware() and is applied whether or not you replace the
edge stack.
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));