From 23cb079dbaed1e1b65517e28273e100e1978d398 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Tue, 18 Aug 2026 21:02:08 +0200 Subject: [PATCH] [Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle 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. --- .github/workflows/conformance-weekly.yaml | 2 +- .github/workflows/pipeline.yaml | 2 +- .gitignore | 2 +- Makefile | 2 +- docs/index.md | 1 + docs/server-client-communication.md | 9 +- docs/stateless-lifecycle.md | 391 ++++++++++++++++++ docs/transports.md | 8 +- examples/client/README.md | 13 + .../client/stateless_lifecycle_client.php | 109 +++++ examples/server/README.md | 27 ++ .../server/stateless-lifecycle/server.php | 157 +++++++ tests/Integration/DualEraEndpointTest.php | 115 ++++++ tests/Integration/StatelessClientTest.php | 171 ++++++++ tests/Integration/StatelessLifecycleTest.php | 297 +++++++++++++ 15 files changed, 1300 insertions(+), 6 deletions(-) create mode 100644 docs/stateless-lifecycle.md create mode 100644 examples/client/stateless_lifecycle_client.php create mode 100644 examples/server/stateless-lifecycle/server.php create mode 100644 tests/Integration/DualEraEndpointTest.php create mode 100644 tests/Integration/StatelessClientTest.php create mode 100644 tests/Integration/StatelessLifecycleTest.php diff --git a/.github/workflows/conformance-weekly.yaml b/.github/workflows/conformance-weekly.yaml index 3d9b6143..d3012371 100644 --- a/.github/workflows/conformance-weekly.yaml +++ b/.github/workflows/conformance-weekly.yaml @@ -26,7 +26,7 @@ jobs: baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' dist-tag: alpha - path: '/stateless' + path: '/' baseline: conformance-baseline-2026-07-28.yml steps: - uses: actions/checkout@v7 diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 2a5475da..b0185431 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -109,7 +109,7 @@ jobs: path: '/' baseline: conformance-baseline-2025-11-25.yml - spec-version: '2026-07-28' - path: '/stateless' + path: '/' baseline: conformance-baseline-2026-07-28.yml steps: diff --git a/.gitignore b/.gitignore index 5ea477c0..c5d87ce7 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ examples/**/cache examples/**/sessions tests/Conformance/client-conformance.json tests/Conformance/server-conformance.json -tests/Conformance/results +tests/Conformance/results* tests/Conformance/sessions tests/Conformance/logs/*.log diff --git a/Makefile b/Makefile index 7490515f..93fbf1f2 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ conformance-draft-server: @echo "Waiting for server to start..." @sleep 5 rm -rf tests/Conformance/results-2026-07-28 - cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/stateless --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true + cd tests/Conformance && $(CONFORMANCE) server --url http://localhost:8000/ --suite all --spec-version 2026-07-28 --expected-failures conformance-baseline-2026-07-28.yml --output-dir results-2026-07-28 || true php tests/Conformance/score.php server 2026-07-28 results-2026-07-28 docker compose -f tests/Conformance/Fixtures/docker-compose.yml down diff --git a/docs/index.md b/docs/index.md index 91162290..a1f6f663 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ - [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods. - [Server Builder](server-builder.md) — Fluent builder class for creating and configuring MCP server instances. - [Client](client.md) — Client SDK for connecting to and communicating with MCP servers. +- [The 2026-07-28 Lifecycle](stateless-lifecycle.md) — The stateless protocol revision: per-request metadata, `server/discover`, multi round-trip requests, caching and subscriptions. - [Transports](transports.md) — STDIO and HTTP transport implementations with guidance on choosing between them. - [Server-Client Communication](server-client-communication.md) — Methods for servers to communicate back to clients: sampling, logging, progress, and notifications. - [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources). diff --git a/docs/server-client-communication.md b/docs/server-client-communication.md index 10195eb3..084f51d2 100644 --- a/docs/server-client-communication.md +++ b/docs/server-client-communication.md @@ -1,6 +1,13 @@ # Client Communication -MCP supports various ways a server can communicate back to a server on top of the main request-response flow. +MCP supports various ways a server can communicate back to a client on top of the main request-response flow. + +> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server sends its own +> JSON-RPC requests to the client. The modern lifecycle removed that: sampling, elicitation and roots are +> carried back inside the *result* instead, and `ClientGateway::sample()`, `elicit()` and `listRoots()` +> raise a `LogicException` there. Logging and progress still work as described below — they simply travel +> on the request's own response stream, and the client opts into each. See +> [The 2026-07-28 Lifecycle](stateless-lifecycle.md). ## Table of Contents diff --git a/docs/stateless-lifecycle.md b/docs/stateless-lifecycle.md new file mode 100644 index 00000000..45e3cb2d --- /dev/null +++ b/docs/stateless-lifecycle.md @@ -0,0 +1,391 @@ +# 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](#the-two-eras) +- [Building a stateless server](#building-a-stateless-server) +- [Per-request metadata](#per-request-metadata) +- [Multi round-trip requests](#multi-round-trip-requests) +- [Progress and logging](#progress-and-logging) +- [Caching](#caching) +- [Subscriptions](#subscriptions) +- [Serving both eras](#serving-both-eras) +- [What was removed](#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: + +```php +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](#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`](../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`: + +```php +$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: + +```php +->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](#what-a-handler-forks-on). + +```php +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()`: + +```php +->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** emit `notifications/message` at all, and does not. + +```php +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: + +```php +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: + +```php +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: + +```php +$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: + +```php +$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`: + +```php +$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`](../examples/server/elicitation) and +[`examples/server/client-communication`](../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. + +```php +$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. + +```php +$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 no `initialize`. It asks `server/discover` only 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* report `supportedVersions` and 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`, and `Mcp-Name` where the method + addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which + requires the client to have listed the tool first; `tools/list` is 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 with `inputResponses` and the server's `requestState` echoed 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/initialized` +- `ping` +- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]` +- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of + `subscriptions/listen` +- `notifications/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`. diff --git a/docs/transports.md b/docs/transports.md index 4caee513..ffa1496e 100644 --- a/docs/transports.md +++ b/docs/transports.md @@ -146,7 +146,6 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst |-------|------------|---------| | 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). | | 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. | -| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. | ```php // Zero-config, secure-by-default — local servers get full protection automatically. @@ -159,6 +158,13 @@ The default stack can be inspected and recomposed via the public factory: $middleware = StreamableHttpTransport::defaultMiddleware(); ``` +These run at the edge, before the request's protocol era is known, because what they enforce is true of +both eras. `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. +See [Serving both eras](stateless-lifecycle.md#serving-both-eras). + ### CORS Configuration CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it diff --git a/examples/client/README.md b/examples/client/README.md index 3e3bc092..c2121719 100644 --- a/examples/client/README.md +++ b/examples/client/README.md @@ -22,6 +22,19 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php php examples/client/http_discovery_calculator.php ``` +## Modern-era client (2026-07-28) + +Speaks the stateless lifecycle: no `initialize`, a `_meta` envelope and SEP-2243 headers on every +request, and multi round-trip calls answered by the client without the caller noticing. + +```bash +# First, start the matching server +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php + +# Then run the client +php examples/client/stateless_lifecycle_client.php +``` + ## Requirements All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server. diff --git a/examples/client/stateless_lifecycle_client.php b/examples/client/stateless_lifecycle_client.php new file mode 100644 index 00000000..917028e5 --- /dev/null +++ b/examples/client/stateless_lifecycle_client.php @@ -0,0 +1,109 @@ +message}\n"; + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } +}; + +$client = Client::builder() + ->setClientInfo('stateless-example-client', '1.0.0') + // The only line that selects the modern lifecycle. + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + // Declared in the envelope of every request, so the server knows what it + // may ask for before it decides how to answer. + ->setCapabilities(new ClientCapabilities(elicitation: true)) + ->addRequestHandler($answerWithAName) + ->build(); + +$client->connect(new HttpTransport('http://127.0.0.1:8000/')); + +printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value); + +echo "Tools:\n"; +foreach ($client->listTools()->tools as $tool) { + printf(" %-12s %s\n", $tool->name, $tool->description ?? ''); +} + +echo "\nA plain call:\n"; +echo ' '.text($client->callTool('get_weather', ['city' => 'Munich']))."\n"; + +echo "\nA call the server cannot finish in one round:\n"; +// One call from here. Two on the wire: the server returns its question, the +// handler above answers it, and the client retries carrying both the answer and +// the server's sealed `requestState`. +echo ' '.text($client->callTool('greet', []))."\n"; + +$client->disconnect(); + +/** The first block of text in a tool result. */ +function text(CallToolResult $result): string +{ + $first = $result->content[0] ?? null; + + return $first instanceof TextContent ? $first->text : '(no text)'; +} diff --git a/examples/server/README.md b/examples/server/README.md index a9326395..779d8a43 100644 --- a/examples/server/README.md +++ b/examples/server/README.md @@ -22,6 +22,33 @@ Run with Inspector: npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php ``` +## The 2026-07-28 lifecycle + +`stateless-lifecycle/server.php` speaks protocol revision `2026-07-28`, which removed the `initialize` +handshake and protocol-level sessions. It is HTTP-only and cannot be driven by the Inspector, which +opens with `initialize`: + +```bash +php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php +``` + +Every request carries its own protocol version and client capabilities, so a call is a single POST with +no handshake before it: + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'MCP-Protocol-Version: 2026-07-28' \ + -H 'Mcp-Method: server/discover' \ + -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}' +``` + +`tests/Integration/StatelessLifecycleTest.php` drives this example end to end — discovery, a tool call, +the multi round-trip flow, and the response stream carrying progress and log notifications. + ## Debugging You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by diff --git a/examples/server/stateless-lifecycle/server.php b/examples/server/stateless-lifecycle/server.php new file mode 100644 index 00000000..e08a8709 --- /dev/null +++ b/examples/server/stateless-lifecycle/server.php @@ -0,0 +1,157 @@ +setServerInfo('Stateless Lifecycle Demo', '1.0.0', title: 'Stateless Lifecycle Demo') + ->setLogger(logger()) + ->setCapabilities(new ServerCapabilities(tools: true, toolsListChanged: true, resources: true, logging: true)) + + // Only the handshake leg has anything to keep here: its clients open a + // session and come back to it. The modern leg never mints one, so under + // `php -S` — where nothing survives between requests — this is what lets + // both eras reach the same tools. + ->setSession(new FileSessionStore(__DIR__.'/sessions')) + + // How long an answer stays fresh. Lists are the same for every caller here, + // so they are public and long-lived; anything user-shaped stays private. + ->setCachePolicy( + CachePolicy::default(30_000) + ->withMethod('tools/list', 3_600_000, CacheScope::Public) + ->withMethod('server/discover', 3_600_000, CacheScope::Public), + ) + + // Signs the `requestState` a multi round-trip answer carries. The same key + // must reach every process that might serve the retry — a per-process + // random value only works for a single-process deployment. + ->setRequestState(getenv('MCP_REQUEST_STATE_KEY') ?: str_repeat('example-development-key-', 2)) + + // Carries change notifications to open `subscriptions/listen` streams. + // In-memory suits `php -S` and worker runtimes; under PHP-FPM use + // Psr16NotificationBus, since the publisher and the stream are different + // processes there. + ->setNotificationBus($bus) + ->setSubscriptionLifetime(20.0) + + // An ordinary tool: nothing about it is lifecycle-specific. + ->addTool( + static fn (string $city = 'Berlin'): string => sprintf('It is 17°C and cloudy in %s.', $city), + name: 'get_weather', + description: 'Reports the weather for a city', + ) + + // Progress and logging both travel on this request's own response stream. + // The client opts into each: progress by sending `_meta.progressToken`, + // logging by sending `_meta["io.modelcontextprotocol/logLevel"]`. Without + // those the server must stay silent, and does. + ->addTool( + static function (RequestContext $context, int $steps = 3): string { + $client = $context->getClientGateway(); + + for ($step = 1; $step <= $steps; ++$step) { + $client->log(LoggingLevel::Info, sprintf('Reindexing shard %d of %d', $step, $steps)); + $client->progress($step, $steps, sprintf('Shard %d of %d', $step, $steps)); + } + + return sprintf('Reindexed %d shards.', $steps); + }, + name: 'reindex', + description: 'Reindexes shards, reporting progress as it goes', + ) + + // A tool that needs something from the user. There are no server-initiated + // requests in this revision: instead of asking the client and waiting, the + // server *returns* the ask and the client retries the whole call with the + // answer. Nothing is kept between the two rounds — what the server needs to + // remember it seals into `requestState`, which comes back verified. + // + // No fork for the handshake era, and none needed: there the SDK fulfils the + // same ask over that connection's own channel and re-enters this closure + // with the answer. Which is why it re-derives where it is from what came + // back rather than keeping anything of its own. + ->addTool( + static function (RequestContext $context): CallToolResult|InputRequiredResult { + $answer = $context->getInputContext()?->elicitResult('who'); + + if (null === $answer) { + return new InputRequiredResult( + ['who' => new ElicitRequest( + 'What name should the greeting use?', + new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']), + )], + requestState: $context->mintRequestState(['asked' => 'who']), + ); + } + + return new CallToolResult([new TextContent(sprintf('Hello, %s!', $answer->content['name'] ?? 'friend'))]); + }, + name: 'greet', + description: 'Greets you by name, asking for it first if it has to', + ) + ->build(); + +shutdown($server->run(transport())); diff --git a/tests/Integration/DualEraEndpointTest.php b/tests/Integration/DualEraEndpointTest.php new file mode 100644 index 00000000..754d8a77 --- /dev/null +++ b/tests/Integration/DualEraEndpointTest.php @@ -0,0 +1,115 @@ + 'Ada']; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/stateless-lifecycle/server.php'; + } + + protected static function portBase(): int + { + return 9200; + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName connects to the one endpoint')] + public function testConnects(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertTrue($client->isConnected()); + $this->assertSame($era, $client->getProtocolVersion()); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName sees the same tools')] + public function testListsTheSameTools(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools); + sort($names); + + // One registry behind both legs, so the catalogue cannot drift. + $this->assertSame(['get_weather', 'greet', 'reindex'], $names); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client on $_dataName gets the same answer from the same tool')] + public function testCallsTheSameTool(ProtocolVersion $era): void + { + $client = $this->connect($era); + + $this->assertSame( + 'It is 17°C and cloudy in Munich.', + self::text($client->callTool('get_weather', ['city' => 'Munich'])), + ); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a tool that has to ask the user completes on $_dataName')] + public function testAsksForInput(ProtocolVersion $era): void + { + // The one place the eras genuinely differ on the wire: the handshake era + // is asked mid-call over its session's stream, the modern era is handed + // the question as a result and retries. The example forks on exactly + // that; the caller here does not. + $client = $this->connect($era, elicitation: true); + + $this->assertSame('Hello, Ada!', self::text($client->callTool('greet', []))); + + $client->disconnect(); + } + + #[TestDox('both eras are served in turn without restarting anything')] + public function testBothErasAgainstOneRunningServer(): void + { + $handshake = $this->connect(ProtocolVersion::V2025_11_25); + $modern = $this->connect(ProtocolVersion::V2026_07_28); + + // Interleaved on purpose: the handshake client's session stays open + // while the modern one is served, and neither disturbs the other. + $first = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + $second = self::text($modern->callTool('get_weather', ['city' => 'Berlin'])); + $third = self::text($handshake->callTool('get_weather', ['city' => 'Berlin'])); + + $this->assertSame($first, $second); + $this->assertSame($first, $third); + + $handshake->disconnect(); + $modern->disconnect(); + } +} diff --git a/tests/Integration/StatelessClientTest.php b/tests/Integration/StatelessClientTest.php new file mode 100644 index 00000000..fbd3d59e --- /dev/null +++ b/tests/Integration/StatelessClientTest.php @@ -0,0 +1,171 @@ +port = 8900 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('connects without a handshake and learns who it is talking to')] + public function testConnectWithoutHandshake(): void + { + $client = $this->connect(); + + $this->assertTrue($client->isConnected()); + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + $this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name); + + $client->disconnect(); + } + + #[TestDox('lists and calls a tool, which the server accepts on the first try')] + public function testToolCall(): void + { + $client = $this->connect(); + + $tools = $client->listTools(); + $this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools)); + + // A header the server disagreed with would come back as -32020, so a + // plain result is also the assertion that the mirroring was right. + $result = $client->callTool('get_weather', ['city' => 'Munich']); + + $this->assertStringContainsString('Munich', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('completes a multi round-trip call by answering the server itself')] + public function testMultiRoundTripIsTransparentToTheCaller(): void + { + $client = $this->connect(elicitation: true); + + // One call from here; two on the wire. The server asks for a name, the + // client answers from its own handler and retries with the sealed + // `requestState`, and the caller only ever sees the finished result. + $result = $client->callTool('greet', []); + + $this->assertStringContainsString('Hello, Ada!', self::text($result)); + + $client->disconnect(); + } + + #[TestDox('a capability the client never declared is refused up front, and costs only that call')] + public function testUndeclaredCapabilityIsRefused(): void + { + // No elicitation capability, and the envelope says so on every request, + // so the server refuses rather than asking for something that could + // never be answered. + $client = $this->connect(); + + try { + $client->callTool('greet', []); + $this->fail('Expected the server to refuse the undeclared capability.'); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + // The connection is stateless, so a failed call costs nothing. + $this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich']))); + + $client->disconnect(); + } + + private static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + private function connect(bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('stateless-integration-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setRequestTimeout(10); + + if ($elicitation) { + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada'])); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/StatelessLifecycleTest.php b/tests/Integration/StatelessLifecycleTest.php new file mode 100644 index 00000000..1983d234 --- /dev/null +++ b/tests/Integration/StatelessLifecycleTest.php @@ -0,0 +1,297 @@ +port = 8600 + (getmypid() % 300); + + $this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server->stop(); + } + + #[TestDox('server/discover reports the versions, capabilities, identity and caching hints')] + public function testDiscover(): void + { + $result = $this->call('server/discover', [])['result']; + + $this->assertSame([ProtocolVersion::V2026_07_28->value], $result['supportedVersions']); + $this->assertSame('complete', $result['resultType']); + $this->assertSame('Stateless Lifecycle Demo', $result['_meta']['io.modelcontextprotocol/serverInfo']['name']); + $this->assertSame(3_600_000, $result['ttlMs']); + $this->assertSame('public', $result['cacheScope']); + } + + #[TestDox('a tool call needs no handshake before it')] + public function testToolCallWithoutAHandshake(): void + { + $result = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => ['city' => 'Munich']], name: 'get_weather')['result']; + + $this->assertStringContainsString('Munich', $result['content'][0]['text']); + $this->assertSame('complete', $result['resultType']); + } + + #[TestDox('initialize is gone, and says so')] + public function testInitializeIsRefused(): void + { + $answer = $this->call('initialize', []); + + $this->assertSame(-32601, $answer['error']['code']); + } + + #[TestDox('a request whose header contradicts its body is refused with -32020')] + public function testHeaderMismatchIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else'); + + $this->assertSame(-32020, $answer['error']['code']); + } + + #[TestDox('an unsupported version comes back with the set to retry from')] + public function testUnsupportedVersion(): void + { + $answer = $this->call('tools/list', [], version: '1900-01-01'); + + $this->assertSame(-32022, $answer['error']['code']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']); + } + + #[TestDox('a multi round-trip tool asks, then completes on the retry')] + public function testMultiRoundTrip(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('input_required', $asked['resultType']); + $this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']); + $this->assertNotEmpty($asked['requestState']); + + // An interim result is not cacheable and carries no hints. + $this->assertArrayNotHasKey('ttlMs', $asked); + + $done = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $asked['requestState'], + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + $this->assertSame('Hello, Ada!', $done['content'][0]['text']); + $this->assertSame('complete', $done['resultType']); + } + + #[TestDox('a tampered requestState is refused')] + public function testTamperedRequestStateIsRefused(): void + { + $asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result']; + + [$body] = explode('.', $asked['requestState']); + + $answer = $this->call('tools/call', [ + 'name' => 'greet', + 'arguments' => [], + 'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'), + 'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]], + ], name: 'greet', capabilities: ['elicitation' => new \stdClass()]); + + $this->assertSame(-32602, $answer['error']['code']); + } + + #[TestDox('asking a client that declared no elicitation is refused with -32021')] + public function testUndeclaredCapabilityIsRefused(): void + { + $answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet'); + + $this->assertSame(-32021, $answer['error']['code']); + $this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']); + } + + #[TestDox('progress and log notifications arrive on the response stream, before the response')] + public function testResponseStreamCarriesNotifications(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + 'io.modelcontextprotocol/logLevel' => 'info', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertSame([ + 'notifications/message', + 'notifications/progress', + 'notifications/message', + 'notifications/progress', + 'response', + ], $methods); + + $this->assertSame('p1', $frames[1]['params']['progressToken']); + $this->assertSame('Reindexed 2 shards.', $frames[4]['result']['content'][0]['text']); + } + + #[TestDox('a request naming no log level receives no log messages')] + public function testLoggingIsSilentWithoutALevel(): void + { + $frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [ + 'progressToken' => 'p1', + ]); + + $methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames); + + $this->assertNotContains('notifications/message', $methods); + $this->assertContains('notifications/progress', $methods); + } + + /** + * @param array $params + * @param array $capabilities + * + * @return array + */ + private function call(string $method, array $params, ?string $name = null, ?string $version = null, array $capabilities = []): array + { + $body = $this->body($method, $params, $version, $capabilities); + + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, $version)), + 'content' => $body, + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $raw = file_get_contents($this->url(), false, $context); + + $this->assertIsString($raw, 'no response from the example server'); + + return json_decode($raw, true, flags: \JSON_THROW_ON_ERROR); + } + + /** + * @param array $params + * @param array $meta + * + * @return list> + */ + private function stream(string $method, array $params, ?string $name = null, array $meta = []): array + { + $context = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => implode("\r\n", $this->headers($method, $name, null)), + 'content' => $this->body($method, $params, null, [], $meta), + 'ignore_errors' => true, + 'timeout' => 10, + ]]); + + $handle = fopen($this->url(), 'r', false, $context); + $this->assertIsResource($handle); + + $frames = []; + while (false !== $line = fgets($handle)) { + $line = trim($line); + + // SSE comments are keep-alives and carry no event data. + if ('' === $line || str_starts_with($line, ':')) { + continue; + } + + if (str_starts_with($line, 'data: ')) { + $frames[] = json_decode(substr($line, 6), true, flags: \JSON_THROW_ON_ERROR); + } + } + + fclose($handle); + + return $frames; + } + + /** + * @param array $params + * @param array $capabilities + * @param array $meta + */ + private function body(string $method, array $params, ?string $version, array $capabilities = [], array $meta = []): string + { + $params['_meta'] = [ + 'io.modelcontextprotocol/protocolVersion' => $version ?? ProtocolVersion::V2026_07_28->value, + 'io.modelcontextprotocol/clientCapabilities' => (object) $capabilities, + 'io.modelcontextprotocol/clientInfo' => ['name' => 'integration-test', 'version' => '1.0.0'], + ...$meta, + ]; + + return json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => $method, + 'params' => $params, + ], \JSON_THROW_ON_ERROR); + } + + /** + * @return list + */ + private function headers(string $method, ?string $name, ?string $version): array + { + $headers = [ + 'Content-Type: application/json', + 'Accept: application/json, text/event-stream', + 'MCP-Protocol-Version: '.($version ?? ProtocolVersion::V2026_07_28->value), + 'Mcp-Method: '.$method, + ]; + + if (null !== $name) { + $headers[] = 'Mcp-Name: '.$name; + } + + return $headers; + } + + private function url(): string + { + return \sprintf('http://127.0.0.1:%d/', $this->port); + } +}