[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:
Christopher Hertel 2026-08-19 03:24:17 +02:00 • committed by GitHub
commit 7fe88677ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1300 additions and 6 deletions

View file

@ -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

View file

@ -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
View file

@ -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

View file

@ -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

View file

@ -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).

View file

@ -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
View 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`.

View file

@ -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

View file

@ -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.

View 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)';
}

View file

@ -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

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

View 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();
}
}

View 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;
}
}

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