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

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