Skip to content

Commit ddf2518

Browse files
committed
[Examples][Tests][Docs] Cover the modern client end to end
An example against the stateless-lifecycle server, an integration test driving that same server with the SDK's own client, and the conformance fixture honouring MCP_CONFORMANCE_PROTOCOL_VERSION — without which it opened every scenario with `initialize`. The draft client baseline is down to the auth block.
1 parent 4b4a1a5 commit ddf2518

8 files changed

Lines changed: 455 additions & 82 deletions

File tree

docs/stateless-lifecycle.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,48 @@ A client detects which it is talking to by attempting a modern request and inspe
299299
`400`: a recognised modern JSON-RPC error means the server is modern and the client should retry with a
300300
supported version, anything else means it should fall back to `initialize`.
301301

302+
## Writing a client for this revision
303+
304+
One line selects the lifecycle; nothing else about the API changes.
305+
306+
```php
307+
$client = Client::builder()
308+
->setClientInfo('my-client', '1.0.0')
309+
->setProtocolVersion(ProtocolVersion::V2026_07_28)
310+
->setCapabilities(new ClientCapabilities(elicitation: true))
311+
->addRequestHandler($myElicitationHandler)
312+
->build();
313+
314+
$client->connect(new HttpTransport('https://example.com/mcp'));
315+
316+
$client->callTool('greet', []);
317+
```
318+
319+
What that changes underneath:
320+
321+
- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's
322+
identity, and a server that does not answer it still yields a usable connection — the method is
323+
optional. If discovery *does* report `supportedVersions` and the configured revision is not among
324+
them, the client moves to a modern revision the server lists, or refuses the connection outright
325+
rather than talking past it.
326+
- **An envelope on every request**, carrying the revision, the declared capabilities and the client
327+
identity. The capabilities are what let a server decide, per request, whether it may ask for input.
328+
- **Headers on every POST**`MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method
329+
addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which
330+
requires the client to have listed the tool first; `tools/list` is what populates that knowledge.
331+
A tool whose annotations are malformed is dropped from the listing and refused if called, since the
332+
client cannot produce the headers it demands.
333+
- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is
334+
resolved through the same request handlers that served server-initiated requests in the handshake era,
335+
and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for
336+
byte, under a new JSON-RPC id. The caller sees one call and one result.
337+
338+
Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`;
339+
`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the
340+
skipped handshake, the round-trip loop — applies to both.
341+
342+
See `examples/client/stateless_lifecycle_client.php` for a runnable version.
343+
302344
## What was removed
303345

304346
Answered with `404` and `-32601` by a modern server:

examples/client/README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php
2222
php examples/client/http_discovery_calculator.php
2323
```
2424

25+
## Modern-era client (2026-07-28)
26+
27+
Speaks the stateless lifecycle: no `initialize`, a `_meta` envelope and SEP-2243 headers on every
28+
request, and multi round-trip calls answered by the client without the caller noticing.
29+
30+
```bash
31+
# First, start the matching server
32+
php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
33+
34+
# Then run the client
35+
php examples/client/stateless_lifecycle_client.php
36+
```
37+
2538
## Requirements
2639

2740
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.
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
/**
13+
* Modern-era client (2026-07-28).
14+
*
15+
* The counterpart to examples/server/stateless-lifecycle. What is worth noticing
16+
* is how little of it is about the lifecycle: one call to setProtocolVersion()
17+
* and everything else is the same API as any other client.
18+
*
19+
* Underneath, that one call changes the wire completely:
20+
* - no `initialize` handshake — the connection is usable immediately, and
21+
* `server/discover` is asked only for the server's identity
22+
* - every request carries its own `_meta` envelope naming the revision and
23+
* what this client can be asked to do (SEP-2575)
24+
* - every POST carries `Mcp-Method`, and `Mcp-Name` where the method
25+
* addresses a subject, so an intermediary can route without reading the
26+
* body (SEP-2243)
27+
* - a tool that needs input is answered and retried by the client, so the
28+
* caller sees one call and one result (SEP-2322)
29+
*
30+
* Usage:
31+
* 1. Start the server: php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
32+
* 2. Run this script: php examples/client/stateless_lifecycle_client.php
33+
*/
34+
35+
require_once __DIR__.'/../../vendor/autoload.php';
36+
37+
use Mcp\Client;
38+
use Mcp\Client\Handler\Request\RequestHandlerInterface;
39+
use Mcp\Client\Transport\HttpTransport;
40+
use Mcp\Schema\ClientCapabilities;
41+
use Mcp\Schema\Content\TextContent;
42+
use Mcp\Schema\Enum\ElicitAction;
43+
use Mcp\Schema\Enum\ProtocolVersion;
44+
use Mcp\Schema\JsonRpc\Request;
45+
use Mcp\Schema\JsonRpc\Response;
46+
use Mcp\Schema\Request\ElicitRequest;
47+
use Mcp\Schema\Result\CallToolResult;
48+
use Mcp\Schema\Result\ElicitResult;
49+
50+
/**
51+
* Answers the server's request for input.
52+
*
53+
* In this revision the server cannot interrupt a call to ask — it returns the
54+
* question as the result. The handler is the same shape either way, which is
55+
* the point: a client written for the handshake era keeps working.
56+
*/
57+
$answerWithAName = new class implements RequestHandlerInterface {
58+
public function supports(Request $request): bool
59+
{
60+
return $request instanceof ElicitRequest;
61+
}
62+
63+
public function handle(Request $request): Response
64+
{
65+
assert($request instanceof ElicitRequest);
66+
67+
echo " server asked: {$request->message}\n";
68+
69+
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']));
70+
}
71+
};
72+
73+
$client = Client::builder()
74+
->setClientInfo('stateless-example-client', '1.0.0')
75+
// The only line that selects the modern lifecycle.
76+
->setProtocolVersion(ProtocolVersion::V2026_07_28)
77+
// Declared in the envelope of every request, so the server knows what it
78+
// may ask for before it decides how to answer.
79+
->setCapabilities(new ClientCapabilities(elicitation: true))
80+
->addRequestHandler($answerWithAName)
81+
->build();
82+
83+
$client->connect(new HttpTransport('http://127.0.0.1:8000/'));
84+
85+
printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value);
86+
87+
echo "Tools:\n";
88+
foreach ($client->listTools()->tools as $tool) {
89+
printf(" %-12s %s\n", $tool->name, $tool->description ?? '');
90+
}
91+
92+
echo "\nA plain call:\n";
93+
echo ' '.text($client->callTool('get_weather', ['city' => 'Munich']))."\n";
94+
95+
echo "\nA call the server cannot finish in one round:\n";
96+
// One call from here. Two on the wire: the server returns its question, the
97+
// handler above answers it, and the client retries carrying both the answer and
98+
// the server's sealed `requestState`.
99+
echo ' '.text($client->callTool('greet', []))."\n";
100+
101+
$client->disconnect();
102+
103+
/** The first block of text in a tool result. */
104+
function text(CallToolResult $result): string
105+
{
106+
$first = $result->content[0] ?? null;
107+
108+
return $first instanceof TextContent ? $first->text : '(no text)';
109+
}

spec-report.md

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,34 @@
1111

1212
The original audit found three structural holes and eight bugs. **All eight bugs and all three structural
1313
holes are closed**, along with the Tasks extension, the extensions framework, subscriptions delivery,
14-
request-scoped streaming, the caching policy, deprecations, docs and an end-user example.
14+
request-scoped streaming, the caching policy, deprecations, docs and end-user examples. The **client** now
15+
speaks the revision too, so both halves of the SDK are on 2026-07-28.
1516

1617
| | Before | After |
1718
| --- | --- | --- |
1819
| Modern conformance (`make conformance-draft-server`) | 138 passed / 3 failed | **151/151** |
1920
| Handshake conformance (`make conformance-server`) | 39/39 | **80/80** |
2021
| Server baseline entries | 3 | **0** |
21-
| Unit tests | 1222 | **1421** |
22-
| Integration tests | 33 | **43** |
22+
| Modern client conformance (`make conformance-draft-client`) | not gated | **baseline clean** |
23+
| Unit tests | 1222 | **1476** |
24+
| Integration tests | 33 | **47** |
2325
| Inspector snapshot tests | 97 | **103** |
2426
| Tasks conformance (`tasks-*`, nine scenarios) | n/a | every failure the runner's core-schema check |
2527
| PHPStan | 7 pre-existing errors | 7 pre-existing errors |
2628

27-
The server side now passes both revisions outright: the two failures the branch had disclaimed turned out
29+
Both sides now pass both revisions outright. The two server failures the branch had disclaimed turned out
2830
to be fixture bugs, not SDK ones — a resource template echoing back its own `{id}` pattern instead of the
2931
resolved URI, and a `json_schema_2020_12_tool` neither conformance server defined. The check counts jumped
3032
because the runner is now pinned to a version that carries the 2026-07-28 scenarios and is run with
3133
`--suite all` on both revisions.
3234

3335
**What is done** — §A lifecycle & transport · §B MRTR · §C headers & metadata · §D results & caching ·
34-
§E errors & schema · §F subscriptions · §G1 extensions framework · §G2 Tasks · §I1 deprecations · §I3 docs.
36+
§E errors & schema · §F subscriptions · §G1 extensions framework · §G2 Tasks · §I1 deprecations · §I3 docs ·
37+
the client side of all of it.
3538

3639
**What remains** — §3.1 MCP Apps ergonomics (an attribute and a scheme check) · §3.2 Authorization (mostly
3740
blocked: there is no client-side OAuth for those rules to constrain yet) · §3.3 `anyOf` for a union of two
38-
different array shapes · §3.4 conformance traceability · **§3.5 the client half of 2026-07-28, which is
39-
untouched**. None is a MUST-level gap for a server implementation.
41+
different array shapes · §3.4 conformance traceability. None is a MUST-level gap.
4042
These are tracked in [§3](#3-remaining-work) with the same requirement/evidence/action shape as the original
4143
audit. Everything else in this document is kept as the record of what was found and how it was closed.
4244

@@ -213,25 +215,6 @@ Related: #370 (`additionalProperties` unsupported) and #397 (phpstan/psalm numbe
213215
to wire SEP traceability files into the runner and surface per-SEP pass rates; #368 is the Tier 2 gap
214216
analysis, for which this document is input.
215217

216-
### 3.5 The client does not speak 2026-07-28 — P1, no upstream issue yet
217-
218-
Everything above is the **server** half. Gating the client suite on the modern revision (see §4) shows the
219-
client still opens with `initialize` regardless of the revision it is pointed at, so it never reaches the
220-
stateless wire at all. Seven scenarios fail on that one root cause:
221-
222-
| Scenario | What is missing |
223-
| --- | --- |
224-
| `request-metadata` | the per-request `_meta` envelope, `MCP-Protocol-Version` header, client info and capability declarations (SEP-2575) |
225-
| `http-standard-headers` | `Mcp-Method` on every POST, `Mcp-Name` on the addressed methods (SEP-2243) |
226-
| `http-custom-headers` | `x-mcp-header``Mcp-Param-*` mirroring, value encoding, the Base64 sentinel, null omission (SEP-2243) |
227-
| `http-invalid-tool-headers` | refusing a tool whose header annotations do not validate (SEP-2243) |
228-
| `sep-2322-client-request-state` | echoing `requestState`, a fresh JSON-RPC id per round, parallel isolation (SEP-2322) |
229-
| `tools_call`, `json-schema-2020-12-preservation` | cascade only — both need a reachable `tools/list` |
230-
231-
The pieces exist server-side (`StatelessProtocol`, `StandardHeaderValidator`, the MRTR machinery), so this
232-
is a matter of giving the client the same wire, not new protocol work. Baselined per-scenario so the
233-
entries double as the work list.
234-
235218
---
236219

237220
## 4. Verification
@@ -243,16 +226,16 @@ four run in CI — `.github/workflows/pipeline.yaml` matrixes each role over the
243226
make conformance-server # handshake endpoint — 80/80
244227
make conformance-draft-server # modern endpoint — 151/151
245228
make conformance-client # handshake — baseline clean (auth stack absent, §3.2)
246-
make conformance-draft-client # modern — baseline clean (§3.2 + §3.5)
229+
make conformance-draft-client # modern — baseline clean (auth stack absent, §3.2)
247230
248231
# Tasks (off the spec timeline, so --force and one scenario at a time):
249232
npx @modelcontextprotocol/conformance@0.2.0-alpha.11 server --url http://localhost:8000/stateless \
250233
--scenario tasks-lifecycle --spec-version 2026-07-28 --force
251234
# Every remaining failure is the runner's wire-schema-valid check applying the
252235
# *core* schema to an extension result.
253236
254-
vendor/bin/phpunit --testsuite=unit # 1421
255-
vendor/bin/phpunit --testsuite=integration # 43, boots the example over real HTTP
237+
vendor/bin/phpunit --testsuite=unit # 1476
238+
vendor/bin/phpunit --testsuite=integration # 47, boots the examples over real HTTP
256239
vendor/bin/phpunit --testsuite=inspector # 103 (7 skipped); handshake-era examples only
257240
vendor/bin/phpstan --memory-limit=-1 # 7 pre-existing errors, all alreadyNarrowedType under PHP 8.5
258241
vendor/bin/php-cs-fixer fix
@@ -264,6 +247,8 @@ the dated revision and `alpha` for the draft one, which is what keeps the pin fr
264247

265248
**The Inspector cannot reach a modern-lifecycle server.** It opens with `initialize`, which this revision
266249
removed, so `tests/Inspector/` covers the handshake-era examples only. The
267-
`stateless-lifecycle` example is verified instead by `tests/Integration/StatelessLifecycleTest.php`, which
268-
drives it over real HTTP the way a conforming client would — discovery, a tool call, both MRTR rounds, a
269-
tampered `requestState`, and the response stream carrying interleaved progress and log notifications.
250+
`stateless-lifecycle` example is verified instead by two integration tests, one per direction:
251+
`StatelessLifecycleTest` drives it with hand-built HTTP the way a conforming client would — discovery, a
252+
tool call, both MRTR rounds, a tampered `requestState`, and the response stream carrying interleaved
253+
progress and log notifications — and `StatelessClientTest` drives it with the SDK's own client, which is
254+
what proves that client is conforming.

0 commit comments

Comments
 (0)