[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
3 changed files with 1300 additions and 6 deletions

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