[Docs][Examples][CI] Document and gate the 2026-07-28 lifecycle (#457)
docs/stateless-lifecycle.md walks the revision end to end - per-request _meta, server/discover, multi round-trip requests, caching and subscriptions - and a runnable example on each side shows what that looks like in code. CI matrixes both conformance roles over both revisions. --spec-version is cumulative across the dated revisions, so 2025-11-25 covers the two before it; 2026-07-28 is not cumulative with them and needs its own run against its own baseline. The runner is pinned so a PR only goes red for reasons in the PR, and conformance-weekly tracks the moving target instead - on both revisions, since the draft scenarios ship only on the alpha dist-tag. The Inspector cannot reach a modern-lifecycle server (it opens with initialize), so the example is covered by integration tests instead: one drives it with hand-built HTTP the way a conforming client would, one drives it with this SDK's client, and one drives the single endpoint from both eras.
This commit is contained in:
parent
8c06dcecc6
commit
7fe88677ad
15 changed files with 1300 additions and 6 deletions
2
.github/workflows/conformance-weekly.yaml
vendored
2
.github/workflows/conformance-weekly.yaml
vendored
|
|
@ -26,7 +26,7 @@ jobs:
|
||||||
baseline: conformance-baseline-2025-11-25.yml
|
baseline: conformance-baseline-2025-11-25.yml
|
||||||
- spec-version: '2026-07-28'
|
- spec-version: '2026-07-28'
|
||||||
dist-tag: alpha
|
dist-tag: alpha
|
||||||
path: '/stateless'
|
path: '/'
|
||||||
baseline: conformance-baseline-2026-07-28.yml
|
baseline: conformance-baseline-2026-07-28.yml
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
|
|
|
||||||
2
.github/workflows/pipeline.yaml
vendored
2
.github/workflows/pipeline.yaml
vendored
|
|
@ -109,7 +109,7 @@ jobs:
|
||||||
path: '/'
|
path: '/'
|
||||||
baseline: conformance-baseline-2025-11-25.yml
|
baseline: conformance-baseline-2025-11-25.yml
|
||||||
- spec-version: '2026-07-28'
|
- spec-version: '2026-07-28'
|
||||||
path: '/stateless'
|
path: '/'
|
||||||
baseline: conformance-baseline-2026-07-28.yml
|
baseline: conformance-baseline-2026-07-28.yml
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -8,7 +8,7 @@ examples/**/cache
|
||||||
examples/**/sessions
|
examples/**/sessions
|
||||||
tests/Conformance/client-conformance.json
|
tests/Conformance/client-conformance.json
|
||||||
tests/Conformance/server-conformance.json
|
tests/Conformance/server-conformance.json
|
||||||
tests/Conformance/results
|
tests/Conformance/results*
|
||||||
tests/Conformance/sessions
|
tests/Conformance/sessions
|
||||||
tests/Conformance/logs/*.log
|
tests/Conformance/logs/*.log
|
||||||
|
|
||||||
|
|
|
||||||
2
Makefile
2
Makefile
|
|
@ -55,7 +55,7 @@ conformance-draft-server:
|
||||||
@echo "Waiting for server to start..."
|
@echo "Waiting for server to start..."
|
||||||
@sleep 5
|
@sleep 5
|
||||||
rm -rf tests/Conformance/results-2026-07-28
|
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
|
php tests/Conformance/score.php server 2026-07-28 results-2026-07-28
|
||||||
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
|
docker compose -f tests/Conformance/Fixtures/docker-compose.yml down
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
- [MCP Elements](mcp-elements.md) — Core capabilities (Tools, Resources, Resource Templates, and Prompts) with registration methods.
|
- [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.
|
- [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.
|
- [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.
|
- [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.
|
- [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).
|
- [Protocol Extensions](extensions.md) — Opt-in protocol extensions announced during capability negotiation, including MCP Apps (HTML UI resources).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
# Client Communication
|
# 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
|
## Table of Contents
|
||||||
|
|
||||||
|
|
|
||||||
391
docs/stateless-lifecycle.md
Normal file
391
docs/stateless-lifecycle.md
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
# The 2026-07-28 lifecycle
|
||||||
|
|
||||||
|
Protocol revision `2026-07-28` removed the `initialize` handshake and protocol-level sessions. Everything a
|
||||||
|
server needs to answer a request now travels *in* that request, which means any process can answer any
|
||||||
|
request and none of them need to share state.
|
||||||
|
|
||||||
|
This guide covers what changes for a server author. Tools, resources, prompts and their handlers are
|
||||||
|
unaffected — the same registrations serve either lifecycle.
|
||||||
|
|
||||||
|
- [The two eras](#the-two-eras)
|
||||||
|
- [Building a stateless server](#building-a-stateless-server)
|
||||||
|
- [Per-request metadata](#per-request-metadata)
|
||||||
|
- [Multi round-trip requests](#multi-round-trip-requests)
|
||||||
|
- [Progress and logging](#progress-and-logging)
|
||||||
|
- [Caching](#caching)
|
||||||
|
- [Subscriptions](#subscriptions)
|
||||||
|
- [Serving both eras](#serving-both-eras)
|
||||||
|
- [What was removed](#what-was-removed)
|
||||||
|
|
||||||
|
## The two eras
|
||||||
|
|
||||||
|
| | Handshake era (`2025-11-25` and earlier) | Modern era (`2026-07-28`) |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Opening | `initialize` / `notifications/initialized` | none |
|
||||||
|
| Version | negotiated once, kept on the session | declared on every request |
|
||||||
|
| Capabilities | exchanged once | declared on every request |
|
||||||
|
| Discovery | `initialize` result | `server/discover` |
|
||||||
|
| Sessions | `Mcp-Session-Id` | removed |
|
||||||
|
| Server → client requests | sent as JSON-RPC requests | returned in the result (MRTR) |
|
||||||
|
| Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` |
|
||||||
|
| Dispatcher | `Protocol` | `StatelessProtocol` |
|
||||||
|
| HTTP entry | `StreamableHttpTransport` — the same one, for both |
|
||||||
|
|
||||||
|
`ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION`
|
||||||
|
is where the boundary sits.
|
||||||
|
|
||||||
|
## Building a stateless server
|
||||||
|
|
||||||
|
There is nothing to build differently. `Builder::build()` produces a `Server` carrying a dispatcher for
|
||||||
|
each era, and `StreamableHttpTransport` decides per request which of them answers:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mcp\Server;
|
||||||
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
||||||
|
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('My Server', '1.0.0')
|
||||||
|
->addTool(static fn (string $city): string => "17°C in {$city}", name: 'get_weather', description: '…')
|
||||||
|
->build();
|
||||||
|
|
||||||
|
(new SapiEmitter())->emit($server->run(new StreamableHttpTransport($request)));
|
||||||
|
```
|
||||||
|
|
||||||
|
That one endpoint answers `initialize` and `server/discover` alike. See
|
||||||
|
[Serving both eras](#serving-both-eras) for how the decision is made and how to opt out of it.
|
||||||
|
|
||||||
|
Modern-era requests accept `POST` only; a `GET` or `DELETE` is a handshake-era session operation and is
|
||||||
|
routed as one.
|
||||||
|
|
||||||
|
A full example lives in [`examples/server/stateless-lifecycle/server.php`](../examples/server/stateless-lifecycle/server.php).
|
||||||
|
|
||||||
|
## Per-request metadata
|
||||||
|
|
||||||
|
Every request **must** carry two members in `params._meta`, and the HTTP layer mirrors some of them into
|
||||||
|
headers so an intermediary can route without parsing the body:
|
||||||
|
|
||||||
|
| `_meta` key | Required | Header |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `io.modelcontextprotocol/protocolVersion` | yes | `MCP-Protocol-Version` |
|
||||||
|
| `io.modelcontextprotocol/clientCapabilities` | yes | — |
|
||||||
|
| `io.modelcontextprotocol/clientInfo` | no | — |
|
||||||
|
| `io.modelcontextprotocol/logLevel` | no | — |
|
||||||
|
| `progressToken` | no | — |
|
||||||
|
| `traceparent`, `tracestate`, `baggage` | no | — |
|
||||||
|
|
||||||
|
Plus `Mcp-Method` on every request, and `Mcp-Name` on `tools/call`, `prompts/get` and `resources/read`.
|
||||||
|
A header that disagrees with the body is refused with `-32020`; a missing required `_meta` member with
|
||||||
|
`-32602`; an unsupported version with `-32022`, carrying the supported set for the client to retry from.
|
||||||
|
|
||||||
|
Handlers read the metadata through `RequestContext`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$context->getProtocolVersion(); // the revision serving this request
|
||||||
|
$context->getClientCapabilities(); // what this client declared, or null in the handshake era
|
||||||
|
$context->getTraceContext(); // traceparent / tracestate / baggage, verbatim
|
||||||
|
```
|
||||||
|
|
||||||
|
`ClientGateway`'s capability probes — `supportsElicitation()`, `supportsSampling()`, `supportsRoots()` and
|
||||||
|
the sub-capability variants — read the same declaration, so they work in both eras.
|
||||||
|
|
||||||
|
### Mirroring a tool argument into a header
|
||||||
|
|
||||||
|
A tool parameter annotated with `x-mcp-header` is mirrored into `Mcp-Param-{Name}` by the client, and the
|
||||||
|
server checks that the two agree:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->addTool(
|
||||||
|
static fn (string $region, string $query): string => …,
|
||||||
|
name: 'execute_sql',
|
||||||
|
inputSchema: [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'region' => ['type' => 'string', 'x-mcp-header' => 'Region'],
|
||||||
|
'query' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
'required' => ['region', 'query'],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
The annotation must name a valid HTTP field, be unique case-insensitively, and sit on a `string`, `integer`
|
||||||
|
or `boolean` property reachable through `properties` keys alone. `Tool` refuses a definition that breaks
|
||||||
|
any of those rather than letting it fail later as a header mismatch.
|
||||||
|
|
||||||
|
## Multi round-trip requests
|
||||||
|
|
||||||
|
There are no server-initiated requests in this revision. A server that needs sampling, elicitation or roots
|
||||||
|
**returns** the ask, and the client retries the original call carrying the answers.
|
||||||
|
|
||||||
|
This is the shape to write handlers in even if you also serve handshake-era clients — the SDK fulfils the
|
||||||
|
same ask over their connection instead. See [What a handler forks on](#what-a-handler-forks-on).
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Schema\Result\InputRequiredResult;
|
||||||
|
|
||||||
|
static function (RequestContext $context): CallToolResult|InputRequiredResult {
|
||||||
|
$answer = $context->getInputContext()?->elicitResult('who');
|
||||||
|
|
||||||
|
if (null === $answer) {
|
||||||
|
return new InputRequiredResult(
|
||||||
|
['who' => new ElicitRequest('Your name?', $schema)],
|
||||||
|
requestState: $context->mintRequestState(['asked' => 'who']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CallToolResult([new TextContent("Hello, {$answer->content['name']}!")]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/call`, `prompts/get` and `resources/read` may answer this way; nothing else may.
|
||||||
|
|
||||||
|
**Reading the answers.** `InputContext` hands them back typed — `elicitResult()`, `samplingResult()`,
|
||||||
|
`rootsResult()` — and returns `null` for an answer that is absent *or* malformed. Both mean the same thing
|
||||||
|
to a handler: ask again. `response()` is still there for the raw array.
|
||||||
|
|
||||||
|
**`requestState`.** Whatever the server needs to remember between rounds. It travels through the client, so
|
||||||
|
it is attacker-controlled on return; `mintRequestState()` seals it with an HMAC and a TTL, and a state that
|
||||||
|
fails verification never reaches a handler. Configure the key with `Builder::setRequestState()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
->setRequestState($_ENV['MCP_REQUEST_STATE_KEY'], ttl: 600)
|
||||||
|
```
|
||||||
|
|
||||||
|
The **same key must reach every process that might serve the retry**. A per-process random value works only
|
||||||
|
for a single-process deployment. Nothing secret belongs in the payload — it is signed, not encrypted.
|
||||||
|
|
||||||
|
**Capabilities.** A server must not ask for input the client cannot provide. The SDK checks each ask against
|
||||||
|
the request's declared capabilities and answers `-32021` — with the missing set in
|
||||||
|
`data.requiredCapabilities` — rather than sending an ask that could never be answered. Url-mode elicitation
|
||||||
|
needs its own `elicitation.url` declaration; a bare `elicitation` means form mode only.
|
||||||
|
|
||||||
|
**What not to call.** `ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the
|
||||||
|
handshake era. Calling one under this revision raises a `LogicException` naming `InputRequiredResult` as the
|
||||||
|
replacement.
|
||||||
|
|
||||||
|
## Progress and logging
|
||||||
|
|
||||||
|
Both travel on the request's own response stream, and both are opt-in by the client:
|
||||||
|
|
||||||
|
- **Progress** — the client sends `_meta.progressToken`; without one, `$gateway->progress()` sends nothing.
|
||||||
|
- **Logging** — the client sends `_meta["io.modelcontextprotocol/logLevel"]`; without it the server **must
|
||||||
|
not** emit `notifications/message` at all, and does not.
|
||||||
|
|
||||||
|
```php
|
||||||
|
static function (RequestContext $context): string {
|
||||||
|
$client = $context->getClientGateway();
|
||||||
|
$client->log(LoggingLevel::Info, 'Reindexing shard 1 of 3');
|
||||||
|
$client->progress(1, 3, 'Shard 1 of 3');
|
||||||
|
|
||||||
|
return 'done';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The server answers with a single JSON object when the handler emits nothing, and opens an SSE stream when it
|
||||||
|
does — so an error that has to carry a specific status still gets one, and a handler that talks gets a
|
||||||
|
stream. Trace context from the request is echoed onto every notification it causes.
|
||||||
|
|
||||||
|
## Caching
|
||||||
|
|
||||||
|
`server/discover`, the four list methods and `resources/read` **must** carry `ttlMs` and `cacheScope`. The
|
||||||
|
default is `ttlMs: 0, cacheScope: "private"` — conformant, and a flat refusal to let anything be cached.
|
||||||
|
Say what you actually mean:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mcp\Schema\Enum\CacheScope;
|
||||||
|
use Mcp\Server\Wire\CachePolicy;
|
||||||
|
|
||||||
|
->setCachePolicy(
|
||||||
|
CachePolicy::default(30_000)
|
||||||
|
->withMethod('tools/list', 3_600_000, CacheScope::Public)
|
||||||
|
->withMethod('server/discover', 3_600_000, CacheScope::Public),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`public` lets a shared proxy serve one caller's answer to another, so use it only for results that do not
|
||||||
|
vary by caller. A `ReadResourceResult` may set its own `ttlMs`/`cacheScope`, which win over the policy.
|
||||||
|
Results produced by an MRTR retry are never given hints: their inputs are not part of any cache key.
|
||||||
|
|
||||||
|
## Subscriptions
|
||||||
|
|
||||||
|
`subscriptions/listen` replaces the HTTP `GET` stream and `resources/subscribe`. The client opens a
|
||||||
|
long-lived POST whose response stream carries the notification types it asked for; the server acknowledges
|
||||||
|
first with `notifications/subscriptions/acknowledged`, reporting the subset it agreed to honour.
|
||||||
|
|
||||||
|
Delivery needs a bus, because the process that publishes and the process holding the stream open are often
|
||||||
|
not the same one:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Mcp\Server\Subscription\InMemoryNotificationBus;
|
||||||
|
use Mcp\Server\Subscription\Psr16NotificationBus;
|
||||||
|
|
||||||
|
// stdio, or a persistent runtime where the whole server is one process
|
||||||
|
->setNotificationBus(new InMemoryNotificationBus())
|
||||||
|
|
||||||
|
// PHP-FPM: the publisher and the stream are different workers
|
||||||
|
->setNotificationBus(new Psr16NotificationBus($cache))
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry changes (`registerTool()`, `unregisterPrompt()`, …) are published automatically. Anything else —
|
||||||
|
`notifications/resources/updated` above all — is published by the application:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$bus->publish(new ResourceUpdatedNotification('file:///project/config.json'));
|
||||||
|
```
|
||||||
|
|
||||||
|
`Builder::setSubscriptionLifetime()` bounds how long a stream is held before the server closes it
|
||||||
|
gracefully. The real ceiling is the runtime's: under PHP-FPM a stream cannot outlive `max_execution_time`.
|
||||||
|
Pass `0` for "until the client or the runtime ends it".
|
||||||
|
|
||||||
|
## Serving both eras
|
||||||
|
|
||||||
|
One endpoint serves both, and the client picks nothing. Every request is classified once, before anything
|
||||||
|
else looks at it, and routed to the lifecycle it belongs to. The decision is **body-primary**:
|
||||||
|
|
||||||
|
| Evidence | Routed to |
|
||||||
|
| --- | --- |
|
||||||
|
| `params._meta` names a modern revision | modern era |
|
||||||
|
| `params._meta` names a handshake revision | handshake era |
|
||||||
|
| no such member | handshake era — `initialize` included |
|
||||||
|
| a notification with no member, under a modern header | modern era |
|
||||||
|
| `GET` / `DELETE` | handshake era |
|
||||||
|
|
||||||
|
The `MCP-Protocol-Version` header never decides. It is cross-checked against the body, and a request whose
|
||||||
|
header contradicts its `_meta` is refused with `-32020` before either leg sees it — the check has to happen
|
||||||
|
at the edge, because a body claiming a handshake revision routes to a leg that has no such check of its
|
||||||
|
own. A modern header on a request carrying no envelope is refused with `-32602` naming the member it wants.
|
||||||
|
|
||||||
|
An unrecognised revision goes to whichever leg can answer it best: claimed in the envelope, the modern leg
|
||||||
|
answers, naming the modern revisions it serves; named only in a header, the handshake leg answers, naming
|
||||||
|
the handshake ones.
|
||||||
|
|
||||||
|
Both legs come from **one** builder configuration — one registry, one set of handler instances, one session
|
||||||
|
manager. A tool registered once is reachable from both, and a change made through one is visible to the
|
||||||
|
other.
|
||||||
|
|
||||||
|
To serve the handshake era alone, say so:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('My Server', '1.0.0')
|
||||||
|
->withoutModernEra()
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
That server refuses a modern claim with `-32022`, naming the handshake revisions it does serve.
|
||||||
|
`setModernVersions()` narrows the modern leg instead of removing it.
|
||||||
|
|
||||||
|
For the opposite — an endpoint that serves the modern era and nothing else — build the dispatcher on its
|
||||||
|
own and mount it on `StatelessHttpTransport`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$protocol = Server::builder()
|
||||||
|
->setServerInfo('My Server', '1.0.0')
|
||||||
|
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||||
|
|
||||||
|
(new SapiEmitter())->emit((new StatelessHttpTransport($protocol))->handle($request));
|
||||||
|
```
|
||||||
|
|
||||||
|
### What a handler forks on
|
||||||
|
|
||||||
|
Nothing. Tools, resources, prompts, structured output, progress and errors do not care which era called,
|
||||||
|
and neither does the one thing that looks like it should: **asking the user something**.
|
||||||
|
|
||||||
|
Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the answer off
|
||||||
|
`RequestContext::getInputContext()` when the call comes back. On a handshake-era connection the SDK's
|
||||||
|
input-required shim fulfils the same ask over that connection's own channel: each embedded request goes
|
||||||
|
out as the real `elicitation/create` / `sampling/createMessage` / `roots/list`, and the handler is
|
||||||
|
re-entered with the answers under the keys it asked for. It is on by default;
|
||||||
|
[`examples/server/elicitation`](../examples/server/elicitation) and
|
||||||
|
[`examples/server/client-communication`](../examples/server/client-communication) are written this way and
|
||||||
|
name no era anywhere.
|
||||||
|
|
||||||
|
Two things to know about it.
|
||||||
|
|
||||||
|
**Re-entry is re-execution.** The handler runs again from the top each round, so it has to re-derive where
|
||||||
|
it is from what came back rather than from anything it kept. That is already true of the modern era — the
|
||||||
|
client retries the whole call there — so a portable handler is written that way regardless. It is only new
|
||||||
|
if you were relying on `ClientGateway::elicit()` suspending mid-body and keeping your locals; that keeps
|
||||||
|
working untouched, since nothing here runs unless a handler *returns* an ask.
|
||||||
|
|
||||||
|
**Each round holds the request open.** The shim waits for the client's answer inside the originating
|
||||||
|
request, which on a process-per-request runtime means it holds a worker for as long as the user takes.
|
||||||
|
That is the same cost `ClientGateway::elicit()` already pays on that leg, but the shim makes it reachable
|
||||||
|
from handlers that never mention it — so size `setInputRequiredLimits()` against your pool.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('My Server', '1.0.0')
|
||||||
|
// Re-entries per request, and seconds to wait for one answer.
|
||||||
|
->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120)
|
||||||
|
->build();
|
||||||
|
```
|
||||||
|
|
||||||
|
`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era connection instead of
|
||||||
|
being fulfilled behind your back.
|
||||||
|
|
||||||
|
## Writing a client for this revision
|
||||||
|
|
||||||
|
One line selects the lifecycle; nothing else about the API changes.
|
||||||
|
|
||||||
|
```php
|
||||||
|
$client = Client::builder()
|
||||||
|
->setClientInfo('my-client', '1.0.0')
|
||||||
|
->setProtocolVersion(ProtocolVersion::V2026_07_28)
|
||||||
|
->setCapabilities(new ClientCapabilities(elicitation: true))
|
||||||
|
->addRequestHandler($myElicitationHandler)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$client->connect(new HttpTransport('https://example.com/mcp'));
|
||||||
|
|
||||||
|
$client->callTool('greet', []);
|
||||||
|
```
|
||||||
|
|
||||||
|
What that changes underneath:
|
||||||
|
|
||||||
|
- **No handshake.** `connect()` sends no `initialize`. It asks `server/discover` only for the server's
|
||||||
|
identity, and a server that does not answer it still yields a usable connection — the method is
|
||||||
|
optional. If discovery *does* report `supportedVersions` and the configured revision is not among
|
||||||
|
them, the client moves to a modern revision the server lists, or refuses the connection outright
|
||||||
|
rather than talking past it.
|
||||||
|
- **An envelope on every request**, carrying the revision, the declared capabilities and the client
|
||||||
|
identity. The capabilities are what let a server decide, per request, whether it may ask for input.
|
||||||
|
- **Headers on every POST** — `MCP-Protocol-Version`, `Mcp-Method`, and `Mcp-Name` where the method
|
||||||
|
addresses a subject. Arguments annotated with `x-mcp-header` are mirrored into `Mcp-Param-*`, which
|
||||||
|
requires the client to have listed the tool first; `tools/list` is what populates that knowledge.
|
||||||
|
A tool whose annotations are malformed is dropped from the listing and refused if called, since the
|
||||||
|
client cannot produce the headers it demands.
|
||||||
|
- **Multi round-trip calls are answered by the client.** A result of `resultType: "input_required"` is
|
||||||
|
resolved through the same request handlers that served server-initiated requests in the handshake era,
|
||||||
|
and the call is re-sent with `inputResponses` and the server's `requestState` echoed back byte for
|
||||||
|
byte, under a new JSON-RPC id. The caller sees one call and one result.
|
||||||
|
|
||||||
|
Headers are an HTTP concern, so a transport opts into them by implementing `HeaderAwareTransportInterface`;
|
||||||
|
`HttpTransport` does, `StdioTransport` has nothing to carry them on. Everything else — the envelope, the
|
||||||
|
skipped handshake, the round-trip loop — applies to both.
|
||||||
|
|
||||||
|
See `examples/client/stateless_lifecycle_client.php` for a runnable version.
|
||||||
|
|
||||||
|
## What was removed
|
||||||
|
|
||||||
|
Answered with `404` and `-32601` by a modern server:
|
||||||
|
|
||||||
|
- `initialize`, `notifications/initialized`
|
||||||
|
- `ping`
|
||||||
|
- `logging/setLevel` — replaced by `_meta["io.modelcontextprotocol/logLevel"]`
|
||||||
|
- `resources/subscribe`, `resources/unsubscribe` — replaced by the `resourceSubscriptions` filter of
|
||||||
|
`subscriptions/listen`
|
||||||
|
- `notifications/roots/list_changed`
|
||||||
|
|
||||||
|
Also gone: `Mcp-Session-Id`, the HTTP `GET` stream, and SSE resumability (`Last-Event-ID`). A broken
|
||||||
|
response stream loses the request; the client re-issues it with a new id.
|
||||||
|
|
||||||
|
Error code `-32002` (resource not found) is retired in favour of `-32602`, and must not be emitted by a
|
||||||
|
server of this revision. The SDK picks the code from the revision serving the request, so a handshake-era
|
||||||
|
client still gets `-32002`.
|
||||||
|
|
||||||
|
Roots, sampling and logging are all **deprecated** as of this revision. They remain functional for at least
|
||||||
|
twelve months; new servers should pass directories through tool arguments or resource URIs instead of roots,
|
||||||
|
integrate with an LLM provider directly instead of sampling, and log to `stderr` or OpenTelemetry instead of
|
||||||
|
`notifications/message`.
|
||||||
|
|
@ -146,7 +146,6 @@ When the `middleware` argument is omitted (or set to `null`), the transport inst
|
||||||
|-------|------------|---------|
|
|-------|------------|---------|
|
||||||
| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). |
|
| 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. |
|
| 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
|
```php
|
||||||
// Zero-config, secure-by-default — local servers get full protection automatically.
|
// 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();
|
$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 Configuration
|
||||||
|
|
||||||
CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it
|
CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,19 @@ php -S localhost:8000 examples/server/discovery-calculator/server.php
|
||||||
php examples/client/http_discovery_calculator.php
|
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
|
## 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.
|
All examples require the server examples to be available. The STDIO examples spawn the server process, while the HTTP examples connect to a running HTTP server.
|
||||||
|
|
|
||||||
109
examples/client/stateless_lifecycle_client.php
Normal file
109
examples/client/stateless_lifecycle_client.php
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern-era client (2026-07-28).
|
||||||
|
*
|
||||||
|
* The counterpart to examples/server/stateless-lifecycle. What is worth noticing
|
||||||
|
* is how little of it is about the lifecycle: one call to setProtocolVersion()
|
||||||
|
* and everything else is the same API as any other client.
|
||||||
|
*
|
||||||
|
* Underneath, that one call changes the wire completely:
|
||||||
|
* - no `initialize` handshake — the connection is usable immediately, and
|
||||||
|
* `server/discover` is asked only for the server's identity
|
||||||
|
* - every request carries its own `_meta` envelope naming the revision and
|
||||||
|
* what this client can be asked to do (SEP-2575)
|
||||||
|
* - every POST carries `Mcp-Method`, and `Mcp-Name` where the method
|
||||||
|
* addresses a subject, so an intermediary can route without reading the
|
||||||
|
* body (SEP-2243)
|
||||||
|
* - a tool that needs input is answered and retried by the client, so the
|
||||||
|
* caller sees one call and one result (SEP-2322)
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* 1. Start the server: php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
|
||||||
|
* 2. Run this script: php examples/client/stateless_lifecycle_client.php
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__.'/../../vendor/autoload.php';
|
||||||
|
|
||||||
|
use Mcp\Client;
|
||||||
|
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||||
|
use Mcp\Client\Transport\HttpTransport;
|
||||||
|
use Mcp\Schema\ClientCapabilities;
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Enum\ElicitAction;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
|
use Mcp\Schema\JsonRpc\Response;
|
||||||
|
use Mcp\Schema\Request\ElicitRequest;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Schema\Result\ElicitResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Answers the server's request for input.
|
||||||
|
*
|
||||||
|
* In this revision the server cannot interrupt a call to ask — it returns the
|
||||||
|
* question as the result. The handler is the same shape either way, which is
|
||||||
|
* the point: a client written for the handshake era keeps working.
|
||||||
|
*/
|
||||||
|
$answerWithAName = new class implements RequestHandlerInterface {
|
||||||
|
public function supports(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request instanceof ElicitRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
assert($request instanceof ElicitRequest);
|
||||||
|
|
||||||
|
echo " server asked: {$request->message}\n";
|
||||||
|
|
||||||
|
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$client = Client::builder()
|
||||||
|
->setClientInfo('stateless-example-client', '1.0.0')
|
||||||
|
// The only line that selects the modern lifecycle.
|
||||||
|
->setProtocolVersion(ProtocolVersion::V2026_07_28)
|
||||||
|
// Declared in the envelope of every request, so the server knows what it
|
||||||
|
// may ask for before it decides how to answer.
|
||||||
|
->setCapabilities(new ClientCapabilities(elicitation: true))
|
||||||
|
->addRequestHandler($answerWithAName)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$client->connect(new HttpTransport('http://127.0.0.1:8000/'));
|
||||||
|
|
||||||
|
printf("Connected to %s (revision %s)\n\n", $client->getServerInfo()?->name, $client->getProtocolVersion()?->value);
|
||||||
|
|
||||||
|
echo "Tools:\n";
|
||||||
|
foreach ($client->listTools()->tools as $tool) {
|
||||||
|
printf(" %-12s %s\n", $tool->name, $tool->description ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "\nA plain call:\n";
|
||||||
|
echo ' '.text($client->callTool('get_weather', ['city' => 'Munich']))."\n";
|
||||||
|
|
||||||
|
echo "\nA call the server cannot finish in one round:\n";
|
||||||
|
// One call from here. Two on the wire: the server returns its question, the
|
||||||
|
// handler above answers it, and the client retries carrying both the answer and
|
||||||
|
// the server's sealed `requestState`.
|
||||||
|
echo ' '.text($client->callTool('greet', []))."\n";
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
|
||||||
|
/** The first block of text in a tool result. */
|
||||||
|
function text(CallToolResult $result): string
|
||||||
|
{
|
||||||
|
$first = $result->content[0] ?? null;
|
||||||
|
|
||||||
|
return $first instanceof TextContent ? $first->text : '(no text)';
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,33 @@ Run with Inspector:
|
||||||
npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/server.php
|
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
|
## Debugging
|
||||||
|
|
||||||
You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by
|
You can enable debug output by setting the `DEBUG` environment variable to `1`, and additionally log to a file by
|
||||||
|
|
|
||||||
157
examples/server/stateless-lifecycle/server.php
Normal file
157
examples/server/stateless-lifecycle/server.php
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/bootstrap.php';
|
||||||
|
chdir(__DIR__);
|
||||||
|
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Elicitation\ElicitationSchema;
|
||||||
|
use Mcp\Schema\Elicitation\StringSchemaDefinition;
|
||||||
|
use Mcp\Schema\Enum\CacheScope;
|
||||||
|
use Mcp\Schema\Enum\LoggingLevel;
|
||||||
|
use Mcp\Schema\Request\ElicitRequest;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Schema\Result\InputRequiredResult;
|
||||||
|
use Mcp\Schema\ServerCapabilities;
|
||||||
|
use Mcp\Server;
|
||||||
|
use Mcp\Server\RequestContext;
|
||||||
|
use Mcp\Server\Session\FileSessionStore;
|
||||||
|
use Mcp\Server\Subscription\InMemoryNotificationBus;
|
||||||
|
use Mcp\Server\Wire\CachePolicy;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A server showing off the modern (2026-07-28) lifecycle.
|
||||||
|
*
|
||||||
|
* What is different about that lifecycle: there is no `initialize` handshake
|
||||||
|
* and no session. Each request carries its own protocol version and client
|
||||||
|
* capabilities in `_meta`, so it can be scaled to as many processes as you like
|
||||||
|
* without any of them sharing state.
|
||||||
|
*
|
||||||
|
* What is *not* different: the endpoint. Like every other example here, this one
|
||||||
|
* serves both eras — a request is classified before anything else looks at it
|
||||||
|
* and routed to the lifecycle it belongs to. The settings below are the ones the
|
||||||
|
* modern era makes use of; a handshake-era client reaches the same tools and
|
||||||
|
* simply never sees them.
|
||||||
|
*
|
||||||
|
* php -S 127.0.0.1:8000 examples/server/stateless-lifecycle/server.php
|
||||||
|
*
|
||||||
|
* Call it the modern way — every request carries the headers *and* the `_meta`:
|
||||||
|
*
|
||||||
|
* curl -sS http://127.0.0.1:8000/ \
|
||||||
|
* -H 'Content-Type: application/json' \
|
||||||
|
* -H 'Accept: application/json, text/event-stream' \
|
||||||
|
* -H 'MCP-Protocol-Version: 2026-07-28' \
|
||||||
|
* -H 'Mcp-Method: server/discover' \
|
||||||
|
* -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{
|
||||||
|
* "io.modelcontextprotocol/protocolVersion":"2026-07-28",
|
||||||
|
* "io.modelcontextprotocol/clientCapabilities":{}}}}'
|
||||||
|
*
|
||||||
|
* Or the handshake way, against the same URL:
|
||||||
|
*
|
||||||
|
* curl -sS http://127.0.0.1:8000/ \
|
||||||
|
* -H 'Content-Type: application/json' \
|
||||||
|
* -H 'Accept: application/json, text/event-stream' \
|
||||||
|
* -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||||
|
* "protocolVersion":"2025-11-25","capabilities":{},
|
||||||
|
* "clientInfo":{"name":"curl","version":"1.0.0"}}}'
|
||||||
|
*/
|
||||||
|
|
||||||
|
$bus = new InMemoryNotificationBus();
|
||||||
|
|
||||||
|
$server = Server::builder()
|
||||||
|
->setServerInfo('Stateless Lifecycle Demo', '1.0.0', title: 'Stateless Lifecycle Demo')
|
||||||
|
->setLogger(logger())
|
||||||
|
->setCapabilities(new ServerCapabilities(tools: true, toolsListChanged: true, resources: true, logging: true))
|
||||||
|
|
||||||
|
// Only the handshake leg has anything to keep here: its clients open a
|
||||||
|
// session and come back to it. The modern leg never mints one, so under
|
||||||
|
// `php -S` — where nothing survives between requests — this is what lets
|
||||||
|
// both eras reach the same tools.
|
||||||
|
->setSession(new FileSessionStore(__DIR__.'/sessions'))
|
||||||
|
|
||||||
|
// How long an answer stays fresh. Lists are the same for every caller here,
|
||||||
|
// so they are public and long-lived; anything user-shaped stays private.
|
||||||
|
->setCachePolicy(
|
||||||
|
CachePolicy::default(30_000)
|
||||||
|
->withMethod('tools/list', 3_600_000, CacheScope::Public)
|
||||||
|
->withMethod('server/discover', 3_600_000, CacheScope::Public),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Signs the `requestState` a multi round-trip answer carries. The same key
|
||||||
|
// must reach every process that might serve the retry — a per-process
|
||||||
|
// random value only works for a single-process deployment.
|
||||||
|
->setRequestState(getenv('MCP_REQUEST_STATE_KEY') ?: str_repeat('example-development-key-', 2))
|
||||||
|
|
||||||
|
// Carries change notifications to open `subscriptions/listen` streams.
|
||||||
|
// In-memory suits `php -S` and worker runtimes; under PHP-FPM use
|
||||||
|
// Psr16NotificationBus, since the publisher and the stream are different
|
||||||
|
// processes there.
|
||||||
|
->setNotificationBus($bus)
|
||||||
|
->setSubscriptionLifetime(20.0)
|
||||||
|
|
||||||
|
// An ordinary tool: nothing about it is lifecycle-specific.
|
||||||
|
->addTool(
|
||||||
|
static fn (string $city = 'Berlin'): string => sprintf('It is 17°C and cloudy in %s.', $city),
|
||||||
|
name: 'get_weather',
|
||||||
|
description: 'Reports the weather for a city',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress and logging both travel on this request's own response stream.
|
||||||
|
// The client opts into each: progress by sending `_meta.progressToken`,
|
||||||
|
// logging by sending `_meta["io.modelcontextprotocol/logLevel"]`. Without
|
||||||
|
// those the server must stay silent, and does.
|
||||||
|
->addTool(
|
||||||
|
static function (RequestContext $context, int $steps = 3): string {
|
||||||
|
$client = $context->getClientGateway();
|
||||||
|
|
||||||
|
for ($step = 1; $step <= $steps; ++$step) {
|
||||||
|
$client->log(LoggingLevel::Info, sprintf('Reindexing shard %d of %d', $step, $steps));
|
||||||
|
$client->progress($step, $steps, sprintf('Shard %d of %d', $step, $steps));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sprintf('Reindexed %d shards.', $steps);
|
||||||
|
},
|
||||||
|
name: 'reindex',
|
||||||
|
description: 'Reindexes shards, reporting progress as it goes',
|
||||||
|
)
|
||||||
|
|
||||||
|
// A tool that needs something from the user. There are no server-initiated
|
||||||
|
// requests in this revision: instead of asking the client and waiting, the
|
||||||
|
// server *returns* the ask and the client retries the whole call with the
|
||||||
|
// answer. Nothing is kept between the two rounds — what the server needs to
|
||||||
|
// remember it seals into `requestState`, which comes back verified.
|
||||||
|
//
|
||||||
|
// No fork for the handshake era, and none needed: there the SDK fulfils the
|
||||||
|
// same ask over that connection's own channel and re-enters this closure
|
||||||
|
// with the answer. Which is why it re-derives where it is from what came
|
||||||
|
// back rather than keeping anything of its own.
|
||||||
|
->addTool(
|
||||||
|
static function (RequestContext $context): CallToolResult|InputRequiredResult {
|
||||||
|
$answer = $context->getInputContext()?->elicitResult('who');
|
||||||
|
|
||||||
|
if (null === $answer) {
|
||||||
|
return new InputRequiredResult(
|
||||||
|
['who' => new ElicitRequest(
|
||||||
|
'What name should the greeting use?',
|
||||||
|
new ElicitationSchema(['name' => new StringSchemaDefinition('Name')], ['name']),
|
||||||
|
)],
|
||||||
|
requestState: $context->mintRequestState(['asked' => 'who']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CallToolResult([new TextContent(sprintf('Hello, %s!', $answer->content['name'] ?? 'friend'))]);
|
||||||
|
},
|
||||||
|
name: 'greet',
|
||||||
|
description: 'Greets you by name, asking for it first if it has to',
|
||||||
|
)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
shutdown($server->run(transport()));
|
||||||
115
tests/Integration/DualEraEndpointTest.php
Normal file
115
tests/Integration/DualEraEndpointTest.php
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Tests\Integration;
|
||||||
|
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two clients, two protocol eras, one URL.
|
||||||
|
*
|
||||||
|
* {@see StatelessClientTest} proves the modern client reaches this example and
|
||||||
|
* the Inspector snapshots prove a handshake-era one reaches the others. This
|
||||||
|
* proves the thing neither of them can on its own: that both reach the *same*
|
||||||
|
* endpoint, with nothing for the client to choose and nothing for the operator
|
||||||
|
* to mount twice.
|
||||||
|
*/
|
||||||
|
final class DualEraEndpointTest extends DualEraExampleTestCase
|
||||||
|
{
|
||||||
|
protected const ANSWERS = ['name' => 'Ada'];
|
||||||
|
|
||||||
|
protected static function server(): string
|
||||||
|
{
|
||||||
|
return __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function portBase(): int
|
||||||
|
{
|
||||||
|
return 9200;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideEras')]
|
||||||
|
#[TestDox('a client on $_dataName connects to the one endpoint')]
|
||||||
|
public function testConnects(ProtocolVersion $era): void
|
||||||
|
{
|
||||||
|
$client = $this->connect($era);
|
||||||
|
|
||||||
|
$this->assertTrue($client->isConnected());
|
||||||
|
$this->assertSame($era, $client->getProtocolVersion());
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideEras')]
|
||||||
|
#[TestDox('a client on $_dataName sees the same tools')]
|
||||||
|
public function testListsTheSameTools(ProtocolVersion $era): void
|
||||||
|
{
|
||||||
|
$client = $this->connect($era);
|
||||||
|
|
||||||
|
$names = array_map(static fn ($tool): string => $tool->name, $client->listTools()->tools);
|
||||||
|
sort($names);
|
||||||
|
|
||||||
|
// One registry behind both legs, so the catalogue cannot drift.
|
||||||
|
$this->assertSame(['get_weather', 'greet', 'reindex'], $names);
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideEras')]
|
||||||
|
#[TestDox('a client on $_dataName gets the same answer from the same tool')]
|
||||||
|
public function testCallsTheSameTool(ProtocolVersion $era): void
|
||||||
|
{
|
||||||
|
$client = $this->connect($era);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
'It is 17°C and cloudy in Munich.',
|
||||||
|
self::text($client->callTool('get_weather', ['city' => 'Munich'])),
|
||||||
|
);
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideEras')]
|
||||||
|
#[TestDox('a tool that has to ask the user completes on $_dataName')]
|
||||||
|
public function testAsksForInput(ProtocolVersion $era): void
|
||||||
|
{
|
||||||
|
// The one place the eras genuinely differ on the wire: the handshake era
|
||||||
|
// is asked mid-call over its session's stream, the modern era is handed
|
||||||
|
// the question as a result and retries. The example forks on exactly
|
||||||
|
// that; the caller here does not.
|
||||||
|
$client = $this->connect($era, elicitation: true);
|
||||||
|
|
||||||
|
$this->assertSame('Hello, Ada!', self::text($client->callTool('greet', [])));
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('both eras are served in turn without restarting anything')]
|
||||||
|
public function testBothErasAgainstOneRunningServer(): void
|
||||||
|
{
|
||||||
|
$handshake = $this->connect(ProtocolVersion::V2025_11_25);
|
||||||
|
$modern = $this->connect(ProtocolVersion::V2026_07_28);
|
||||||
|
|
||||||
|
// Interleaved on purpose: the handshake client's session stays open
|
||||||
|
// while the modern one is served, and neither disturbs the other.
|
||||||
|
$first = self::text($handshake->callTool('get_weather', ['city' => 'Berlin']));
|
||||||
|
$second = self::text($modern->callTool('get_weather', ['city' => 'Berlin']));
|
||||||
|
$third = self::text($handshake->callTool('get_weather', ['city' => 'Berlin']));
|
||||||
|
|
||||||
|
$this->assertSame($first, $second);
|
||||||
|
$this->assertSame($first, $third);
|
||||||
|
|
||||||
|
$handshake->disconnect();
|
||||||
|
$modern->disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
171
tests/Integration/StatelessClientTest.php
Normal file
171
tests/Integration/StatelessClientTest.php
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Tests\Integration;
|
||||||
|
|
||||||
|
use Mcp\Client;
|
||||||
|
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||||
|
use Mcp\Client\Transport\HttpTransport;
|
||||||
|
use Mcp\Schema\ClientCapabilities;
|
||||||
|
use Mcp\Schema\Content\TextContent;
|
||||||
|
use Mcp\Schema\Enum\ElicitAction;
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use Mcp\Schema\JsonRpc\Request;
|
||||||
|
use Mcp\Schema\JsonRpc\Response;
|
||||||
|
use Mcp\Schema\Request\ElicitRequest;
|
||||||
|
use Mcp\Schema\Result\CallToolResult;
|
||||||
|
use Mcp\Schema\Result\ElicitResult;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SDK's client against the SDK's server, both on 2026-07-28.
|
||||||
|
*
|
||||||
|
* {@see StatelessLifecycleTest} drives the same example with hand-built HTTP,
|
||||||
|
* which proves the server answers a conforming client. This proves the other
|
||||||
|
* half: that the client *is* one — no handshake, an envelope on every request,
|
||||||
|
* headers the server's own validator accepts, and a multi round-trip call
|
||||||
|
* completed without the caller doing anything special.
|
||||||
|
*/
|
||||||
|
class StatelessClientTest extends TestCase
|
||||||
|
{
|
||||||
|
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||||
|
|
||||||
|
private Process $server;
|
||||||
|
private int $port;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->port = 8900 + (getmypid() % 300);
|
||||||
|
|
||||||
|
$this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]);
|
||||||
|
$this->server->start();
|
||||||
|
|
||||||
|
$deadline = microtime(true) + 5;
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->server->stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('connects without a handshake and learns who it is talking to')]
|
||||||
|
public function testConnectWithoutHandshake(): void
|
||||||
|
{
|
||||||
|
$client = $this->connect();
|
||||||
|
|
||||||
|
$this->assertTrue($client->isConnected());
|
||||||
|
$this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion());
|
||||||
|
$this->assertSame('Stateless Lifecycle Demo', $client->getServerInfo()?->name);
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('lists and calls a tool, which the server accepts on the first try')]
|
||||||
|
public function testToolCall(): void
|
||||||
|
{
|
||||||
|
$client = $this->connect();
|
||||||
|
|
||||||
|
$tools = $client->listTools();
|
||||||
|
$this->assertContains('get_weather', array_map(static fn ($tool) => $tool->name, $tools->tools));
|
||||||
|
|
||||||
|
// A header the server disagreed with would come back as -32020, so a
|
||||||
|
// plain result is also the assertion that the mirroring was right.
|
||||||
|
$result = $client->callTool('get_weather', ['city' => 'Munich']);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Munich', self::text($result));
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('completes a multi round-trip call by answering the server itself')]
|
||||||
|
public function testMultiRoundTripIsTransparentToTheCaller(): void
|
||||||
|
{
|
||||||
|
$client = $this->connect(elicitation: true);
|
||||||
|
|
||||||
|
// One call from here; two on the wire. The server asks for a name, the
|
||||||
|
// client answers from its own handler and retries with the sealed
|
||||||
|
// `requestState`, and the caller only ever sees the finished result.
|
||||||
|
$result = $client->callTool('greet', []);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Hello, Ada!', self::text($result));
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a capability the client never declared is refused up front, and costs only that call')]
|
||||||
|
public function testUndeclaredCapabilityIsRefused(): void
|
||||||
|
{
|
||||||
|
// No elicitation capability, and the envelope says so on every request,
|
||||||
|
// so the server refuses rather than asking for something that could
|
||||||
|
// never be answered.
|
||||||
|
$client = $this->connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$client->callTool('greet', []);
|
||||||
|
$this->fail('Expected the server to refuse the undeclared capability.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The connection is stateless, so a failed call costs nothing.
|
||||||
|
$this->assertStringContainsString('Munich', self::text($client->callTool('get_weather', ['city' => 'Munich'])));
|
||||||
|
|
||||||
|
$client->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function text(CallToolResult $result): string
|
||||||
|
{
|
||||||
|
$first = $result->content[0] ?? null;
|
||||||
|
|
||||||
|
self::assertInstanceOf(TextContent::class, $first);
|
||||||
|
|
||||||
|
return $first->text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function connect(bool $elicitation = false): Client
|
||||||
|
{
|
||||||
|
$builder = Client::builder()
|
||||||
|
->setClientInfo('stateless-integration-client', '1.0.0')
|
||||||
|
->setProtocolVersion(ProtocolVersion::V2026_07_28)
|
||||||
|
->setRequestTimeout(10);
|
||||||
|
|
||||||
|
if ($elicitation) {
|
||||||
|
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||||
|
$builder->addRequestHandler(new class implements RequestHandlerInterface {
|
||||||
|
public function supports(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request instanceof ElicitRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, ['name' => 'Ada']));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$client = $builder->build();
|
||||||
|
$client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)));
|
||||||
|
|
||||||
|
return $client;
|
||||||
|
}
|
||||||
|
}
|
||||||
297
tests/Integration/StatelessLifecycleTest.php
Normal file
297
tests/Integration/StatelessLifecycleTest.php
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* This file is part of the official PHP MCP SDK.
|
||||||
|
*
|
||||||
|
* A collaboration between Symfony and the PHP Foundation.
|
||||||
|
*
|
||||||
|
* For the full copyright and license information, please view the LICENSE
|
||||||
|
* file that was distributed with this source code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace Mcp\Tests\Integration;
|
||||||
|
|
||||||
|
use Mcp\Schema\Enum\ProtocolVersion;
|
||||||
|
use PHPUnit\Framework\Attributes\TestDox;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drives the `stateless-lifecycle` example over real HTTP.
|
||||||
|
*
|
||||||
|
* Not an Inspector snapshot test like the other examples: the Inspector CLI
|
||||||
|
* opens with `initialize`, which this revision removed, so it cannot reach a
|
||||||
|
* modern-lifecycle server at all. Until it can, the example is verified the way
|
||||||
|
* a conforming client would actually use it — raw requests carrying their own
|
||||||
|
* `_meta` and headers.
|
||||||
|
*/
|
||||||
|
class StatelessLifecycleTest extends TestCase
|
||||||
|
{
|
||||||
|
private const SERVER = __DIR__.'/../../examples/server/stateless-lifecycle/server.php';
|
||||||
|
|
||||||
|
private Process $server;
|
||||||
|
private int $port;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->port = 8600 + (getmypid() % 300);
|
||||||
|
|
||||||
|
$this->server = new Process(['php', '-S', \sprintf('127.0.0.1:%d', $this->port), self::SERVER]);
|
||||||
|
$this->server->start();
|
||||||
|
|
||||||
|
$deadline = microtime(true) + 5;
|
||||||
|
while (microtime(true) < $deadline) {
|
||||||
|
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
usleep(50_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$this->server->stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('server/discover reports the versions, capabilities, identity and caching hints')]
|
||||||
|
public function testDiscover(): void
|
||||||
|
{
|
||||||
|
$result = $this->call('server/discover', [])['result'];
|
||||||
|
|
||||||
|
$this->assertSame([ProtocolVersion::V2026_07_28->value], $result['supportedVersions']);
|
||||||
|
$this->assertSame('complete', $result['resultType']);
|
||||||
|
$this->assertSame('Stateless Lifecycle Demo', $result['_meta']['io.modelcontextprotocol/serverInfo']['name']);
|
||||||
|
$this->assertSame(3_600_000, $result['ttlMs']);
|
||||||
|
$this->assertSame('public', $result['cacheScope']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a tool call needs no handshake before it')]
|
||||||
|
public function testToolCallWithoutAHandshake(): void
|
||||||
|
{
|
||||||
|
$result = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => ['city' => 'Munich']], name: 'get_weather')['result'];
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Munich', $result['content'][0]['text']);
|
||||||
|
$this->assertSame('complete', $result['resultType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('initialize is gone, and says so')]
|
||||||
|
public function testInitializeIsRefused(): void
|
||||||
|
{
|
||||||
|
$answer = $this->call('initialize', []);
|
||||||
|
|
||||||
|
$this->assertSame(-32601, $answer['error']['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a request whose header contradicts its body is refused with -32020')]
|
||||||
|
public function testHeaderMismatchIsRefused(): void
|
||||||
|
{
|
||||||
|
$answer = $this->call('tools/call', ['name' => 'get_weather', 'arguments' => []], name: 'something_else');
|
||||||
|
|
||||||
|
$this->assertSame(-32020, $answer['error']['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('an unsupported version comes back with the set to retry from')]
|
||||||
|
public function testUnsupportedVersion(): void
|
||||||
|
{
|
||||||
|
$answer = $this->call('tools/list', [], version: '1900-01-01');
|
||||||
|
|
||||||
|
$this->assertSame(-32022, $answer['error']['code']);
|
||||||
|
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a multi round-trip tool asks, then completes on the retry')]
|
||||||
|
public function testMultiRoundTrip(): void
|
||||||
|
{
|
||||||
|
$asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||||
|
|
||||||
|
$this->assertSame('input_required', $asked['resultType']);
|
||||||
|
$this->assertSame('elicitation/create', $asked['inputRequests']['who']['method']);
|
||||||
|
$this->assertNotEmpty($asked['requestState']);
|
||||||
|
|
||||||
|
// An interim result is not cacheable and carries no hints.
|
||||||
|
$this->assertArrayNotHasKey('ttlMs', $asked);
|
||||||
|
|
||||||
|
$done = $this->call('tools/call', [
|
||||||
|
'name' => 'greet',
|
||||||
|
'arguments' => [],
|
||||||
|
'requestState' => $asked['requestState'],
|
||||||
|
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Ada']]],
|
||||||
|
], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||||
|
|
||||||
|
$this->assertSame('Hello, Ada!', $done['content'][0]['text']);
|
||||||
|
$this->assertSame('complete', $done['resultType']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a tampered requestState is refused')]
|
||||||
|
public function testTamperedRequestStateIsRefused(): void
|
||||||
|
{
|
||||||
|
$asked = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet', capabilities: ['elicitation' => new \stdClass()])['result'];
|
||||||
|
|
||||||
|
[$body] = explode('.', $asked['requestState']);
|
||||||
|
|
||||||
|
$answer = $this->call('tools/call', [
|
||||||
|
'name' => 'greet',
|
||||||
|
'arguments' => [],
|
||||||
|
'requestState' => $body.'.'.strtr(base64_encode('forged'), '+/', '-_'),
|
||||||
|
'inputResponses' => ['who' => ['action' => 'accept', 'content' => ['name' => 'Mallory']]],
|
||||||
|
], name: 'greet', capabilities: ['elicitation' => new \stdClass()]);
|
||||||
|
|
||||||
|
$this->assertSame(-32602, $answer['error']['code']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('asking a client that declared no elicitation is refused with -32021')]
|
||||||
|
public function testUndeclaredCapabilityIsRefused(): void
|
||||||
|
{
|
||||||
|
$answer = $this->call('tools/call', ['name' => 'greet', 'arguments' => []], name: 'greet');
|
||||||
|
|
||||||
|
$this->assertSame(-32021, $answer['error']['code']);
|
||||||
|
$this->assertArrayHasKey('elicitation', $answer['error']['data']['requiredCapabilities']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('progress and log notifications arrive on the response stream, before the response')]
|
||||||
|
public function testResponseStreamCarriesNotifications(): void
|
||||||
|
{
|
||||||
|
$frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [
|
||||||
|
'progressToken' => 'p1',
|
||||||
|
'io.modelcontextprotocol/logLevel' => 'info',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames);
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
'notifications/message',
|
||||||
|
'notifications/progress',
|
||||||
|
'notifications/message',
|
||||||
|
'notifications/progress',
|
||||||
|
'response',
|
||||||
|
], $methods);
|
||||||
|
|
||||||
|
$this->assertSame('p1', $frames[1]['params']['progressToken']);
|
||||||
|
$this->assertSame('Reindexed 2 shards.', $frames[4]['result']['content'][0]['text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[TestDox('a request naming no log level receives no log messages')]
|
||||||
|
public function testLoggingIsSilentWithoutALevel(): void
|
||||||
|
{
|
||||||
|
$frames = $this->stream('tools/call', ['name' => 'reindex', 'arguments' => ['steps' => 2]], name: 'reindex', meta: [
|
||||||
|
'progressToken' => 'p1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$methods = array_map(static fn (array $frame): string => $frame['method'] ?? 'response', $frames);
|
||||||
|
|
||||||
|
$this->assertNotContains('notifications/message', $methods);
|
||||||
|
$this->assertContains('notifications/progress', $methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param array<string, mixed> $capabilities
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function call(string $method, array $params, ?string $name = null, ?string $version = null, array $capabilities = []): array
|
||||||
|
{
|
||||||
|
$body = $this->body($method, $params, $version, $capabilities);
|
||||||
|
|
||||||
|
$context = stream_context_create(['http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => implode("\r\n", $this->headers($method, $name, $version)),
|
||||||
|
'content' => $body,
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'timeout' => 10,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$raw = file_get_contents($this->url(), false, $context);
|
||||||
|
|
||||||
|
$this->assertIsString($raw, 'no response from the example server');
|
||||||
|
|
||||||
|
return json_decode($raw, true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function stream(string $method, array $params, ?string $name = null, array $meta = []): array
|
||||||
|
{
|
||||||
|
$context = stream_context_create(['http' => [
|
||||||
|
'method' => 'POST',
|
||||||
|
'header' => implode("\r\n", $this->headers($method, $name, null)),
|
||||||
|
'content' => $this->body($method, $params, null, [], $meta),
|
||||||
|
'ignore_errors' => true,
|
||||||
|
'timeout' => 10,
|
||||||
|
]]);
|
||||||
|
|
||||||
|
$handle = fopen($this->url(), 'r', false, $context);
|
||||||
|
$this->assertIsResource($handle);
|
||||||
|
|
||||||
|
$frames = [];
|
||||||
|
while (false !== $line = fgets($handle)) {
|
||||||
|
$line = trim($line);
|
||||||
|
|
||||||
|
// SSE comments are keep-alives and carry no event data.
|
||||||
|
if ('' === $line || str_starts_with($line, ':')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($line, 'data: ')) {
|
||||||
|
$frames[] = json_decode(substr($line, 6), true, flags: \JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return $frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $params
|
||||||
|
* @param array<string, mixed> $capabilities
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function body(string $method, array $params, ?string $version, array $capabilities = [], array $meta = []): string
|
||||||
|
{
|
||||||
|
$params['_meta'] = [
|
||||||
|
'io.modelcontextprotocol/protocolVersion' => $version ?? ProtocolVersion::V2026_07_28->value,
|
||||||
|
'io.modelcontextprotocol/clientCapabilities' => (object) $capabilities,
|
||||||
|
'io.modelcontextprotocol/clientInfo' => ['name' => 'integration-test', 'version' => '1.0.0'],
|
||||||
|
...$meta,
|
||||||
|
];
|
||||||
|
|
||||||
|
return json_encode([
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => 1,
|
||||||
|
'method' => $method,
|
||||||
|
'params' => $params,
|
||||||
|
], \JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function headers(string $method, ?string $name, ?string $version): array
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: application/json',
|
||||||
|
'Accept: application/json, text/event-stream',
|
||||||
|
'MCP-Protocol-Version: '.($version ?? ProtocolVersion::V2026_07_28->value),
|
||||||
|
'Mcp-Method: '.$method,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (null !== $name) {
|
||||||
|
$headers[] = 'Mcp-Name: '.$name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function url(): string
|
||||||
|
{
|
||||||
|
return \sprintf('http://127.0.0.1:%d/', $this->port);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue