[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
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.
This commit is contained in:
parent
8c06dcecc6
commit
7fe88677ad
15 changed files with 1300 additions and 6 deletions
2
.github/workflows/conformance-weekly.yaml
vendored
2
.github/workflows/conformance-weekly.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/pipeline.yaml
vendored
2
.github/workflows/pipeline.yaml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
2
Makefile
2
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
391
docs/stateless-lifecycle.md
Normal file
391
docs/stateless-lifecycle.md
Normal file
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
109
examples/client/stateless_lifecycle_client.php
Normal file
109
examples/client/stateless_lifecycle_client.php
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Modern-era client (2026-07-28).
|
||||
*
|
||||
* The counterpart to examples/server/stateless-lifecycle. What is worth noticing
|
||||
* is how little of it is about the lifecycle: one call to setProtocolVersion()
|
||||
* and everything else is the same API as any other client.
|
||||
*
|
||||
* Underneath, that one call changes the wire completely:
|
||||
* - no `initialize` handshake — the connection is usable immediately, and
|
||||
* `server/discover` is asked only for the server's identity
|
||||
* - every request carries its own `_meta` envelope naming the revision and
|
||||
* what this client can be asked to do (SEP-2575)
|
||||
* - every POST carries `Mcp-Method`, and `Mcp-Name` where the method
|
||||
* addresses a subject, so an intermediary can route without reading the
|
||||
* body (SEP-2243)
|
||||
* - a tool that needs input is answered and retried by the client, so the
|
||||
* caller sees one call and one result (SEP-2322)
|
||||
*
|
||||
* Usage:
|
||||
* 1. Start the server: php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
|
||||
* 2. Run this script: php examples/client/stateless_lifecycle_client.php
|
||||
*/
|
||||
|
||||
require_once __DIR__.'/../../vendor/autoload.php';
|
||||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
|
||||
/**
|
||||
* Answers the server's request for input.
|
||||
*
|
||||
* In this revision the server cannot interrupt a call to ask — it returns the
|
||||
* question as the result. The handler is the same shape either way, which is
|
||||
* the point: a client written for the handshake era keeps working.
|
||||
*/
|
||||
$answerWithAName = new class implements RequestHandlerInterface {
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
assert($request instanceof ElicitRequest);
|
||||
|
||||
echo " server asked: {$request->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)';
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
157
examples/server/stateless-lifecycle/server.php
Normal file
157
examples/server/stateless-lifecycle/server.php
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
require_once dirname(__DIR__).'/bootstrap.php';
|
||||
chdir(__DIR__);
|
||||
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||
use Mcp\Schema\Enum\CacheScope;
|
||||
use Mcp\Schema\Enum\LoggingLevel;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\InputRequiredResult;
|
||||
use Mcp\Schema\ServerCapabilities;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Session\FileSessionStore;
|
||||
use Mcp\Server\Subscription\InMemoryNotificationBus;
|
||||
use Mcp\Server\Wire\CachePolicy;
|
||||
|
||||
/*
|
||||
* A server showing off the modern (2026-07-28) lifecycle.
|
||||
*
|
||||
* What is different about that lifecycle: there is no `initialize` handshake
|
||||
* and no session. Each request carries its own protocol version and client
|
||||
* capabilities in `_meta`, so it can be scaled to as many processes as you like
|
||||
* without any of them sharing state.
|
||||
*
|
||||
* What is *not* different: the endpoint. Like every other example here, this one
|
||||
* serves both eras — a request is classified before anything else looks at it
|
||||
* and routed to the lifecycle it belongs to. The settings below are the ones the
|
||||
* modern era makes use of; a handshake-era client reaches the same tools and
|
||||
* simply never sees them.
|
||||
*
|
||||
* php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
|
||||
*
|
||||
* Call it the modern way — every request carries the headers *and* the `_meta`:
|
||||
*
|
||||
* 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":{}}}}'
|
||||
*
|
||||
* Or the handshake way, against the same URL:
|
||||
*
|
||||
* curl -sS http://127.0.0.1:8000/ \
|
||||
* -H 'Content-Type: application/json' \
|
||||
* -H 'Accept: application/json, text/event-stream' \
|
||||
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
* "protocolVersion":"2025-11-25","capabilities":{},
|
||||
* "clientInfo":{"name":"curl","version":"1.0.0"}}}'
|
||||
*/
|
||||
|
||||
$bus = new InMemoryNotificationBus();
|
||||
|
||||
$server = Server::builder()
|
||||
->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()));
|
||||
115
tests/Integration/DualEraEndpointTest.php
Normal file
115
tests/Integration/DualEraEndpointTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* Two clients, two protocol eras, one URL.
|
||||
*
|
||||
* {@see StatelessClientTest} proves the modern client reaches this example and
|
||||
* the Inspector snapshots prove a handshake-era one reaches the others. This
|
||||
* proves the thing neither of them can on its own: that both reach the *same*
|
||||
* endpoint, with nothing for the client to choose and nothing for the operator
|
||||
* to mount twice.
|
||||
*/
|
||||
final class DualEraEndpointTest extends DualEraExampleTestCase
|
||||
{
|
||||
protected const ANSWERS = ['name' => '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();
|
||||
}
|
||||
}
|
||||
171
tests/Integration/StatelessClientTest.php
Normal file
171
tests/Integration/StatelessClientTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* The SDK's client against the SDK's server, both on 2026-07-28.
|
||||
*
|
||||
* {@see StatelessLifecycleTest} drives the same example with hand-built HTTP,
|
||||
* which proves the server answers a conforming client. This proves the other
|
||||
* half: that the client *is* one — no handshake, an envelope on every request,
|
||||
* headers the server's own validator accepts, and a multi round-trip call
|
||||
* completed without the caller doing anything special.
|
||||
*/
|
||||
class StatelessClientTest extends TestCase
|
||||
{
|
||||
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||
|
||||
private Process $server;
|
||||
private int $port;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
297
tests/Integration/StatelessLifecycleTest.php
Normal file
297
tests/Integration/StatelessLifecycleTest.php
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the official PHP MCP SDK.
|
||||
*
|
||||
* A collaboration between Symfony and the PHP Foundation.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Mcp\Tests\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Drives the `stateless-lifecycle` example over real HTTP.
|
||||
*
|
||||
* Not an Inspector snapshot test like the other examples: the Inspector CLI
|
||||
* opens with `initialize`, which this revision removed, so it cannot reach a
|
||||
* modern-lifecycle server at all. Until it can, the example is verified the way
|
||||
* a conforming client would actually use it — raw requests carrying their own
|
||||
* `_meta` and headers.
|
||||
*/
|
||||
class StatelessLifecycleTest extends TestCase
|
||||
{
|
||||
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||
|
||||
private Process $server;
|
||||
private int $port;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->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<string, mixed> $params
|
||||
* @param array<string, mixed> $capabilities
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $params
|
||||
* @param array<string, mixed> $meta
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $params
|
||||
* @param array<string, mixed> $capabilities
|
||||
* @param array<string, mixed> $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<string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue