Skip to content

Latest commit

 

History

History
136 lines (106 loc) · 5.56 KB

File metadata and controls

136 lines (106 loc) · 5.56 KB

Serving both eras

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.

What a modern request carries

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.

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. See Schema generation for where a hand-written inputSchema fits.

How a request is routed

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.

Middleware

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.

Serving one era only

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));