diff --git a/CHANGELOG.md b/CHANGELOG.md index e873dd58..de355a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ All notable changes to `mcp/sdk` will be documented in this file. * Reject a recognized `Mcp-Param-*` header whose mirrored argument is absent from the body with `-32020`, instead of accepting the request (SEP-2243). * Fix `JwtTokenValidator` with several issuers always fetching the keys of the first one: keys now come from the issuer the token claims, which must be configured. * Fix `RequestEvent`, `ResponseEvent` and `ErrorEvent` not being dispatched for `2026-07-28` requests. +* [BC Break] Bump the client's default protocol version to `2026-07-28`: `connect()` probes with `server/discover` and falls back to the `initialize` handshake on `2025-11-25` when the server does not speak the modern era. `Builder::setFallbackProtocolVersion()` picks the fallback revision, or `null` for a modern-only client; `Builder::setProtocolVersion(ProtocolVersion::V2025_11_25)` skips the probe. +* Serve both protocol eras over stdio: `StdioTransport` settles the era on the client's first request and serves `2026-07-28` requests, `subscriptions/listen` and `notifications/cancelled` on the one channel. +* On a `2026-07-28` connection, `Client::setLoggingLevel()` stamps the level on every following request, `Client::ping()` sends `server/discover` and `Client::sendRootsListChanged()` sends nothing. +* Answer a bare `initialize` on a `2026-07-28`-only endpoint with `-32022` naming the served revisions, and a request without a session on the handshake leg with its id. +* Fail a client request at once when the HTTP server refuses it with an error status or the stdio server process exits, instead of waiting out the timeout. * [BC Break] Validate a tool result's `structuredContent` against the tool's `outputSchema`, which the specification requires the server to honour. A mismatch is answered with a `CallToolResult` carrying `isError: true` instead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares no `outputSchema`, when the result carries no `structuredContent`, and when the result is already an error. 0.8.0 diff --git a/docs/client/connecting.md b/docs/client/connecting.md index 1fe01e12..d843b239 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -61,30 +61,42 @@ $client = Client::builder() ### Protocol Version -Specify the MCP protocol version to offer during the handshake (defaults to `V2025_11_25`, the -latest handshake revision — the modern `2026-07-28` revision must be chosen explicitly): +A client speaks both protocol eras by default. It prefers `2026-07-28`, probes for it with `server/discover` when +connecting, and falls back to the `initialize` handshake on `2025-11-25` when the server turns out not to speak it. +Use `$client->getProtocolVersion()` after connecting to read what the connection settled on. ```php use Mcp\Schema\Enum\ProtocolVersion; +// Fall back to an older handshake revision instead of 2025-11-25… +$client = Client::builder() + ->setFallbackProtocolVersion(ProtocolVersion::V2025_06_18) + ->build(); + +// …or not at all, refusing servers without the modern era. +$client = Client::builder() + ->setFallbackProtocolVersion(null) + ->build(); +``` + +Passing a handshake revision to `setProtocolVersion()` skips the probe and opens with `initialize`, the way a client +from before the modern era would: + +```php $client = Client::builder() ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->build(); ``` -This is an offer, not a demand. A server that does not support the requested revision counter-offers one it does, as -described in the specification's +The handshake is an offer, not a demand. A server that does not support the requested revision counter-offers one it +does, as described in the specification's [protocol version negotiation](https://modelcontextprotocol.io/specification/latest/basic/versioning#protocol-version-negotiation) section. The client accepts any counter-offer it knows about and continues on that revision; a counter-offer the SDK cannot speak fails the handshake with a `ConnectionException` rather than continuing on a revision neither side agreed -on. Use `$client->getProtocolVersion()` after connecting to read what was actually negotiated. - -Setting a modern revision such as `2026-07-28` selects the other lifecycle rather than making an offer: there is no -`initialize` to negotiate with, so `connect()` sends none and every request carries its own revision instead. Nothing -else about the client API changes. See [Clients on this revision](../protocol-versions.md) for what happens underneath. +on. -See [Protocol versions](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the -exchange. +See [Protocol versions](../protocol-versions.md#how-the-client-settles-on-an-era) for how the probe is read, and +[the handshake era](../protocol-versions.md#negotiating-in-the-handshake-era) for the server side of the exchange. ### Capabilities diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 09081bab..082f601c 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -21,6 +21,7 @@ map; the mechanics live with the task they belong to. | Change notifications | HTTP `GET` stream, `resources/subscribe` | `subscriptions/listen` | | Dispatcher | `Protocol` | `StatelessProtocol` | | HTTP entry | `StreamableHttpTransport` — the same one, for both | +| stdio entry | `StdioTransport` — the same one, settled by the client's first request | `ProtocolVersion::isModern()` tells the two apart, and `Mcp\Schema\Enum\ProtocolVersion::FIRST_MODERN_VERSION` is where the boundary sits. @@ -105,28 +106,62 @@ either lifecycle. What changes: ## Speaking it from a client -One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. +A client speaks both eras out of the box. It prefers `2026-07-28`, and finds out on +`connect()` whether the server does too; nothing about the [client API](client/index.md) +depends on the answer. ```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->getProtocolVersion(); // 2026-07-28, or 2025-11-25 against an older server $client->callTool('greet', []); ``` -What that changes underneath: +### How the client settles on an era -- **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. +`connect()` probes with `server/discover`, stamped with the preferred revision, before +anything else — as the specification's backward-compatibility rules for +[stdio](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility) +and +[Streamable HTTP](https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility) +describe. + +| The probe gets | The client | +| --- | --- | +| a `DiscoverResult` listing a modern revision it speaks | stays modern, on that revision | +| `-32022` naming a modern revision it speaks | retries the probe with that revision | +| `-32022` naming only handshake revisions | falls back to `initialize` | +| `-32022` naming nothing it speaks | fails the connection | +| any other error, an HTTP refusal without one, or no answer in time | falls back to `initialize` | +| a `DiscoverResult` listing only handshake revisions | falls back to `initialize` | + +The fallback is not keyed to one error code: servers from before the modern era refuse an +unexpected request however they like, or not at all. A refusal costs nothing — the client +falls back as soon as it arrives — but a server that stays silent costs the +[initialization timeout](client/connecting.md#basic-configuration). A server process that exits fails the +attempt outright: an outage is not an answer about the era. + +The fallback offers `2025-11-25`; `setFallbackProtocolVersion()` picks another handshake +revision, and `setFallbackProtocolVersion(null)` makes the client modern-only, failing the +connection instead. A handshake revision passed to `setProtocolVersion()` skips the probe +and opens with `initialize`, as a client from before the modern era would. + +Once modern, a handful of calls change shape under the same API: `setLoggingLevel()` rides on +every following request instead of sending the removed `logging/setLevel`, `ping()` becomes a +`server/discover`, and `sendRootsListChanged()` sends nothing, since roots are gone. Sampling +and roots are handshake-era features: a server asking for them on a modern connection fails the +call instead. + +What being modern changes underneath: + +- **No handshake.** `connect()` sends no `initialize`; the probe's `DiscoverResult` is what + fills in the server's identity and instructions. - **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. @@ -143,8 +178,8 @@ What that changes underneath: 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. +them on. Everything else — the probe, the envelope, the skipped handshake, the round-trip +loop — applies to both. See [`examples/client/stateless_lifecycle_client.php`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/client/stateless_lifecycle_client.php) @@ -152,7 +187,9 @@ for a runnable version, described in [Examples](examples.md#modern-era-client). ## What was removed -Answered with `404` and `-32601` by a modern server: +Answered with `404` and `-32601` by a modern server — except a bare `initialize`, which is how +a client from before the modern era opens, and is refused with `-32022` naming the revisions +the server does speak: - `initialize`, `notifications/initialized` - `ping` diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md index 9c6cb28f..2109c2cf 100644 --- a/docs/run/protocol-eras.md +++ b/docs/run/protocol-eras.md @@ -100,6 +100,31 @@ Both legs come from **one** builder configuration — one registry, one set of h instances, one session manager. A tool registered once is reachable from both, and a change made through one is visible to the other. +## Over stdio + +`StdioTransport` serves both eras too, but stdio carries one client per process, so the era is +settled once rather than per request: the client's **first request** decides it, by the same +body-primary rule as above. + +| Opening request | The connection | +| --- | --- | +| carries a modern revision in `params._meta` | is served by the modern dispatcher from then on | +| anything else — `initialize` above all | runs the handshake, as before `2026-07-28` | + +A request from the other era after that is refused rather than served: `initialize` on a modern +connection gets `-32022` naming the modern revisions, an enveloped request on a handshake one +gets `-32600`. That is what a client that probed, gave up waiting and fell back to the handshake +needs to learn that the server settled on the modern era after all. + +On a modern connection everything shares the one channel. A request's progress and log +messages are written as its handler emits them, ahead of its result; a `subscriptions/listen` +stays open alongside other requests, each of its messages tagged with the subscription id; and +`notifications/cancelled` is how a client stops one, since there is no per-request stream to +close. stdio has no headers, so none of the `Mcp-*` header rules apply. + +A server built `withoutModernEra()` refuses a modern opening with `-32022` naming the handshake +revisions, and still accepts the handshake that follows. + ## Middleware The [default middleware stack](http.md#default-middleware) runs at the edge, before the diff --git a/examples/server/bootstrap.php b/examples/server/bootstrap.php index 99fcfdaf..b2e5af38 100644 --- a/examples/server/bootstrap.php +++ b/examples/server/bootstrap.php @@ -30,10 +30,10 @@ /** * The transport every example runs on. * - * Over HTTP that is one endpoint serving both protocol eras: `StreamableHttpTransport` - * classifies each request and routes it to the lifecycle it belongs to, so every - * example here answers an `initialize` handshake and a 2026-07-28 envelope alike. - * Over stdio there is no such choice to make — that binding carries the handshake era. + * Either way it serves both protocol eras: over HTTP, `StreamableHttpTransport` + * classifies each request and routes it to the lifecycle it belongs to; over stdio, + * `StdioTransport` settles the era on the client's first request. So every example + * here answers an `initialize` handshake and a 2026-07-28 envelope alike. * * @return TransportInterface|TransportInterface */ diff --git a/src/Client.php b/src/Client.php index 532f60a2..3f134241 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,6 +28,7 @@ use Mcp\Schema\PromptReference; use Mcp\Schema\Request\CallToolRequest; use Mcp\Schema\Request\CompletionCompleteRequest; +use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\GetPromptRequest; use Mcp\Schema\Request\ListPromptsRequest; use Mcp\Schema\Request\ListResourcesRequest; @@ -157,11 +158,14 @@ public function getProtocolVersion(): ?ProtocolVersion } /** - * Send a ping request to the server. + * Check that the server is reachable and answering. + * + * A `ping` on the handshake era. The modern era removed it, so there the + * check is a `server/discover`, which every modern server answers. */ public function ping(): void { - $request = new PingRequest(); + $request = $this->protocol->isModern() ? new DiscoverRequest() : new PingRequest(); $this->sendRequest($request); } @@ -299,9 +303,23 @@ public function complete(PromptReference|ResourceReference $ref, array $argument /** * Set the minimum logging level for server log messages. + * + * On the handshake era this is a `logging/setLevel` request. The modern + * era removed it: there the level rides on every request that follows, + * and until one is set the server sends no log messages at all. */ public function setLoggingLevel(LoggingLevel $level): void { + if (!$this->isConnected()) { + throw new ConnectionException('Client is not connected. Call connect() first.'); + } + + if ($this->protocol->isModern()) { + $this->protocol->setLogLevel($level); + + return; + } + $request = new SetLogLevelRequest($level); $this->sendRequest($request); @@ -325,6 +343,14 @@ public function sendRootsListChanged(): void throw new ConnectionException('Client is not connected. Call connect() first.'); } + // The modern era removed roots, so a server on it has nothing to + // refresh — and nothing to tell. + if ($this->protocol->isModern()) { + $this->logger->debug('Not sending "notifications/roots/list_changed": the connection is on the modern era, which removed roots.'); + + return; + } + $this->protocol->sendNotification(new RootsListChangedNotification()); } diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 098b6e6a..81d496f8 100644 --- a/src/Client/Builder.php +++ b/src/Client/Builder.php @@ -35,6 +35,7 @@ final class Builder private ?string $description = null; private ?string $title = null; private ?ProtocolVersion $protocolVersion = null; + private ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25; private ?ClientCapabilities $capabilities = null; /** @var array> */ @@ -66,7 +67,12 @@ public function setClientInfo(string $name, string $version, ?string $descriptio } /** - * Set the protocol version to use. + * Set the protocol version the client prefers. + * + * Defaults to 2026-07-28. A modern revision is probed for with + * `server/discover` and falls back to the `initialize` handshake when the + * server turns out not to speak it, see {@see self::setFallbackProtocolVersion()}; + * a handshake revision skips the probe and opens with the handshake. */ public function setProtocolVersion(ProtocolVersion $protocolVersion): self { @@ -75,6 +81,20 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self return $this; } + /** + * Set the handshake revision a modern client falls back to when the server + * does not speak the modern era. Defaults to 2025-11-25. + * + * Null makes the client modern-only: a server without the modern era then + * fails the connection instead. + */ + public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self + { + $this->fallbackProtocolVersion = $protocolVersion; + + return $this; + } + /** * Set client capabilities. */ @@ -198,10 +218,11 @@ public function build(): Client $config = new Configuration( clientInfo: $clientInfo, capabilities: $capabilities, - protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2025_11_25, + protocolVersion: $this->protocolVersion ?? ProtocolVersion::V2026_07_28, initTimeout: $this->initTimeout, requestTimeout: $this->requestTimeout, maxRetries: $this->maxRetries, + fallbackProtocolVersion: $this->fallbackProtocolVersion, ); $protocol = new Protocol( diff --git a/src/Client/Configuration.php b/src/Client/Configuration.php index f0ed6f73..a53e1378 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -23,14 +23,27 @@ */ class Configuration { + /** + * @param ProtocolVersion $protocolVersion the revision the client prefers. A modern one is + * probed for with `server/discover` before anything else + * @param ProtocolVersion|null $fallbackProtocolVersion the handshake revision offered through `initialize` when + * a probe shows the server does not speak the modern era; + * null makes a modern client modern-only. Unused when + * $protocolVersion is a handshake revision already + */ public function __construct( public readonly Implementation $clientInfo, public readonly ClientCapabilities $capabilities, - public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2025_11_25, + public readonly ProtocolVersion $protocolVersion = ProtocolVersion::V2026_07_28, public readonly int $initTimeout = 30, public readonly int $requestTimeout = 120, public readonly int $maxRetries = 3, + public readonly ?ProtocolVersion $fallbackProtocolVersion = ProtocolVersion::V2025_11_25, ) { + if (null !== $fallbackProtocolVersion && $fallbackProtocolVersion->isModern()) { + throw new InvalidArgumentException(\sprintf('The fallback protocol version must be one reached through the "initialize" handshake, got "%s".', $fallbackProtocolVersion->value)); + } + if ($initTimeout < 1) { throw new InvalidArgumentException(\sprintf('The initialization timeout must be a positive number of seconds, got %d.', $initTimeout)); } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 281aedc8..a252d58e 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -22,8 +22,8 @@ use Mcp\Client\Stateless\ToolCatalog; use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\TransportInterface; -use Mcp\Exception\ConnectionException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -75,6 +75,9 @@ class Protocol private ToolCatalog $tools; + /** What a modern-era connection stamps on every request, see {@see self::setLogLevel()}. */ + private ?LoggingLevel $logLevel = null; + private readonly InputRequestResolver $inputRequests; /** @@ -134,15 +137,6 @@ public function connect(TransportInterface $transport, Configuration $config): v // or another — has said nothing yet. $this->tools = new ToolCatalog($this->logger); - if ($config->protocolVersion->isModern()) { - $this->envelope = new RequestEnvelope( - $config->protocolVersion, - $config->capabilities, - $config->clientInfo, - ); - $this->headers = new HeaderFactory($this->tools); - } - $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); @@ -174,11 +168,13 @@ private function headersFor(string $payload): array } /** - * Ready the connection for use. + * Ready the connection for use, settling which protocol era it speaks. * - * Up to 2025-11-25 that means the `initialize` handshake: offer a revision, - * take the server's answer, confirm with `notifications/initialized`. From - * 2026-07-28 there is no handshake at all — see {@see self::discover()}. + * A handshake revision opens with `initialize`, as every revision up to + * 2025-11-25 does. A modern one has no handshake: the client probes with + * `server/discover` instead, and falls back to the handshake when the + * answer shows the server does not speak the modern era — see + * {@see self::negotiate()}. * * @param Configuration $config The client configuration * @@ -186,12 +182,215 @@ private function headersFor(string $payload): array */ public function initialize(Configuration $config): Response|Error { - if (null !== $this->envelope) { - return $this->discover($config); + // Settled anew on every attempt: a reconnect may reach another server. + $this->envelope = null; + $this->headers = null; + + if (!$config->protocolVersion->isModern()) { + return $this->handshake($config->protocolVersion, $config); + } + + return $this->negotiate($config); + } + + /** + * Probe for the modern era, falling back to the handshake when the server + * does not speak it. + * + * Only positive evidence keeps the connection modern: a `DiscoverResult` + * naming a modern revision this client speaks, or a refusal naming one + * (which {@see self::request()} has already retried with). Any other error, + * silence until the timeout, or a server advertising nothing but handshake + * revisions identifies a server from before the modern era. The fallback is + * deliberately not keyed to any one error code: such servers answer an + * unknown request before `initialize` however they like, or not at all. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/stdio#backward-compatibility + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#backward-compatibility + * + * @return Response>|Error + */ + private function negotiate(Configuration $config): Response|Error + { + $version = $config->protocolVersion; + + // Twice at most: a probe that timed out here may still have reached a + // slow-starting server and settled it on the modern era, which the + // fallback handshake then hears about as a refusal naming that era. + for ($attempt = 0; $attempt < 2; ++$attempt) { + $this->enterModernEra($version, $config); + + $probe = $this->request(new DiscoverRequest(), $config->initTimeout); + $adopted = $this->adopt($probe, $config); + + if ($adopted instanceof Response || $adopted instanceof Error) { + return $adopted; + } + + if (null === $config->fallbackProtocolVersion) { + return Error::forInvalidRequest(\sprintf( + 'Server does not speak protocol version %s and this client is configured without a handshake fallback: %s', + $config->protocolVersion->value, + self::describe($probe), + )); + } + + $this->logger->info('Server does not speak the modern era; falling back to the "initialize" handshake.', [ + 'probe' => self::describe($probe), + 'offering' => $config->fallbackProtocolVersion->value, + ]); + + $this->envelope = null; + $this->headers = null; + + $handshake = $this->handshake($config->fallbackProtocolVersion, $config); + + if ($handshake instanceof Error && 0 === $attempt && null !== $modern = self::mutualModern($handshake)) { + $this->logger->info('Server settled on the modern era after all; probing again.', ['version' => $modern->value]); + $version = $modern; + + continue; + } + + return $handshake; + } + + return Error::forInternalError('Protocol negotiation did not settle on a revision.'); + } + + private function enterModernEra(ProtocolVersion $version, Configuration $config): void + { + $this->envelope = new RequestEnvelope($version, $config->capabilities, $config->clientInfo, $this->logLevel); + $this->headers = new HeaderFactory($this->tools); + } + + /** + * Whether the connection settled on the modern era, where every request + * carries its own metadata. + */ + public function isModern(): bool + { + return null !== $this->envelope; + } + + /** + * Ask for the server's log messages from $level up on every request that + * follows — the modern era's stand-in for `logging/setLevel`. + */ + public function setLogLevel(LoggingLevel $level): void + { + $this->logLevel = $level; + $this->envelope = $this->envelope?->withLogLevel($level); + } + + /** + * Reads a probe's answer: the connection, now modern; an error ending the + * attempt; or null when the server is not a modern one. + * + * @param Response>|Error $probe + * + * @return Response>|Error|null + */ + private function adopt(Response|Error $probe, Configuration $config): Response|Error|null + { + \assert(null !== $this->envelope); + + if ($probe instanceof Error) { + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $probe->code) { + return null; + } + + // A refusal naming a modern revision was already retried with it, + // so reaching here means it named none this client speaks. A server + // naming handshake revisions is still reachable through them; one + // naming neither is a modern server this client cannot talk to. + $supported = self::supportedVersions($probe); + + foreach ($supported as $version) { + if (!$version->isModern()) { + return null; + } + } + + $named = \is_array($probe->data['supported'] ?? null) ? array_filter($probe->data['supported'], is_string(...)) : []; + + return Error::forInvalidRequest(\sprintf('Server supports none of the protocol versions this client speaks (it advertises %s).', [] === $named ? 'none' : implode(', ', $named)), $probe->id); + } + + $advertised = $probe->result['supportedVersions'] ?? null; + + if (!\is_array($advertised)) { + // Not a DiscoverResult, so not evidence of the modern era. A client + // with nowhere else to go keeps the revision it was configured with. + if (null !== $config->fallbackProtocolVersion) { + return null; + } + + $this->readDiscovery($probe->result); + + return $this->settleModern($probe); + } + + $current = $this->envelope->protocolVersion(); + $chosen = null; + + foreach (ProtocolVersion::modernVersions() as $version) { + if (\in_array($version->value, $advertised, true)) { + $chosen = $version; + } + } + + if (\in_array($current->value, $advertised, true)) { + $chosen = $current; + } + + if (null === $chosen) { + // It speaks discover but advertises only handshake revisions: a + // statement of where it can be reached, not an incompatibility. + return null; } - $offered = $config->protocolVersion; + if ($chosen !== $current) { + $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ + 'configured' => $current->value, + 'using' => $chosen->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($chosen); + } + + $this->readDiscovery($probe->result); + + return $this->settleModern($probe); + } + + /** + * @param Response> $probe + * + * @return Response> + */ + private function settleModern(Response $probe): Response + { + \assert(null !== $this->envelope); + + $this->state->setProtocolVersion($this->envelope->protocolVersion()); + $this->state->setInitialized(true); + + $this->logger->info('Connection settled on the modern era', [ + 'protocolVersion' => $this->envelope->protocolVersion()->value, + ]); + + return $probe; + } + /** + * The `initialize` handshake: offer a revision, take the server's answer, + * confirm with `notifications/initialized`. + * + * @return Response>|Error + */ + private function handshake(ProtocolVersion $offered, Configuration $config): Response|Error + { $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -200,6 +399,16 @@ public function initialize(Configuration $config): Response|Error $response = $this->request($request, $config->initTimeout); + if ($response instanceof Error && Error::UNSUPPORTED_PROTOCOL_VERSION === $response->code) { + $named = \is_array($response->data['supported'] ?? null) ? array_filter($response->data['supported'], is_string(...)) : []; + + return new Error($response->id, $response->code, \sprintf( + 'Server does not speak protocol version %s; it supports %s.', + $offered->value, + [] === $named ? 'none it named' : implode(', ', $named), + ), $response->data); + } + if ($response instanceof Response) { $initResult = InitializeResult::fromArray($response->result); @@ -238,40 +447,7 @@ public function initialize(Configuration $config): Response|Error } /** - * Stand in for the handshake in the modern era. - * - * There is nothing to negotiate: the revision travels on every request, so - * the connection is usable the moment the transport is. `server/discover` - * is only asked because the facade exposes `getServerInfo()`, and a server - * that will not answer it still serves every other method — so a failure - * here is logged and the connection proceeds. - * - * @return Response> - */ - private function discover(Configuration $config): Response - { - $this->state->setProtocolVersion($config->protocolVersion); - $this->state->setInitialized(true); - - $response = $this->request(new DiscoverRequest(), $config->initTimeout); - - if ($response instanceof Error) { - $this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [ - 'code' => $response->code, - 'message' => $response->message, - ]); - - return new Response(0, []); - } - - $this->readDiscovery($response->result); - - return $response; - } - - /** - * Read defensively: `server/discover` is optional, so a server may answer - * with something that is not a DiscoverResult at all, and none of it is + * Read defensively: none of a `DiscoverResult` beyond its revisions is * load-bearing for the requests that follow. * * @param array $result @@ -295,56 +471,52 @@ private function readDiscovery(array $result): void if (\is_string($result['instructions'] ?? null)) { $this->state->setInstructions($result['instructions']); } - - $this->reconcileVersion($result['supportedVersions'] ?? null); - - $this->logger->info('Discovery complete', [ - 'supportedVersions' => $result['supportedVersions'] ?? null, - ]); } /** - * Move to a revision the server actually speaks, if it said which. - * - * `server/discover` reports rather than negotiates, so a client that asked - * for something the server does not list learns it here — and learning it - * now is far better than a stream of refusals later. A server that stays - * silent about its versions is left alone; the method is optional and - * saying nothing is not the same as saying no. + * The newest modern revision a refusal names that this client speaks. */ - private function reconcileVersion(mixed $supportedVersions): void + private static function mutualModern(Error $error): ?ProtocolVersion { - if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) { - return; - } - - $current = $this->envelope->protocolVersion(); - - if (\in_array($current->value, $supportedVersions, true)) { - return; + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code) { + return null; } - foreach ($supportedVersions as $candidate) { - $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + $mutual = null; - if (null === $version || !$version->isModern()) { - continue; + foreach (self::supportedVersions($error) as $version) { + if ($version->isModern() && (null === $mutual || $version->isAtLeast($mutual))) { + $mutual = $version; } + } - $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ - 'configured' => $current->value, - 'using' => $version->value, - ]); + return $mutual; + } - $this->envelope = $this->envelope->withProtocolVersion($version); - $this->state->setProtocolVersion($version); + /** + * The revisions a `-32022` refusal names, as far as this SDK knows them. + * + * @return list + */ + private static function supportedVersions(Error $error): array + { + $data = \is_array($error->data) ? $error->data : []; + $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; - return; - } + return array_values(array_filter(array_map( + static fn (mixed $v): ?ProtocolVersion => \is_string($v) ? ProtocolVersion::tryFrom($v) : null, + $supported, + ))); + } - // Everything it offers is handshake era, which this connection cannot - // reach — it has already skipped the handshake. - throw new ConnectionException(\sprintf('Server does not support any modern protocol revision (it advertises %s); the configured "%s" cannot be used against it.', implode(', ', array_map(strval(...), $supportedVersions)), $current->value)); + /** + * @param Response>|Error $probe + */ + private static function describe(Response|Error $probe): string + { + return $probe instanceof Error + ? \sprintf('"server/discover" was answered with error %d (%s)', $probe->code, $probe->message) + : '"server/discover" was answered without a modern revision'; } /** diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php index fcc1cac4..f2ebce2a 100644 --- a/src/Client/Stateless/RequestEnvelope.php +++ b/src/Client/Stateless/RequestEnvelope.php @@ -12,6 +12,7 @@ namespace Mcp\Client\Stateless; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Server\Stateless\RequestMeta; @@ -31,10 +32,15 @@ */ final class RequestEnvelope { + /** + * @param LoggingLevel|null $logLevel the least severe log message the client wants to hear about while a + * request runs; null asks for none, which is what this revision assumes + */ public function __construct( private readonly ProtocolVersion $protocolVersion, private readonly ClientCapabilities $capabilities, private readonly Implementation $clientInfo, + private readonly ?LoggingLevel $logLevel = null, ) { } @@ -45,7 +51,16 @@ public function protocolVersion(): ProtocolVersion public function withProtocolVersion(ProtocolVersion $protocolVersion): self { - return new self($protocolVersion, $this->capabilities, $this->clientInfo); + return new self($protocolVersion, $this->capabilities, $this->clientInfo, $this->logLevel); + } + + /** + * The per-request replacement for `logging/setLevel`, which this revision + * removed: the level now rides on every request instead of being set once. + */ + public function withLogLevel(?LoggingLevel $logLevel): self + { + return new self($this->protocolVersion, $this->capabilities, $this->clientInfo, $logLevel); } /** @@ -71,6 +86,10 @@ public function stamp(array $payload): array RequestMeta::CLIENT_INFO => $this->clientInfo, ]; + if (null !== $this->logLevel) { + $params['_meta'][RequestMeta::LOG_LEVEL] = $this->logLevel->value; + } + $payload['params'] = $params; return $payload; diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index 3c12ea74..627dc418 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -175,6 +175,12 @@ public function send(string $data): void $contentType = strtolower($response->getHeaderLine('Content-Type')); + if ($response->getStatusCode() >= 400) { + $this->handleErrorStatus($data, $response->getStatusCode(), $response->getReasonPhrase(), $response->getBody()->getContents()); + + return; + } + if (str_contains($contentType, 'text/event-stream')) { $this->activeStream = $response->getBody(); $this->sseBuffer = ''; @@ -186,6 +192,41 @@ public function send(string $data): void } } + /** + * Answers a request the server refused at the HTTP level. + * + * A JSON-RPC error carrying the request's id is handled like any other + * answer. Anything else — an empty or non-JSON body, or an error without + * that id, which is how a server from before the modern era + * typically refuses a request it did not expect — is turned into an error + * for that request, so the caller learns of it now rather than at its + * timeout. That is what a probe for the modern era relies on to fall back. + */ + private function handleErrorStatus(string $sent, int $status, string $reason, string $body): void + { + $request = json_decode($sent, true); + $requestId = \is_array($request) ? ($request['id'] ?? null) : null; + $answer = '' === trim($body) ? null : json_decode($body, true); + + if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId) { + $this->handleMessage($body); + + return; + } + + if ((!\is_string($requestId) && !\is_int($requestId)) || null === $this->state) { + $this->logger->warning('Server refused a message', ['status' => $status, 'body' => $body]); + + return; + } + + $error = \is_array($answer['error'] ?? null) && \is_int($answer['error']['code'] ?? null) + ? new Error($requestId, $answer['error']['code'], \is_string($answer['error']['message'] ?? null) ? $answer['error']['message'] : $reason, $answer['error']['data'] ?? null) + : Error::forInvalidRequest(\sprintf('Server answered with HTTP %d%s.', $status, '' !== $reason ? ' '.$reason : ''), $requestId); + + $this->state->storeResponse($requestId, $error->jsonSerialize()); + } + /** * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index f1029619..b4623a7b 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -113,7 +113,12 @@ public function send(string $data): void throw new ConnectionException('Process stdin not available'); } - fwrite($this->stdin, $data."\n"); + // Silenced: a server that has exited is reported as the connection + // failure it is, not as a broken-pipe warning. + if (false === @fwrite($this->stdin, $data."\n")) { + throw new ConnectionException('Could not write to the server process; it is no longer running.'); + } + fflush($this->stdin); $this->logger->debug('Sent message to server', ['data' => $data]); @@ -258,6 +263,26 @@ private function processInput(): void $this->handleMessage($trimmed); } } + + // Everything it wrote has been read, and it will write nothing more: + // whatever is still pending can only time out, so fail it now. Failed + // as answers rather than thrown, so each waiting fiber unwinds and + // clears its request instead of leaving it to time out a later one. + if (feof($this->stdout)) { + $this->failPending('The server process closed its output; it is no longer running.'); + } + } + + private function failPending(string $reason): void + { + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $this->state->storeResponse($requestId, Error::forInternalError($reason, $requestId)->jsonSerialize()); + } } /** @@ -276,15 +301,7 @@ private function abortInput(string $reason): void 'max_buffer_size' => $this->maxBufferSize, ]); - if (null === $this->state) { - return; - } - - foreach ($this->state->getPendingRequests() as $pending) { - $requestId = $pending['request_id']; - $error = Error::forInternalError('stdio input aborted: '.$reason, $requestId); - $this->state->storeResponse($requestId, $error->jsonSerialize()); - } + $this->failPending('stdio input aborted: '.$reason); } private function processFiber(): void diff --git a/src/Schema/JsonRpc/MessageInterface.php b/src/Schema/JsonRpc/MessageInterface.php index 9427eaac..9de53118 100644 --- a/src/Schema/JsonRpc/MessageInterface.php +++ b/src/Schema/JsonRpc/MessageInterface.php @@ -21,5 +21,5 @@ interface MessageInterface extends \JsonSerializable { public const JSONRPC_VERSION = '2.0'; - public const PROTOCOL_VERSION = ProtocolVersion::V2025_11_25; + public const PROTOCOL_VERSION = ProtocolVersion::V2026_07_28; } diff --git a/src/Schema/Result/InitializeResult.php b/src/Schema/Result/InitializeResult.php index e80c28ca..4a81c0c5 100644 --- a/src/Schema/Result/InitializeResult.php +++ b/src/Schema/Result/InitializeResult.php @@ -14,7 +14,6 @@ use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; -use Mcp\Schema\JsonRpc\MessageInterface; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\ServerCapabilities; @@ -91,7 +90,7 @@ public static function fromArray(array $data): self */ public function jsonSerialize(): array { - $protocolVersion = $this->protocolVersion ?? MessageInterface::PROTOCOL_VERSION; + $protocolVersion = $this->protocolVersion ?? ProtocolVersion::latestHandshake(); $data = [ 'protocolVersion' => $protocolVersion->value, 'capabilities' => $this->capabilities, diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index f460c3c7..03061bb8 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -698,7 +698,16 @@ private function resolveSession(TransportInterface $transport, ?Uuid $sessionId, } if (!$sessionId) { - $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.'); + // Echoes the request's id: a client probing for the modern era sends + // `server/discover` before any handshake, and an error it cannot + // correlate would leave it waiting out its timeout to fall back. + $id = match (true) { + 1 !== \count($messages) => null, + $messages[0] instanceof Request => $messages[0]->getId(), + $messages[0] instanceof InvalidInputMessageException => $messages[0]->getRequestId(), + default => null, + }; + $error = Error::forInvalidRequest('A valid session id is REQUIRED for non-initialize requests.', $id); $this->sendResponse($transport, $error, null, ['status_code' => 400]); return null; diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 4c4c016e..9e3dfe63 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -152,6 +152,28 @@ private function requiresTransportHeaders(): bool * @param array $headers request headers, case-insensitively matched */ public function handle(string $body, array $headers = []): StatelessResult + { + return $this->answer($body, $headers, true); + } + + /** + * Answers one JSON-RPC message read off a transport without a header layer. + * + * stdio carries the request metadata inline (see the stdio binding's + * "Request Metadata"), so there is no header to require or cross-check, and + * its one channel always carries a request's notifications. A long-lived + * stream is left for the caller to pace, since it interleaves it with + * everything else arriving on that channel. + */ + public function handleInline(string $message): StatelessResult + { + return $this->answer($message, [], false); + } + + /** + * @param array $headers + */ + private function answer(string $body, array $headers, bool $headerLayer): StatelessResult { try { /** @var array|null $decoded */ @@ -201,19 +223,30 @@ public function handle(string $body, array $headers = []): StatelessResult return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400); } + // How a client from before the modern era opens. It has no way to move + // forward to this one, so the refusal is the only thing it can show its + // user: it names the revisions served rather than the envelope missing. + // One stamped with the envelope is a modern client, told further down + // that its revision has no such method. + if ('initialize' === $method && !isset($params['_meta'][RequestMeta::PROTOCOL_VERSION])) { + $offered = $params['protocolVersion'] ?? null; + + return StatelessResult::error(Error::forUnsupportedProtocolVersion(\is_string($offered) ? $offered : '', $this->supportedVersions, $id), 400); + } + try { $meta = RequestMeta::fromParams($params, $headers); } catch (MissingRequestMetaException $e) { return StatelessResult::error(Error::forInvalidParams($e->getMessage(), $id), 400); } - if (null !== $versionError = $this->checkVersion($meta, $headers, $id)) { + if (null !== $versionError = $this->checkVersion($meta, $headers, $id, $headerLayer)) { return $versionError; } // After the version check: a peer on the wrong revision has a more // fundamental problem than headers that disagree with its body. - if (null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) { + if ($headerLayer && null !== $headerError = $this->headerValidator?->validate($method, $params, $headers)) { return StatelessResult::error(Error::forHeaderMismatch($headerError, $id), 400); } @@ -222,7 +255,7 @@ public function handle(string $body, array $headers = []): StatelessResult return $this->encode($method, $id, $this->discover()); } - return $this->listen($params, $id); + return $this->listen($params, $id, $headerLayer); } if (\in_array($method, self::REMOVED_METHODS, true)) { @@ -232,7 +265,7 @@ public function handle(string $body, array $headers = []): StatelessResult ); } - return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers)); + return $this->dispatch($method, $decoded, $meta, $id, !$headerLayer || self::acceptsEventStream($headers)); } /** @@ -265,14 +298,14 @@ private function acknowledge(string $method): StatelessResult * * @param array $headers */ - private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id): ?StatelessResult + private function checkVersion(RequestMeta $meta, array $headers, string|int|null $id, bool $headerLayer = true): ?StatelessResult { $headerVersion = $this->header($headers, 'MCP-Protocol-Version'); // REQUIRED on every POST. The 2025-03-26 fallback for a header-less // request exists only for servers choosing to serve pre-2025-06-18 // clients, which a modern-only endpoint is not. - if (null === $headerVersion && $this->requiresTransportHeaders()) { + if (null === $headerVersion && $headerLayer && $this->requiresTransportHeaders()) { return StatelessResult::error( Error::forHeaderMismatch( \sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion), @@ -305,8 +338,10 @@ private function checkVersion(RequestMeta $meta, array $headers, string|int|null * JSON-RPC id of this request, so there is none to mint. * * @param array|null $params + * @param bool $paced whether the stream sleeps between polls itself, or its consumer + * paces it by how often it asks for the next frame */ - private function listen(?array $params, string|int $id): StatelessResult + private function listen(?array $params, string|int $id, bool $paced = true): StatelessResult { $notifications = \is_array($params['notifications'] ?? null) ? $params['notifications'] : null; $agreed = NotificationFilter::fromParams($notifications)->intersect($this->configuration->capabilities); @@ -315,7 +350,7 @@ private function listen(?array $params, string|int $id): StatelessResult $bus = $this->notificationBus; $codec = $this->codec; - return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec): \Generator { + return StatelessResult::stream(static function () use ($agreed, $id, $lifetime, $bus, $codec, $paced): \Generator { // MUST be the first message carrying this subscription's id, and // MUST precede any notification on it. yield [ @@ -350,6 +385,10 @@ private function listen(?array $params, string|int $id): StatelessResult yield null; + if (!$paced) { + continue; + } + if (connection_aborted()) { return; } diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 565f7da7..9935b69e 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -12,27 +12,63 @@ namespace Mcp\Server\Transport; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Server\Stateless\StatelessProtocol; use Mcp\Server\Transport\Stdio\RunnerControl; use Mcp\Server\Transport\Stdio\RunnerControlInterface; use Mcp\Server\Transport\Stdio\RunnerState; +use Mcp\Server\Wire\InboundClassifier; use Psr\Log\LoggerInterface; /** + * Serves one client over the standard streams, in whichever protocol era it + * opens with. + * + * The client's first request decides, once, for the life of the process: a + * request carrying the 2026-07-28 per-request `_meta` envelope opens a modern + * connection, anything else — the `initialize` handshake above all — a + * handshake-era one. A later request from the other era is refused rather than + * served, so a client that probed with `server/discover`, timed out and fell + * back to the handshake learns the connection is already modern. + * + * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/versioning#backward-compatibility-with-initialization-based-versions + * * @extends BaseTransport * * @author Kyrian Obikwelu */ -class StdioTransport extends BaseTransport +class StdioTransport extends BaseTransport implements StatelessAwareTransportInterface { /** * Default cap on the bytes read for a single input line. */ public const DEFAULT_MAX_LINE_BYTES = 4 * 1024 * 1024; + private const CANCELLED_NOTIFICATION = 'notifications/cancelled'; + /** Whether the current over-length line is still being drained and discarded. */ private bool $discardingLine = false; + private ?StatelessProtocol $stateless = null; + + private readonly InboundClassifier $classifier; + + /** Null until the client's first request settles the era. */ + private ?bool $modern = null; + + /** + * Modern-era answers still being written, by the id of the request they + * answer: a `subscriptions/listen` for as long as it lasts, and a request + * whose handler streams notifications until its result is in. + * + * Keyed by {@see self::streamKey()}, since PHP would fold the ids `"5"` + * and `5` into one key, and JSON-RPC tells them apart. + * + * @var array> + */ + private array $streams = []; + /** * @param resource $input * @param resource $output @@ -50,11 +86,18 @@ public function __construct( ) { parent::__construct($logger); + $this->classifier = new InboundClassifier(); + if ($maxLineBytes < 1) { throw new InvalidArgumentException(\sprintf('The maximum line size must be a positive number of bytes, got %d.', $maxLineBytes)); } } + public function connectStateless(StatelessProtocol $protocol): void + { + $this->stateless = $protocol; + } + public function send(string $data, array $context): void { if (isset($context['session_id'])) { @@ -72,6 +115,7 @@ public function listen(): int while (!feof($this->input) && RunnerState::RUNNING === $this->runnerControl->getState()) { $this->processInput(); $this->processFiber(); + $this->processStreams(); $this->flushOutgoingMessages(); } @@ -118,8 +162,137 @@ protected function processInput(): void $trimmedLine = trim($line); if (!empty($trimmedLine)) { - $this->handleMessage($trimmedLine, $this->sessionId); + $this->route($trimmedLine); + } + } + + /** + * Hands one message to the era it belongs to, settling the connection's + * era on the first request. + */ + private function route(string $message): void + { + $classification = $this->classifier->classify('POST', $message); + + if ($classification->isRejected()) { + \assert(null !== $classification->error); + $this->writeError($classification->error); + + return; + } + + $decoded = json_decode($message, true); + $request = \is_array($decoded) && !array_is_list($decoded) && isset($decoded['id']) ? $decoded : null; + + if (null === $this->modern && null !== $request) { + if ($classification->modern && null === $this->stateless) { + // Served nothing but the handshake: say which revisions that + // is, the way the HTTP entry does, and leave the era open. + $this->writeError(Error::forUnsupportedProtocolVersion((string) $classification->claimedVersion, ProtocolVersion::handshakeVersions(), $request['id'])); + + return; + } + + $this->modern = $classification->modern; + + $this->logger->info('StdioTransport settled the connection era.', [ + 'era' => $this->modern ? 'modern' : 'handshake', + 'opened_with' => $request['method'] ?? null, + ]); + } + + if (true === $this->modern) { + $this->routeModern($message, $decoded, $request); + + return; + } + + if (false === $this->modern && $classification->modern && null !== $request) { + $this->writeError(Error::forInvalidRequest('This connection opened with the "initialize" handshake; a request carrying a per-request protocol version cannot follow it.', $request['id'])); + + return; } + + $this->handleMessage($message, $this->sessionId); + } + + /** + * @param mixed $decoded the message, decoded + * @param array|null $request the message when it is a request + */ + private function routeModern(string $message, mixed $decoded, ?array $request): void + { + \assert(null !== $this->stateless); + + // stdio has no per-request stream to close, so this notification is + // how a client stops one; nothing more may be sent for it. + if (\is_array($decoded) && self::CANCELLED_NOTIFICATION === ($decoded['method'] ?? null) && !isset($decoded['id'])) { + $requestId = $decoded['params']['requestId'] ?? null; + + if ((\is_string($requestId) || \is_int($requestId)) && isset($this->streams[$key = self::streamKey($requestId)])) { + unset($this->streams[$key]); + $this->logger->debug('StdioTransport dropped a cancelled request.', ['request_id' => $requestId]); + } + + return; + } + + $result = $this->stateless->handleInline($message); + + if ($result->isEmpty()) { + return; + } + + if ($result->isStream()) { + \assert(null !== $result->frames && null !== $request); + $this->streams[self::streamKey($request['id'])] = ($result->frames)(); + + return; + } + + $this->writeLine($result->toJson()); + } + + /** + * Writes what each open stream has ready: every frame up to its next idle + * poll, so a listen stream polls once per tick and a handler's + * notifications go out as it emits them. + */ + private function processStreams(): void + { + foreach ($this->streams as $id => $frames) { + try { + while ($frames->valid()) { + $frame = $frames->current(); + $frames->next(); + + if (null === $frame) { + break; + } + + $this->writeLine(json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)); + } + } catch (\Throwable $e) { + $this->logger->error('StdioTransport ended a stream that failed.', ['stream' => $id, 'exception' => $e]); + unset($this->streams[$id]); + + continue; + } + + if (!$frames->valid()) { + unset($this->streams[$id]); + } + } + } + + private static function streamKey(string|int $id): string + { + return (\is_int($id) ? 'i:' : 's:').$id; + } + + private function writeError(Error $error): void + { + $this->writeLine(json_encode($error, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)); } private function processFiber(): void diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php index dc168479..8448e1a8 100644 --- a/tests/Integration/ElicitationTest.php +++ b/tests/Integration/ElicitationTest.php @@ -14,9 +14,11 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\ElicitationCallbackInterface; use Mcp\Client\Handler\Request\ElicitationRequestHandler; +use Mcp\Exception\RuntimeException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\ElicitResult; use PHPUnit\Framework\Attributes\TestDox; @@ -75,10 +77,14 @@ public function testCapabilityIsVisibleToTheServer(): void public function testAdvertisedCapabilityWithoutHandler(): void { // The client answers "method not found", which the gateway raises inside - // the tool as a ClientException rather than leaving it waiting. + // the tool as a ClientException rather than leaving it waiting. That is + // a handshake-era exchange: the modern era has no way to answer an ask + // with an error, see below. $client = $this->connect( 'elicitation', - $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), + $this->clientBuilder() + ->setProtocolVersion(ProtocolVersion::V2025_11_25) + ->setCapabilities(new ClientCapabilities(elicitation: true)), ); $result = $client->callTool('ask_name'); @@ -87,6 +93,22 @@ public function testAdvertisedCapabilityWithoutHandler(): void $this->assertSame('Client does not handle "elicitation/create" requests.', $result->content[0]->text); } + #[TestDox('on the modern era, an ask the client advertised but cannot answer fails the call on the client')] + public function testAdvertisedCapabilityWithoutHandlerOnTheModernEra(): void + { + $client = $this->connect( + 'elicitation', + $this->clientBuilder()->setCapabilities(new ClientCapabilities(elicitation: true)), + ); + + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Client does not handle "elicitation/create" requests.'); + + $client->callTool('ask_name'); + } + private function clientAnswering(ElicitResult $answer): ClientBuilder { $callback = new class($answer) implements ElicitationCallbackInterface { diff --git a/tests/Integration/Fixture/handshake.php b/tests/Integration/Fixture/handshake.php index 776f4513..0760289b 100644 --- a/tests/Integration/Fixture/handshake.php +++ b/tests/Integration/Fixture/handshake.php @@ -13,6 +13,7 @@ * Server for {@see \Mcp\Tests\Integration\HandshakeTest}. * * The test pins the revision through the environment; unset negotiates freely. + * MCP_INTEGRATION_HANDSHAKE_ONLY makes it a server from before the modern era. */ use Mcp\Schema\Enum\ProtocolVersion; @@ -29,4 +30,8 @@ $builder->setProtocolVersion(ProtocolVersion::from($pinned)); } +if ('' !== (string) getenv('MCP_INTEGRATION_HANDSHAKE_ONLY')) { + $builder->withoutModernEra(); +} + $builder->build()->run(new StdioTransport()); diff --git a/tests/Integration/Fixture/http.php b/tests/Integration/Fixture/http.php new file mode 100644 index 00000000..6b9d3174 --- /dev/null +++ b/tests/Integration/Fixture/http.php @@ -0,0 +1,46 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.') + // Nothing survives between requests under `php -S`, so the handshake era + // keeps its sessions on disk. + ->setSession(new FileSessionStore((string) getenv('MCP_INTEGRATION_SESSIONS'))) + ->addTool(static fn (string $text): string => $text, name: 'echo', description: 'Echoes the text back.'); + +if ('' !== (string) getenv('MCP_INTEGRATION_HANDSHAKE_ONLY')) { + $builder->withoutModernEra(); +} + +$request = (new Psr17Factory())->createServerRequestFromGlobals(); + +$response = '' !== (string) getenv('MCP_INTEGRATION_MODERN_ONLY') + ? (new StatelessHttpTransport($builder->buildStateless()))->handle($request) + : $builder->build()->run(new StreamableHttpTransport($request)); + +(new SapiEmitter())->emit($response); diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5d213209..87991eb8 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -11,6 +11,7 @@ namespace Mcp\Tests\Integration; +use Mcp\Exception\ConnectionException; use Mcp\Schema\Enum\ProtocolVersion; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -24,30 +25,30 @@ final class HandshakeTest extends IntegrationTestCase { #[TestDox('client and server agree on a revision')] #[DataProvider('provideNegotiations')] - public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?ProtocolVersion $serverVersion, ProtocolVersion $expected): void + public function testNegotiatedVersion(?ProtocolVersion $clientVersion, ?ProtocolVersion $serverVersion, ProtocolVersion $expected, bool $handshakeOnly = false, ?ProtocolVersion $fallback = null): void { $client = $this->clientBuilder(); + if (null !== $clientVersion) { $client->setProtocolVersion($clientVersion); } - $connected = $this->connect( - 'handshake', - $client, - null !== $serverVersion ? ['MCP_INTEGRATION_PROTOCOL_VERSION' => $serverVersion->value] : [], - ); + if (null !== $fallback) { + $client->setFallbackProtocolVersion($fallback); + } + + $connected = $this->connect('handshake', $client, self::environment($serverVersion, $handshakeOnly)); $this->assertSame($expected, $connected->getProtocolVersion()); } /** - * @return iterable + * @return iterable */ public static function provideNegotiations(): iterable { - $latest = ProtocolVersion::latestHandshake(); - - yield 'both unconfigured' => [null, null, $latest]; + // Both ends speak both eras, so they settle on the modern one. + yield 'both unconfigured' => [null, null, ProtocolVersion::V2026_07_28]; // Whichever end of the supported range it sits at. foreach (ProtocolVersion::handshakeVersions() as $version) { @@ -59,29 +60,88 @@ public static function provideNegotiations(): iterable yield 'server pins a newer revision' => [ProtocolVersion::V2024_11_05, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; yield 'both pin the same revision' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; - // A modern client does not negotiate at all: it skips the handshake and - // states its revision on every request, so what it was configured with - // is what it reports. This server never answers `server/discover`, so - // there is nothing to reconcile against either. yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2026_07_28]; yield 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28]; // The server end still falls back: a handshake-era client offered a // revision, and `initialize` cannot answer with a modern one. yield 'server configured modern' => [ProtocolVersion::V2025_06_18, ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18]; + + // A server from before the modern era refuses the probe, and the + // client takes the handshake instead. + yield 'server without the modern era' => [null, null, ProtocolVersion::V2025_11_25, true]; + yield 'server without the modern era, client falling back further' => [null, null, ProtocolVersion::V2025_06_18, true, ProtocolVersion::V2025_06_18]; + yield 'server without the modern era pinning a revision' => [null, ProtocolVersion::V2025_03_26, ProtocolVersion::V2025_03_26, true]; } - #[TestDox('the handshake carries the server identity to the client')] - public function testServerInfoIsExchanged(): void + #[TestDox('falling back to the handshake costs a refusal, not a timeout')] + public function testFallbackDoesNotWaitOutTheProbe(): void { - $client = $this->connect('handshake'); + $started = microtime(true); + $client = $this->connect('handshake', null, self::environment(null, true)); + + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + // Well under the five seconds the probe would otherwise be waited for. + $this->assertLessThan(3, microtime(true) - $started); + } + #[TestDox('a modern-only client refuses a server without the modern era')] + public function testModernOnlyClientRefusesAHandshakeOnlyServer(): void + { + $client = $this->clientBuilder()->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); + + try { + $client->connect($this->transport('handshake', self::environment(null, true))); + $this->fail('A modern-only client must not connect to a server without the modern era.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('without a handshake fallback', $e->getMessage()); + } finally { + $client->disconnect(); + } + } + + #[TestDox('the server identity reaches the client on $_dataName')] + #[DataProvider('provideEras')] + public function testServerInfoIsExchanged(ProtocolVersion $era): void + { + $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion($era)); + + $this->assertSame($era, $client->getProtocolVersion()); $this->assertSame('integration-server', $client->getServerInfo()->name); $this->assertSame('1.0.0', $client->getServerInfo()->version); $this->assertSame('Be brief.', $client->getInstructions()); $this->assertTrue($client->isConnected()); } + #[TestDox('a liveness check works on $_dataName')] + #[DataProvider('provideEras')] + public function testPing(ProtocolVersion $era): void + { + $client = $this->connect('handshake', $this->clientBuilder()->setProtocolVersion($era)); + + $client->ping(); + + $this->assertTrue($client->isConnected()); + } + + /** + * @return array + */ + private static function environment(?ProtocolVersion $serverVersion, bool $handshakeOnly): array + { + $env = []; + + if (null !== $serverVersion) { + $env['MCP_INTEGRATION_PROTOCOL_VERSION'] = $serverVersion->value; + } + + if ($handshakeOnly) { + $env['MCP_INTEGRATION_HANDSHAKE_ONLY'] = '1'; + } + + return $env; + } + #[TestDox('the negotiated revision is unset before the handshake')] public function testProtocolVersionIsNullBeforeConnecting(): void { diff --git a/tests/Integration/HttpNegotiationTest.php b/tests/Integration/HttpNegotiationTest.php new file mode 100644 index 00000000..99349d96 --- /dev/null +++ b/tests/Integration/HttpNegotiationTest.php @@ -0,0 +1,171 @@ +sessions = sys_get_temp_dir().'/mcp-integration-sessions-'.getmypid(); + $this->port = 9600 + (getmypid() % 200); + } + + protected function tearDown(): void + { + $this->server?->stop(); + + foreach (glob($this->sessions.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->sessions); + } + + /** + * @return iterable + */ + public static function provideNegotiations(): iterable + { + yield 'a client and a server speaking both eras' => [self::BOTH, null, ProtocolVersion::V2026_07_28]; + yield 'a handshake-era client and a server speaking both eras' => [self::BOTH, ProtocolVersion::V2025_11_25, ProtocolVersion::V2025_11_25]; + yield 'a client speaking both eras and a server without the modern era' => [self::HANDSHAKE_ONLY, null, ProtocolVersion::V2025_11_25]; + yield 'a handshake-era client and a server without the modern era' => [self::HANDSHAKE_ONLY, ProtocolVersion::V2025_06_18, ProtocolVersion::V2025_06_18]; + yield 'a client speaking both eras and a server with only the modern era' => [self::MODERN_ONLY, null, ProtocolVersion::V2026_07_28]; + } + + #[DataProvider('provideNegotiations')] + #[TestDox('$_dataName settle on a revision and talk')] + public function testNegotiatesAndTalks(string $server, ?ProtocolVersion $clientVersion, ProtocolVersion $expected): void + { + $this->start($server); + + $builder = $this->clientBuilder(); + + if (null !== $clientVersion) { + $builder->setProtocolVersion($clientVersion); + } + + $client = $builder->build(); + $started = microtime(true); + $client->connect($this->transport()); + + // A refused probe is answered at once; only silence would cost the timeout. + $this->assertLessThan(self::TIMEOUT - 1, microtime(true) - $started); + $this->assertSame($expected, $client->getProtocolVersion()); + $this->assertSame('integration-server', $client->getServerInfo()?->name); + $this->assertSame('Be brief.', $client->getInstructions()); + + $result = $client->callTool('echo', ['text' => 'hello']); + + $this->assertInstanceOf(TextContent::class, $result->content[0]); + $this->assertSame('hello', $result->content[0]->text); + + $client->disconnect(); + } + + #[TestDox('a handshake-era client is told which revisions a server with only the modern era speaks')] + public function testHandshakeClientLearnsWhatAModernOnlyServerSpeaks(): void + { + $this->start(self::MODERN_ONLY); + + $client = $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25)->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('it supports 2026-07-28'); + + $client->connect($this->transport()); + } + + #[TestDox('a modern-only client refuses a server without the modern era')] + public function testModernOnlyClientRefusesAHandshakeOnlyServer(): void + { + $this->start(self::HANDSHAKE_ONLY); + + $client = $this->clientBuilder()->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('without a handshake fallback'); + + $client->connect($this->transport()); + } + + private function clientBuilder(): ClientBuilder + { + return Client::builder() + ->setClientInfo('integration-client', '1.0.0') + ->setInitTimeout(self::TIMEOUT) + ->setRequestTimeout(self::TIMEOUT); + } + + private function transport(): HttpTransport + { + return new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)); + } + + private function start(string $server): void + { + @mkdir($this->sessions); + + $this->server = new Process( + [\PHP_BINARY, '-S', \sprintf('127.0.0.1:%d', $this->port), __DIR__.'/Fixture/http.php'], + env: [ + 'MCP_INTEGRATION_SESSIONS' => $this->sessions, + 'MCP_INTEGRATION_HANDSHAKE_ONLY' => self::HANDSHAKE_ONLY === $server ? '1' : '', + 'MCP_INTEGRATION_MODERN_ONLY' => self::MODERN_ONLY === $server ? '1' : '', + ], + ); + $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 fixture server did not start: %s', $this->server->getErrorOutput())); + } +} diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php index 4ad33d42..e057d4b6 100644 --- a/tests/Integration/IntegrationTestCase.php +++ b/tests/Integration/IntegrationTestCase.php @@ -15,6 +15,7 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Transport\StdioTransport; use Mcp\Exception\ConnectionException; +use Mcp\Schema\Enum\ProtocolVersion; use PHPUnit\Framework\TestCase; /** @@ -45,6 +46,18 @@ protected function clientBuilder(): ClientBuilder ->setRequestTimeout(self::TIMEOUT); } + /** + * Both eras one fixture server serves, for behaviour a caller should see + * the same on either. + * + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + } + /** * Spawn a fixture server and connect a client to it. * diff --git a/tests/Integration/NotificationTest.php b/tests/Integration/NotificationTest.php index 09487185..76d4b2fb 100644 --- a/tests/Integration/NotificationTest.php +++ b/tests/Integration/NotificationTest.php @@ -14,7 +14,9 @@ use Mcp\Client\Handler\Notification\LoggingNotificationHandler; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Notification\LoggingMessageNotification; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; /** @@ -28,9 +30,11 @@ final class NotificationTest extends IntegrationTestCase { #[TestDox('progress notifications reach the callback passed to callTool()')] - public function testProgressReachesTheCaller(): void + #[DataProvider('provideEras')] + public function testProgressReachesTheCaller(ProtocolVersion $era): void { - $client = $this->connect('notification'); + $client = $this->connect('notification', $this->clientBuilder()->setProtocolVersion($era)); + $this->assertSame($era, $client->getProtocolVersion()); $updates = []; $result = $client->callTool('work', [], static function (float $progress, ?float $total, ?string $message) use (&$updates): void { @@ -43,11 +47,12 @@ public function testProgressReachesTheCaller(): void } #[TestDox('progress is skipped when the caller asked for none')] - public function testProgressIsSkippedWithoutAToken(): void + #[DataProvider('provideEras')] + public function testProgressIsSkippedWithoutAToken(ProtocolVersion $era): void { // Without an onProgress callback the request carries no progress token, // so the gateway drops the notification instead of sending it. - $client = $this->connect('notification'); + $client = $this->connect('notification', $this->clientBuilder()->setProtocolVersion($era)); $result = $client->callTool('work'); @@ -56,18 +61,25 @@ public function testProgressIsSkippedWithoutAToken(): void } #[TestDox('log notifications reach a registered logging handler')] - public function testLoggingReachesTheClient(): void + #[DataProvider('provideEras')] + public function testLoggingReachesTheClient(ProtocolVersion $era): void { $logged = []; $client = $this->connect( 'notification', - $this->clientBuilder()->addNotificationHandler(new LoggingNotificationHandler( + $this->clientBuilder()->setProtocolVersion($era)->addNotificationHandler(new LoggingNotificationHandler( static function (LoggingMessageNotification $notification) use (&$logged): void { $logged[] = [$notification->level, $notification->data]; }, )), ); + $this->assertSame($era, $client->getProtocolVersion()); + + // A request on the handshake era, a level carried by every request + // that follows on the modern one — the caller cannot tell. + $client->setLoggingLevel(LoggingLevel::Info); + $client->callTool('work'); $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); diff --git a/tests/Integration/RootsTest.php b/tests/Integration/RootsTest.php index 6dfd0650..f613a63a 100644 --- a/tests/Integration/RootsTest.php +++ b/tests/Integration/RootsTest.php @@ -16,6 +16,7 @@ use Mcp\Client\Handler\Request\RootsCallbackInterface; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\ListRootsResult; use Mcp\Schema\Root; @@ -28,6 +29,15 @@ */ final class RootsTest extends IntegrationTestCase { + /** + * Roots exists only on the handshake era: 2026-07-28 removed it, so a + * client and server that could both settle on the modern era are kept off it. + */ + protected function clientBuilder(): ClientBuilder + { + return parent::clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25); + } + #[TestDox('the roots the client exposes reach the tool that asked')] public function testRootsReachTheTool(): void { diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index 0f452865..090157b6 100644 --- a/tests/Integration/SamplingTest.php +++ b/tests/Integration/SamplingTest.php @@ -14,8 +14,10 @@ use Mcp\Client\Builder as ClientBuilder; use Mcp\Client\Handler\Request\SamplingCallbackInterface; use Mcp\Client\Handler\Request\SamplingRequestHandler; +use Mcp\Exception\RequestException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Enum\Role; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; @@ -28,6 +30,15 @@ */ final class SamplingTest extends IntegrationTestCase { + /** + * Sampling exists only on the handshake era: 2026-07-28 removed it, so a + * client and server that could both settle on the modern era are kept off it. + */ + protected function clientBuilder(): ClientBuilder + { + return parent::clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25); + } + #[TestDox('the sampled completion reaches the tool that asked for it')] public function testSampledCompletionReachesTheTool(): void { @@ -92,6 +103,21 @@ public function testClientWithoutSamplingRefuses(): void $this->assertSame('Client does not handle "sampling/createMessage" requests.', $result->content[0]->text); } + #[TestDox('a tool that samples tells a client on the modern era it cannot, rather than hanging')] + public function testSamplingIsUnavailableOnTheModernEra(): void + { + $client = $this->connect('sampling', $this->clientSampling()->setProtocolVersion(ProtocolVersion::V2026_07_28)); + + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + + try { + $client->callTool('summarize', ['text' => 'hello']); + $this->fail('Sampling must not be reachable on 2026-07-28, which removed it.'); + } catch (RequestException $e) { + $this->assertStringContainsString('sampling and roots were removed', $e->getMessage()); + } + } + /** * @param \ArrayObject|null $seen collects what the server asked for */ diff --git a/tests/Integration/SamplingToolsTest.php b/tests/Integration/SamplingToolsTest.php index 107f163b..a81ea270 100644 --- a/tests/Integration/SamplingToolsTest.php +++ b/tests/Integration/SamplingToolsTest.php @@ -18,6 +18,7 @@ use Mcp\Schema\Content\TextContent; use Mcp\Schema\Content\ToolResultContent; use Mcp\Schema\Content\ToolUseContent; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Enum\Role; use Mcp\Schema\Request\CreateSamplingMessageRequest; use Mcp\Schema\Result\CreateSamplingMessageResult; @@ -30,6 +31,15 @@ */ final class SamplingToolsTest extends IntegrationTestCase { + /** + * Sampling exists only on the handshake era: 2026-07-28 removed it, so a + * client and server that could both settle on the modern era are kept off it. + */ + protected function clientBuilder(): ClientBuilder + { + return parent::clientBuilder()->setProtocolVersion(ProtocolVersion::V2025_11_25); + } + #[TestDox('the server runs a full tool loop and gets the model\'s final answer')] public function testToolLoopCompletes(): void { diff --git a/tests/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php index ae50a499..24146b81 100644 --- a/tests/Unit/Client/ConfigurationTest.php +++ b/tests/Unit/Client/ConfigurationTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Configuration; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; @@ -51,6 +52,24 @@ public static function provideNonPositiveTimeouts(): iterable yield 'negative' => [-1]; } + #[TestDox('prefers the modern era and falls back to the newest handshake revision by default')] + public function testDefaultsToModernWithHandshakeFallback(): void + { + $config = new Configuration(new Implementation('client', '1.0.0'), new ClientCapabilities()); + + $this->assertSame(ProtocolVersion::V2026_07_28, $config->protocolVersion); + $this->assertSame(ProtocolVersion::latestHandshake(), $config->fallbackProtocolVersion); + } + + #[TestDox('a modern revision cannot be the handshake fallback')] + public function testModernFallbackIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The fallback protocol version must be one reached through the "initialize" handshake, got "2026-07-28".'); + + new Configuration(new Implementation('client', '1.0.0'), new ClientCapabilities(), fallbackProtocolVersion: ProtocolVersion::V2026_07_28); + } + #[TestDox('the builder rejects a non-positive initialization timeout')] public function testBuilderRejectsNonPositiveInitTimeout(): void { diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index ee421258..8370814d 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -15,7 +15,6 @@ use Mcp\Client\Protocol; use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; -use Mcp\Exception\ConnectionException; use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; @@ -78,31 +77,127 @@ public function testModernRequestsCarryTheEnvelope(): void } } - #[TestDox('a server that refuses "server/discover" still leaves a usable connection')] - public function testDiscoveryFailureIsNotFatal(): void + #[TestDox('a server that refuses "server/discover" is reached through the handshake instead')] + public function testRefusedProbeFallsBackToTheHandshake(): void { - $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true); + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, refuseDiscovery: true); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); - $protocol->initialize($config); + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $transport->methods); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $transport->offeredVersion); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); $this->assertTrue($protocol->getState()->isInitialized()); + + // Nothing after the fallback carries the modern envelope. + $this->assertArrayNotHasKey(RequestMeta::PROTOCOL_VERSION, $transport->metas[1]); + } + + #[TestDox('the fallback offers the configured handshake revision')] + public function testFallbackOffersTheConfiguredRevision(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_06_18->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, ProtocolVersion::V2025_06_18)); + + $protocol->initialize($config); + + $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); + $this->assertSame(ProtocolVersion::V2025_06_18, $protocol->getState()->getProtocolVersion()); } - #[TestDox('refuses to continue when discovery shows the server has no modern revision')] - public function testDiscoveryWithoutAModernRevisionFails(): void + #[TestDox('a server that never answers the probe is reached through the handshake once it times out')] + public function testSilentProbeFallsBackToTheHandshake(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, ignoreDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + // The transport times a request out by resuming its fiber with an error; + // driven by hand here, the way StdioTransport::tick() would. + $fiber = new \Fiber(static fn () => $protocol->initialize($config)); + $suspended = $fiber->start(); + + $this->assertSame('await_response', $suspended['type']); + $fiber->resume(Error::forInternalError('Request timed out', $suspended['request_id'])); + + $this->assertTrue($fiber->isTerminated()); + $this->assertInstanceOf(Response::class, $fiber->getReturn()); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } + + #[TestDox('a refusal naming only handshake revisions falls back rather than failing')] + public function testRefusalNamingHandshakeRevisionsFallsBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryError: Error::forUnsupportedProtocolVersion('2026-07-28', ProtocolVersion::handshakeVersions())); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } + + #[TestDox('a refusal naming no revision this client speaks fails without a handshake')] + public function testRefusalNamingNothingUsableFails(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryError: new Error(1, Error::UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', ['requested' => '2026-07-28', 'supported' => ['2099-01-01']])); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString('2099-01-01', $result->message); + $this->assertNotContains('initialize', $transport->methods); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + #[TestDox('a server advertising only handshake revisions is reached through the handshake')] + public function testDiscoveryWithoutAModernRevisionFallsBack(): void { - // Advertising only handshake revisions leaves nothing this connection - // can use: it has already skipped the handshake. $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value); $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); - $this->expectException(ConnectionException::class); - $this->expectExceptionMessage('does not support any modern protocol revision'); + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $transport->methods); + $this->assertSame(ProtocolVersion::V2025_11_25, $protocol->getState()->getProtocolVersion()); + } - $protocol->initialize($config); + #[TestDox('a modern-only client fails against a server without the modern era')] + public function testModernOnlyClientDoesNotFallBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, null)); + + $result = $protocol->initialize($config); + + $this->assertInstanceOf(Error::class, $result); + $this->assertStringContainsString('without a handshake fallback', $result->message); + $this->assertSame(['server/discover'], $transport->methods); + $this->assertFalse($protocol->getState()->isInitialized()); + } + + #[TestDox('a handshake refused because the server already settled on the modern era probes again')] + public function testLateModernSettlementIsProbedAgain(): void + { + // The first probe went unanswered in time; by the handshake, the server + // had answered it and settled on the modern era. + $transport = new RecordingTransport( + ProtocolVersion::V2025_11_25->value, + refuseDiscovery: true, + initializeError: Error::forUnsupportedProtocolVersion('2025-11-25', [ProtocolVersion::V2026_07_28]), + discoverAfterInitialize: true, + ); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $this->assertInstanceOf(Response::class, $protocol->initialize($config)); + $this->assertSame(['server/discover', 'initialize', 'server/discover'], $transport->methods); + $this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); } #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] @@ -199,7 +294,8 @@ public function testEmptyInputResponsesEncodesAsJsonObject(): void { $transport = new InputRequiredRoundTripTransport(); $protocol = new Protocol(); - $protocol->connect($transport, $this->createConfiguration(ProtocolVersion::V2026_07_28)); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + $protocol->initialize($config); $result = $protocol->request(new PingRequest(), 5); @@ -208,12 +304,13 @@ public function testEmptyInputResponsesEncodesAsJsonObject(): void $this->assertStringNotContainsString('"inputResponses":[]', $transport->retryBody); } - private function createConfiguration(ProtocolVersion $protocolVersion): Configuration + private function createConfiguration(ProtocolVersion $protocolVersion, ?ProtocolVersion $fallback = ProtocolVersion::V2025_11_25): Configuration { return new Configuration( clientInfo: new Implementation('client-app', '1.0.0'), capabilities: new ClientCapabilities(), protocolVersion: $protocolVersion, + fallbackProtocolVersion: $fallback, ); } } @@ -237,10 +334,16 @@ public function setState(ClientStateInterface $state): void public function send(string $data): void { - /** @var array{id: int} $message */ + /** @var array{id: int, method: string} $message */ $message = json_decode($data, true); $id = $message['id']; + if ('server/discover' === $message['method']) { + $this->answer($id, ['resultType' => 'complete', 'supportedVersions' => [ProtocolVersion::V2026_07_28->value], 'capabilities' => []]); + + return; + } + if (0 === $this->calls++) { $this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]); @@ -311,9 +414,15 @@ final class RecordingTransport implements TransportInterface private ClientStateInterface $state; + private bool $initialized = false; + public function __construct( private readonly string $counterOffer, private readonly bool $refuseDiscovery = false, + private readonly bool $ignoreDiscovery = false, + private readonly ?Error $discoveryError = null, + private readonly ?Error $initializeError = null, + private readonly bool $discoverAfterInitialize = false, ) { } @@ -336,6 +445,13 @@ public function send(string $data): void if ('initialize' === $method) { $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + $this->initialized = true; + + if (null !== $this->initializeError) { + $this->fail($message['id'], $this->initializeError); + + return; + } $this->answer($message['id'], [ 'protocolVersion' => $this->counterOffer, @@ -350,19 +466,27 @@ public function send(string $data): void return; } - if ($this->refuseDiscovery) { - $this->state->storeResponse($message['id'], [ - 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'error' => ['code' => -32601, 'message' => 'Method not found'], - ]); + if ($this->ignoreDiscovery) { + return; + } + + $discoverable = $this->discoverAfterInitialize && $this->initialized; + + if (null !== $this->discoveryError && !$discoverable) { + $this->fail($message['id'], $this->discoveryError); + + return; + } + + if ($this->refuseDiscovery && !$discoverable) { + $this->fail($message['id'], Error::forMethodNotFound('Method not found')); return; } $this->answer($message['id'], [ 'resultType' => 'complete', - 'supportedVersions' => [$this->counterOffer], + 'supportedVersions' => [$discoverable ? ProtocolVersion::V2026_07_28->value : $this->counterOffer], 'capabilities' => [], 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], ]); @@ -380,6 +504,11 @@ private function answer(int|string $id, array $result): void ]); } + private function fail(int|string $id, Error $error): void + { + $this->state->storeResponse($id, (new Error($id, $error->code, $error->message, $error->data))->jsonSerialize()); + } + public function setState(ClientStateInterface $state): void { $this->state = $state; diff --git a/tests/Unit/Client/Transport/HttpTransportTest.php b/tests/Unit/Client/Transport/HttpTransportTest.php index 83a77e0b..09f44181 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -14,7 +14,9 @@ use Mcp\Client; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; @@ -83,8 +85,10 @@ public function sendRequest(RequestInterface $request): ResponseInterface } }; + // The handshake era is what these servers speak, so no probe precedes it. $client = Client::builder() ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->setInitTimeout(1) ->build(); @@ -127,8 +131,10 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertNull($transport->getSessionId()); + // The handshake era is what these servers speak, so no probe precedes it. $client = Client::builder() ->setClientInfo('test-client', '1.0.0') + ->setProtocolVersion(ProtocolVersion::V2025_11_25) ->setInitTimeout(1) ->build(); @@ -141,6 +147,109 @@ public function sendRequest(RequestInterface $request): ResponseInterface $this->assertNull($transport->getSessionId()); } + /** + * @return iterable, string}> + */ + public static function probeRefusalProvider(): iterable + { + yield 'a JSON-RPC error without an id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"Bad Request: Server not initialized"}}']; + yield 'a JSON-RPC error under another id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":999,"error":{"code":-32600,"message":"Bad Request"}}']; + yield 'an empty body' => [400, [], '']; + yield 'a plain-text body' => [404, ['Content-Type' => 'text/plain'], 'Not Found']; + } + + /** + * @param array $headers + */ + #[DataProvider('probeRefusalProvider')] + #[TestDox('a handshake-era server refusing the probe with $_dataName is reached through the handshake at once')] + public function testRefusedProbeFallsBackWithoutWaiting(int $status, array $headers, string $body): void + { + $httpClient = new class($status, $headers, $body) implements ClientInterface { + /** @var list */ + public array $methods = []; + + /** + * @param array $headers + */ + public function __construct(private readonly int $status, private readonly array $headers, private readonly string $body) + { + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + $this->methods[] = $decoded['method'] ?? ''; + + if ('initialize' !== ($decoded['method'] ?? null)) { + return 'server/discover' === ($decoded['method'] ?? null) + ? new Response($this->status, $this->headers, $this->body) + : new Response(202); + } + + return new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'result' => [ + 'protocolVersion' => '2025-11-25', + 'capabilities' => new \stdClass(), + 'serverInfo' => ['name' => 'legacy-server', 'version' => '1.0.0'], + ], + ])); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(5) + ->build(); + + $started = microtime(true); + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + + $this->assertLessThan(1, microtime(true) - $started, 'the refusal must not be waited out like silence'); + $this->assertSame(['server/discover', 'initialize', 'notifications/initialized'], $httpClient->methods); + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + $this->assertSame('legacy-server', $client->getServerInfo()?->name); + } + + #[TestDox('a modern refusal under 400 is read as the modern error it is, not as a handshake-era server')] + public function testModernRefusalIsRead(): void + { + $httpClient = new class implements ClientInterface { + /** @var list */ + public array $methods = []; + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $decoded = json_decode((string) $request->getBody(), true); + $this->methods[] = $decoded['method'] ?? ''; + + return new Response(400, ['Content-Type' => 'application/json'], (string) json_encode([ + 'jsonrpc' => '2.0', + 'id' => $decoded['id'], + 'error' => ['code' => -32022, 'message' => 'Unsupported protocol version', 'data' => ['requested' => '2026-07-28', 'supported' => ['2099-01-01']]], + ])); + } + }; + + $client = Client::builder() + ->setClientInfo('test-client', '1.0.0') + ->setInitTimeout(5) + ->setMaxRetries(0) + ->build(); + + try { + $client->connect(new HttpTransport('http://localhost/mcp', [], $httpClient, $this->factory, $this->factory)); + $this->fail('A modern server sharing no revision with the client must fail the connection.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('2099-01-01', $e->getMessage()); + } + + // A modern server: no fallback to a handshake it does not have. + $this->assertSame(['server/discover'], $httpClient->methods); + } + #[TestDox('SSE stream is aborted before the buffer can exceed the configured cap')] public function testSseBufferIsBoundedByConfiguredCap(): void { diff --git a/tests/Unit/Client/Transport/StdioTransportTest.php b/tests/Unit/Client/Transport/StdioTransportTest.php index fb314083..43a8c2a3 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -11,8 +11,10 @@ namespace Mcp\Tests\Unit\Client\Transport; +use Mcp\Client; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\StdioTransport; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Schema\JsonRpc\Error; use PHPUnit\Framework\Attributes\TestDox; @@ -70,6 +72,39 @@ public function testWellFormedFramesStillParse(): void $this->assertSame(['{"a":1}', '{"b":2}'], $messages); } + #[TestDox('a server closing its output fails what is pending as answers, so nothing is left to time out later')] + public function testClosedOutputFailsPendingRequests(): void + { + $transport = new StdioTransport(command: 'true'); + $state = new ClientState(); + $transport->setState($state); + $state->addPendingRequest(1, 120); + + $this->setStdout($transport, $this->stream('')); + $this->invokeProcessInput($transport); + + $response = $state->consumeResponse(1); + + $this->assertInstanceOf(Error::class, $response); + $this->assertStringContainsString('no longer running', $response->message); + } + + #[TestDox('a server that exits fails the connection at once instead of timing out')] + public function testExitedServerFailsTheConnection(): void + { + $client = Client::builder()->setInitTimeout(10)->setMaxRetries(0)->build(); + $started = microtime(true); + + try { + $client->connect(new StdioTransport(command: \PHP_BINARY, args: ['-r', 'exit(1);'])); + $this->fail('Connecting to a server that exits must fail.'); + } catch (ConnectionException $e) { + $this->assertStringContainsString('no longer running', $e->getMessage()); + } + + $this->assertLessThan(5, microtime(true) - $started); + } + #[TestDox('the buffer cap must be a positive number of bytes')] public function testRejectsNonPositiveCap(): void { diff --git a/tests/Unit/ClientTest.php b/tests/Unit/ClientTest.php index 558de954..1a92d153 100644 --- a/tests/Unit/ClientTest.php +++ b/tests/Unit/ClientTest.php @@ -17,6 +17,7 @@ use Mcp\Client\Transport\TransportInterface; use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Response; @@ -44,6 +45,19 @@ public function testConnectSucceedsWithoutRetrying(): void $this->assertTrue($client->isConnected()); } + #[TestDox('setting the log level after disconnecting a modern connection fails like any other call')] + public function testSetLoggingLevelRequiresAConnection(): void + { + $client = Client::builder()->build(); + $client->connect(new FakeTransport([FakeTransport::ACCEPT_MODERN])); + $this->assertSame(ProtocolVersion::V2026_07_28, $client->getProtocolVersion()); + $client->disconnect(); + + $this->expectException(ConnectionException::class); + + $client->setLoggingLevel(LoggingLevel::Info); + } + #[TestDox('connect() retries a failed attempt and succeeds on a later one')] public function testConnectRetriesUntilItSucceeds(): void { @@ -160,6 +174,9 @@ final class FakeTransport extends BaseTransport /** The initialize request is answered with a result. */ public const ACCEPT = 'accept'; + /** The `server/discover` probe is answered as a modern server would, settling the connection on 2026-07-28. */ + public const ACCEPT_MODERN = 'accept_modern'; + /** The initialize request is answered with a JSON-RPC error. */ public const REJECT = 'reject'; @@ -178,7 +195,7 @@ final class FakeTransport extends BaseTransport private array $outbox = []; /** - * @param list $attempts How each successive connect() call behaves + * @param list $attempts How each successive connect() call behaves */ public function __construct(private array $attempts = [self::ACCEPT]) { @@ -213,6 +230,16 @@ public function send(string $data): void return; // A notification, nothing to answer. } + if (self::ACCEPT_MODERN === $this->outcome && 'server/discover' === ($message['method'] ?? null)) { + $this->outbox[] = json_encode(['jsonrpc' => '2.0', 'id' => $message['id'], 'result' => [ + 'resultType' => 'complete', + 'supportedVersions' => [ProtocolVersion::V2026_07_28->value], + 'capabilities' => [], + ]], \JSON_THROW_ON_ERROR); + + return; + } + $answer = self::REJECT === $this->outcome ? ['error' => ['code' => Error::INTERNAL_ERROR, 'message' => 'Server unavailable']] : ['result' => [ diff --git a/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 175db74f..04830da5 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -241,7 +241,10 @@ public function testNonInitializeRequestWithoutSessionIdReturnsError(): void $this->callback(static function ($data) { $decoded = json_decode($data, true); + // Echoing the id lets a client probing for the modern era + // correlate the refusal instead of waiting out a timeout. return isset($decoded['error']) + && 1 === ($decoded['id'] ?? null) && str_contains($decoded['error']['message'], 'session id is REQUIRED'); }), $this->callback(static function ($context) { diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 7d7d04c7..3e54b559 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -278,7 +278,6 @@ public function testEmptyCapabilitiesReportNone(): void */ public static function removedMethods(): iterable { - yield 'initialize' => ['initialize', []]; yield 'ping' => ['ping', []]; yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'info']]; yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'test://static']]; @@ -298,6 +297,25 @@ public function testRemovedMethodsAreUnknown(string $method, array $params): voi $this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']); } + #[TestDox('a handshake-era client opening with "initialize" is told which revisions are served')] + public function testInitializeNamesTheServedRevisions(): void + { + $result = self::protocol()->handle(json_encode([ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => ['protocolVersion' => '2025-11-25', 'capabilities' => new \stdClass(), 'clientInfo' => ['name' => 'legacy', 'version' => '1.0.0']], + ], \JSON_THROW_ON_ERROR)); + + $answer = json_decode($result->toJson(), true); + + $this->assertSame(400, $result->httpStatus); + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answer['error']['code']); + $this->assertSame(1, $answer['id']); + $this->assertSame('2025-11-25', $answer['error']['data']['requested']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['error']['data']['supported']); + } + /** * Drains a streaming result into the frames it would write. * @@ -1006,6 +1024,83 @@ public function testListenStreamDeliversSubscribedNotifications(): void $this->assertSame('complete', $frames[3]['result']['resultType']); } + /** + * A request the way stdio carries it: the metadata inline, no headers. + * + * @param array $params + */ + private static function inlineRequest(string $method, array $params = []): string + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ...($params['_meta'] ?? []), + ]; + + return json_encode(['jsonrpc' => '2.0', 'id' => 9, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR); + } + + #[TestDox('a message off a transport without headers is answered without them')] + public function testInlineMessageNeedsNoHeaders(): void + { + $protocol = self::protocol(); + $message = self::inlineRequest('tools/call', ['name' => 'plain_tool', 'arguments' => []]); + + // The same message over HTTP is missing headers it has to carry. + $this->assertSame(Error::HEADER_MISMATCH, json_decode($protocol->handle($message)->toJson(), true)['error']['code']); + + $result = $protocol->handleInline($message); + + $this->assertSame(200, $result->httpStatus); + $this->assertSame('ok', json_decode($result->toJson(), true)['result']['content'][0]['text']); + } + + #[TestDox('an inline request streams its progress without being asked to')] + public function testInlineProgressIsStreamed(): void + { + $result = self::protocol()->handleInline(self::inlineRequest('tools/call', [ + 'name' => 'progress_tool', + 'arguments' => [], + '_meta' => ['progressToken' => 'tok-1'], + ])); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + + $this->assertSame(['notifications/progress', 'notifications/progress'], [$frames[0]['method'], $frames[1]['method']]); + $this->assertSame(9, $frames[2]['id']); + } + + #[TestDox('an inline listen stream leaves the pacing to its consumer')] + public function testInlineListenIsNotPaced(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(toolsListChanged: true)) + ->setNotificationBus(new InMemoryNotificationBus()) + ->setSubscriptionLifetime(60) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $result = $protocol->handleInline(self::inlineRequest('subscriptions/listen', ['notifications' => ['toolsListChanged' => true]])); + + $this->assertTrue($result->isStream()); + + $frames = ($result->frames)(); + $started = microtime(true); + + $this->assertSame('notifications/subscriptions/acknowledged', $frames->current()['method']); + + // A paced stream sleeps a quarter second between polls, which would + // stall every other message sharing the stdio channel. + for ($i = 0; $i < 5; ++$i) { + $frames->next(); + $this->assertNull($frames->current()); + } + + $this->assertLessThan(0.2, microtime(true) - $started); + } + #[TestDox('the acknowledgment drops types the server cannot honour')] public function testAcknowledgmentReflectsWhatTheServerCanDo(): void { diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php new file mode 100644 index 00000000..f454585a --- /dev/null +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -0,0 +1,248 @@ +serve(self::builder(), [ + self::modern(1, 'server/discover'), + self::modern(2, 'tools/call', ['name' => 'echo', 'arguments' => ['text' => 'hi']]), + ]); + + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answers[1]['result']['supportedVersions']); + $this->assertSame('test-server', $answers[1]['result']['_meta'][RequestMeta::SERVER_INFO]['name']); + $this->assertSame('hi', $answers[2]['result']['content'][0]['text']); + } + + #[TestDox('a client opening with the handshake is served the handshake era')] + public function testHandshakeOpening(): void + { + $answers = $this->serve(self::builder(), [ + self::initialize(1), + ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], + ['jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'echo', 'arguments' => ['text' => 'hi']]], + ]); + + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answers[1]['result']['protocolVersion']); + $this->assertSame('hi', $answers[2]['result']['content'][0]['text']); + } + + #[TestDox('a handshake after a modern opening is refused, naming the modern revisions')] + public function testHandshakeAfterModernOpeningIsRefused(): void + { + $answers = $this->serve(self::builder(), [ + self::modern(1, 'server/discover'), + self::initialize(2), + ]); + + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answers[2]['error']['code']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answers[2]['error']['data']['supported']); + } + + #[TestDox('a modern request after a handshake opening is refused')] + public function testModernRequestAfterHandshakeOpeningIsRefused(): void + { + $answers = $this->serve(self::builder(), [ + self::initialize(1), + self::modern(2, 'server/discover'), + ]); + + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answers[1]['result']['protocolVersion']); + $this->assertSame(Error::INVALID_REQUEST, $answers[2]['error']['code']); + } + + #[TestDox('a handshake-only server refuses a modern probe and still accepts the handshake')] + public function testHandshakeOnlyServerRefusesTheProbe(): void + { + $answers = $this->serve(self::builder()->withoutModernEra(), [ + self::modern(1, 'server/discover'), + self::initialize(2), + ]); + + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answers[1]['error']['code']); + $this->assertNotContains(ProtocolVersion::V2026_07_28->value, $answers[1]['error']['data']['supported']); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answers[2]['result']['protocolVersion']); + } + + #[TestDox('a probe without an envelope gets an error it can correlate, not silence')] + public function testUnenvelopedRequestBeforeTheHandshakeIsAnswered(): void + { + $answers = $this->serve(self::builder(), [ + ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'server/discover'], + ]); + + $this->assertSame(Error::INVALID_REQUEST, $answers[1]['error']['code']); + } + + #[TestDox('a modern request streams its progress on the shared channel before its result')] + public function testModernProgressIsStreamed(): void + { + $lines = $this->exchange(self::builder(), [ + self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => [], '_meta' => ['progressToken' => 'p']]), + ]); + + $this->assertSame(['notifications/progress', 'notifications/progress'], [$lines[0]['method'], $lines[1]['method']]); + $this->assertSame(1, $lines[2]['id']); + $this->assertSame('counted', $lines[2]['result']['content'][0]['text']); + } + + #[TestDox('a listen stream shares the channel, acknowledged and tagged with its subscription')] + public function testListenStreamIsAcknowledged(): void + { + $lines = $this->exchange(self::builder()->setCapabilities(new ServerCapabilities(toolsListChanged: true)), [ + self::modern(5, 'subscriptions/listen', ['notifications' => ['toolsListChanged' => true]]), + self::modern(6, 'tools/call', ['name' => 'echo', 'arguments' => ['text' => 'still served']]), + ['jsonrpc' => '2.0', 'method' => 'notifications/cancelled', 'params' => ['requestId' => 5]], + ]); + + $this->assertSame('notifications/subscriptions/acknowledged', $lines[0]['method']); + $this->assertSame(5, $lines[0]['params']['_meta'][RequestMeta::SUBSCRIPTION_ID]); + + // The open subscription does not hold up the next request, and the + // notification ending it is taken without an answer of its own. + $this->assertSame(6, $lines[1]['id']); + $this->assertSame('still served', $lines[1]['result']['content'][0]['text']); + $this->assertCount(2, $lines); + } + + #[TestDox('a string and an integer request id are different requests, so cancelling one leaves the other')] + public function testStreamsAreKeyedByIdType(): void + { + $input = fopen('php://temp', 'r+'); + $output = fopen('php://temp', 'r+'); + + foreach ([ + self::modern(5, 'subscriptions/listen', ['notifications' => ['toolsListChanged' => true]]), + ['jsonrpc' => '2.0', 'id' => '5', 'method' => 'subscriptions/listen', 'params' => self::modern(0, 'x', ['notifications' => ['toolsListChanged' => true]])['params']], + ['jsonrpc' => '2.0', 'method' => 'notifications/cancelled', 'params' => ['requestId' => 5]], + ] as $message) { + fwrite($input, json_encode($message, \JSON_THROW_ON_ERROR)."\n"); + } + + rewind($input); + + $transport = new StdioTransport($input, $output); + self::builder()->setCapabilities(new ServerCapabilities(toolsListChanged: true))->build()->run($transport); + + $streams = (new \ReflectionProperty($transport, 'streams'))->getValue($transport); + + $this->assertSame(['s:5'], array_keys($streams)); + } + + private static function builder(): Builder + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool(static fn (string $text): string => $text, name: 'echo', description: 'Echoes') + ->addTool(static function (RequestContext $context): string { + $context->getClientGateway()->progress(1, 2); + $context->getClientGateway()->progress(2, 2); + + return 'counted'; + }, name: 'count', description: 'Reports progress'); + } + + /** + * @param array $params + * + * @return array + */ + private static function modern(int $id, string $method, array $params = []): array + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ...($params['_meta'] ?? []), + ]; + + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => $method, 'params' => $params]; + } + + /** + * @return array + */ + private static function initialize(int $id): array + { + return ['jsonrpc' => '2.0', 'id' => $id, 'method' => 'initialize', 'params' => [ + 'protocolVersion' => ProtocolVersion::V2025_11_25->value, + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'test-client', 'version' => '1.0.0'], + ]]; + } + + /** + * Runs the server over the given input until it is exhausted, and returns + * every answer by the id it answers. + * + * @param list> $messages + * + * @return array> + */ + private function serve(Builder $builder, array $messages): array + { + $answers = []; + + foreach ($this->exchange($builder, $messages) as $line) { + if (\array_key_exists('id', $line)) { + $answers[$line['id']] = $line; + } + } + + return $answers; + } + + /** + * @param list> $messages + * + * @return list> + */ + private function exchange(Builder $builder, array $messages): array + { + $input = fopen('php://temp', 'r+'); + // A file rather than memory: running the server closes its streams. + $outputFile = tempnam(sys_get_temp_dir(), 'mcp-stdio'); + $output = fopen($outputFile, 'w'); + + foreach ($messages as $message) { + fwrite($input, json_encode($message, \JSON_THROW_ON_ERROR)."\n"); + } + + rewind($input); + + $builder->build()->run(new StdioTransport($input, $output)); + + $written = (string) file_get_contents($outputFile); + unlink($outputFile); + + return array_map( + static fn (string $line): array => json_decode($line, true, flags: \JSON_THROW_ON_ERROR), + array_values(array_filter(explode("\n", $written))), + ); + } +}