diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ee7a5cc..ad49825c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,9 @@ All notable changes to `mcp/sdk` will be documented in this file. * 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. * [BC Break] `StatelessAwareTransportInterface` declares `setHandshakeVersions()`, so a server without the modern era names only the revisions it negotiates when refusing a `2026-07-28` request, e.g. the one set with `Builder::setProtocolVersion()`. * 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. +* Speak both protocol eras from a client configured with `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. +* 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. +* 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. 0.8.0 ----- diff --git a/docs/client/connecting.md b/docs/client/connecting.md index eef0bc7ac..d8ca4fca1 100644 --- a/docs/client/connecting.md +++ b/docs/client/connecting.md @@ -61,8 +61,7 @@ $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): +Specify the MCP protocol version to speak (defaults to `V2025_11_25`). A handshake revision opens with `initialize`: ```php use Mcp\Schema\Enum\ProtocolVersion; @@ -72,19 +71,33 @@ $client = Client::builder() ->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 +A modern revision makes the client speak both protocol eras: it probes for `2026-07-28` 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 +// Fall back to an older handshake revision instead of 2025-11-25… +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setFallbackProtocolVersion(ProtocolVersion::V2025_06_18) + ->build(); + +// …or not at all, refusing servers without the modern era. +$client = Client::builder() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->setFallbackProtocolVersion(null) + ->build(); +``` + +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 4dec7b83e..7d4f2b86e 100644 --- a/docs/protocol-versions.md +++ b/docs/protocol-versions.md @@ -104,7 +104,9 @@ either lifecycle. What changes: ## Speaking it from a client -One line selects the lifecycle; nothing else about the [client API](client/index.md) changes. +Ask for `2026-07-28` and the client speaks both eras: it finds out on `connect()` whether +the server does too, and nothing about the [client API](client/index.md) depends on the +answer. ```php $client = Client::builder() @@ -116,16 +118,49 @@ $client = Client::builder() $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. @@ -142,8 +177,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) diff --git a/src/Client.php b/src/Client.php index 66507ffe8..02ec67e4a 100644 --- a/src/Client.php +++ b/src/Client.php @@ -31,6 +31,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; @@ -173,11 +174,11 @@ public function getProtocolVersion(): ?ProtocolVersion } /** - * Send a ping request to the server. + * Check that the server is reachable: `ping`, or `server/discover` on the modern era, which removed it. */ public function ping(): void { - $request = new PingRequest(); + $request = $this->protocol->isModern() ? new DiscoverRequest() : new PingRequest(); $this->sendRequest($request); } @@ -336,10 +337,20 @@ public function complete(PromptReference|ResourceReference $ref, array $argument } /** - * Set the minimum logging level for server log messages. + * Set the minimum logging level for server log messages; on the modern era it rides on every following request. */ 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); @@ -363,6 +374,12 @@ public function sendRootsListChanged(): void throw new ConnectionException('Client is not connected. Call connect() first.'); } + 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()); } @@ -384,7 +401,13 @@ private function sendRequest(Request $request, ?callable $onProgress = null, ?Ca $withProgress = null !== $onProgress; $fiber = new \Fiber(fn () => $this->protocol->request($request, $this->config->requestTimeout, $withProgress, $cancellation, $timeoutSeconds)); - $response = $transport->runRequest($fiber, $onProgress); + $this->protocol->setProgressCallback($onProgress); + + try { + $response = $transport->runRequest($fiber); + } finally { + $this->protocol->setProgressCallback(null); + } if ($response instanceof Error) { throw RequestException::fromError($response); diff --git a/src/Client/Builder.php b/src/Client/Builder.php index 098b6e6ae..aefd1a47a 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,7 @@ public function setClientInfo(string $name, string $version, ?string $descriptio } /** - * Set the protocol version to use. + * Set the protocol version the client prefers, defaults to 2025-11-25; a modern one is probed for first. */ public function setProtocolVersion(ProtocolVersion $protocolVersion): self { @@ -75,6 +76,16 @@ public function setProtocolVersion(ProtocolVersion $protocolVersion): self return $this; } + /** + * Set the handshake revision a modern client falls back to, defaults to 2025-11-25; null makes it modern-only. + */ + public function setFallbackProtocolVersion(?ProtocolVersion $protocolVersion): self + { + $this->fallbackProtocolVersion = $protocolVersion; + + return $this; + } + /** * Set client capabilities. */ @@ -202,6 +213,7 @@ public function build(): Client 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 f0ed6f73e..ea79542b1 100644 --- a/src/Client/Configuration.php +++ b/src/Client/Configuration.php @@ -23,6 +23,9 @@ */ class Configuration { + /** + * @param ProtocolVersion|null $fallbackProtocolVersion handshake revision a modern client falls back to; null makes it modern-only + */ public function __construct( public readonly Implementation $clientInfo, public readonly ClientCapabilities $capabilities, @@ -30,7 +33,12 @@ public function __construct( 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/Handler/Notification/ProgressNotificationHandler.php b/src/Client/Handler/Notification/ProgressNotificationHandler.php index 3c489bf0d..de3c93cf5 100644 --- a/src/Client/Handler/Notification/ProgressNotificationHandler.php +++ b/src/Client/Handler/Notification/ProgressNotificationHandler.php @@ -11,14 +11,13 @@ namespace Mcp\Client\Handler\Notification; -use Mcp\Client\State\ClientStateInterface; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\Notification\ProgressNotification; /** * Internal handler for progress notifications. * - * Writes progress data to state for transport to consume and execute callbacks. + * Hands progress on as soon as it is parsed, so it keeps its order among other notifications. * * @author Kyrian Obikwelu * @@ -26,8 +25,11 @@ */ class ProgressNotificationHandler implements NotificationHandlerInterface { + /** + * @param \Closure(float, ?float, ?string): void $deliver + */ public function __construct( - private readonly ClientStateInterface $state, + private readonly \Closure $deliver, ) { } @@ -42,11 +44,6 @@ public function handle(Notification $notification): void return; } - $this->state->storeProgress( - (string) $notification->progressToken, - $notification->progress, - $notification->total, - $notification->message, - ); + ($this->deliver)($notification->progress, $notification->total, $notification->message); } } diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 251826c75..efdf2986c 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -23,10 +23,10 @@ use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\HttpTransport; use Mcp\Client\Transport\TransportInterface; -use Mcp\Exception\ConnectionException; use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; use Mcp\JsonRpc\MessageFactory; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -80,6 +80,11 @@ class Protocol private ToolCatalog $tools; + private ?LoggingLevel $logLevel = null; + + /** @var (callable(float, ?float, ?string): void)|null */ + private $onProgress; + private readonly InputRequestResolver $inputRequests; /** @@ -103,7 +108,7 @@ public function __construct( $this->logger = $logger ?? new NullLogger(); $this->notificationHandlers = [ - new ProgressNotificationHandler($this->state), + new ProgressNotificationHandler($this->deliverProgress(...)), ...$notificationHandlers, ]; @@ -139,14 +144,7 @@ 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); - } + $this->logLevel = null; $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); @@ -179,11 +177,7 @@ private function headersFor(string $payload): array } /** - * Ready the connection for use. - * - * 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()}. + * Ready the connection for use, settling which protocol era it speaks. * * @param Configuration $config The client configuration * @@ -191,12 +185,201 @@ private function headersFor(string $payload): array */ public function initialize(Configuration $config): Response|Error { - if (null !== $this->envelope) { - return $this->discover($config); + $this->envelope = null; + $this->headers = null; + + if (!$config->protocolVersion->isModern()) { + return $this->handshake($config->protocolVersion, $config); } - $offered = $config->protocolVersion; + return $this->negotiate($config); + } + + /** + * Probe for the modern era, falling back to the handshake unless the server proves to speak it. + * + * @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, ?ProtocolVersion $version = null, bool $reprobed = false): Response|Error + { + $this->enterModernEra($version ?? $config->protocolVersion, $config); + + $probe = $this->request(new DiscoverRequest(), $config->initTimeout); + $adopted = $this->adopt($probe); + + 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); + + // Once more: a timed-out probe may still have settled a slow server on the modern era. + if ($handshake instanceof Error && !$reprobed && null !== $modern = self::mutualModern($handshake)) { + $this->logger->info('Server settled on the modern era after all; probing again.', ['version' => $modern->value]); + + return $this->negotiate($config, $modern, true); + } + + return $handshake; + } + + 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); + } + + /** + * Set the callback for progress on the request in flight, null to clear it. + * + * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress + */ + public function setProgressCallback(?callable $onProgress): void + { + $this->onProgress = $onProgress; + } + + private function deliverProgress(float $progress, ?float $total, ?string $message): void + { + if (null === $this->onProgress) { + return; + } + + try { + ($this->onProgress)($progress, $total, $message); + } catch (\Throwable $e) { + $this->logger->warning('Progress callback failed', ['exception' => $e]); + } + } + + public function isModern(): bool + { + return null !== $this->envelope; + } + + /** + * The modern era's stand-in for `logging/setLevel`. + */ + public function setLogLevel(LoggingLevel $level): void + { + $this->logLevel = $level; + $this->envelope = $this->envelope?->withLogLevel($level); + } + + /** + * The modern connection, an error ending the attempt, or null to fall back. + * + * @param Response>|Error $probe + * + * @return Response>|Error|null + */ + private function adopt(Response|Error $probe): Response|Error|null + { + \assert(null !== $this->envelope); + + if ($probe instanceof Error) { + if (\is_array($probe->data) && true === ($probe->data[TransportInterface::CONNECTION_LOST] ?? null)) { + return $probe; + } + + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $probe->code) { + return null; + } + + // Modern revisions it names were already retried by request(). + $supported = self::supportedVersions($probe); + + foreach ($supported as $version) { + if (!$version->isModern()) { + return null; + } + } + + $named = self::namedVersions($probe); + + 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)) { + return null; + } + + $current = $this->envelope->protocolVersion(); + $chosen = null; + + foreach (ProtocolVersion::modernVersions() as $version) { + if (\in_array($version->value, $advertised, true) && (null === $chosen || $version->isAtLeast($chosen))) { + $chosen = $version; + } + } + + if (\in_array($current->value, $advertised, true)) { + $chosen = $current; + } + if (null === $chosen) { + return null; + } + + 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; + } + + /** + * @return Response>|Error + */ + private function handshake(ProtocolVersion $offered, Configuration $config): Response|Error + { $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -205,6 +388,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 = self::namedVersions($response); + + 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); @@ -244,42 +437,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 - * `getServerCapabilities()`, 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 - * load-bearing for the requests that follow. + * Read defensively: nothing beyond the revisions is load-bearing. * * @param array $result */ @@ -306,56 +464,54 @@ private function readDiscovery(array $result): void if (\is_array($result['capabilities'] ?? null)) { $this->state->setServerCapabilities(ServerCapabilities::fromArray($result['capabilities'])); } - - $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; + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code) { + return null; } - $current = $this->envelope->protocolVersion(); + $mutual = null; - if (\in_array($current->value, $supportedVersions, true)) { - return; + foreach (self::supportedVersions($error) as $version) { + if ($version->isModern() && (null === $mutual || $version->isAtLeast($mutual))) { + $mutual = $version; + } } - foreach ($supportedVersions as $candidate) { - $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; - - if (null === $version || !$version->isModern()) { - continue; - } + return $mutual; + } - $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ - 'configured' => $current->value, - 'using' => $version->value, - ]); + /** + * @return list + */ + private static function supportedVersions(Error $error): array + { + return array_values(array_filter(array_map(ProtocolVersion::tryFrom(...), self::namedVersions($error)))); + } - $this->envelope = $this->envelope->withProtocolVersion($version); - $this->state->setProtocolVersion($version); + /** + * @return list + */ + private static function namedVersions(Error $error): array + { + $supported = \is_array($error->data) && \is_array($error->data['supported'] ?? null) ? $error->data['supported'] : []; - return; - } + return array_values(array_filter($supported, is_string(...))); + } - // 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 fcc1cac4b..ea42f5c9c 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; @@ -35,6 +36,7 @@ public function __construct( private readonly ProtocolVersion $protocolVersion, private readonly ClientCapabilities $capabilities, private readonly Implementation $clientInfo, + private readonly ?LoggingLevel $logLevel = null, ) { } @@ -45,7 +47,12 @@ 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); + } + + public function withLogLevel(?LoggingLevel $logLevel): self + { + return new self($this->protocolVersion, $this->capabilities, $this->clientInfo, $logLevel); } /** @@ -71,6 +78,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 53596bc90..a6d777d39 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -52,9 +52,6 @@ class HttpTransport extends BaseTransport implements HeaderAwareTransportInterfa /** @var FiberSuspend|null */ private ?array $activeSuspend = null; - /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback; - /** @var StreamInterface|null Active SSE stream being read */ private ?StreamInterface $activeStream = null; @@ -205,6 +202,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')) { // While listening, a request on the GET stream can be what this // response waits for, so neither stream may block the other. @@ -231,6 +234,48 @@ private static function isNotification(string $data): bool return \is_array($payload) && \array_key_exists('method', $payload) && !\array_key_exists('id', $payload); } + /** + * Fails a request refused at the HTTP level at once, rather than at its timeout. + */ + private function handleErrorStatus(string $sent, int $status, string $reason, string $body): void + { + $request = json_decode($sent, true); + $requestId = \is_array($request) && \array_key_exists('method', $request) ? ($request['id'] ?? null) : null; + $answer = '' === trim($body) ? null : json_decode($body, true); + + if (\is_array($answer) && null !== $requestId && ($answer['id'] ?? null) === $requestId && self::isWellFormedAnswer($answer)) { + $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 array $answer + */ + private static function isWellFormedAnswer(array $answer): bool + { + try { + \array_key_exists('error', $answer) ? Error::fromArray($answer) : Response::fromArray($answer); + } catch (InvalidArgumentException) { + return false; + } + + return true; + } + /** * @param McpFiber $fiber * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress @@ -238,7 +283,6 @@ private static function isNotification(string $data): bool public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error { $this->activeFiber = $fiber; - $this->activeProgressCallback = $onProgress; try { $this->activeSuspend = $fiber->start(); while (!$fiber->isTerminated()) { @@ -249,7 +293,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons } finally { $this->activeFiber = null; $this->activeSuspend = null; - $this->activeProgressCallback = null; $this->activeStream?->close(); $this->activeStream = null; $this->sseBuffer = ''; @@ -408,7 +451,6 @@ private function tick(): void $this->checkInterruption(); $this->processSSEStream(); $this->processListenStream(); - $this->processProgress(); $this->checkInterruption(); $this->processFiber(); @@ -592,30 +634,6 @@ private function processSSEEvent(string $event): void } } - /** - * Process pending progress updates from session and execute callback. - */ - private function processProgress(): void - { - if (null === $this->activeProgressCallback || null === $this->state) { - return; - } - - $updates = $this->state->consumeProgressUpdates(); - - foreach ($updates as $update) { - try { - ($this->activeProgressCallback)( - $update['progress'], - $update['total'], - $update['message'], - ); - } catch (\Throwable $e) { - $this->logger->warning('Progress callback failed', ['exception' => $e]); - } - } - } - private function processFiber(): void { if (null === $this->activeFiber || !$this->activeFiber->isSuspended()) { diff --git a/src/Client/Transport/StdioTransport.php b/src/Client/Transport/StdioTransport.php index c2be50e41..2108e64e1 100644 --- a/src/Client/Transport/StdioTransport.php +++ b/src/Client/Transport/StdioTransport.php @@ -60,9 +60,6 @@ class StdioTransport extends BaseTransport /** @var FiberSuspend|null */ private ?array $activeSuspend = null; - /** @var (callable(float, ?float, ?string): void)|null */ - private $activeProgressCallback; - /** * @param string $command The command to run * @param array $args Command arguments @@ -120,7 +117,10 @@ public function send(string $data): void throw new ConnectionException('Process stdin not available'); } - fwrite($this->stdin, $data."\n"); + 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]); @@ -133,7 +133,6 @@ public function send(string $data): void public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error { $this->activeFiber = $fiber; - $this->activeProgressCallback = $onProgress; try { $this->activeSuspend = $fiber->start(); @@ -144,7 +143,6 @@ public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Respons return $fiber->getReturn(); } finally { $this->activeFiber = null; - $this->activeProgressCallback = null; $this->activeSuspend = null; } } @@ -213,37 +211,12 @@ private function spawnProcess(): void private function tick(): void { $this->processInput(); - $this->processProgress(); $this->processFiber(); $this->processStderr(); usleep(1000); // 1ms } - /** - * Process pending progress updates from session and execute callback. - */ - private function processProgress(): void - { - if (null === $this->activeProgressCallback || null === $this->state) { - return; - } - - $updates = $this->state->consumeProgressUpdates(); - - foreach ($updates as $update) { - try { - ($this->activeProgressCallback)( - $update['progress'], - $update['total'], - $update['message'], - ); - } catch (\Throwable $e) { - $this->logger->warning('Progress callback failed', ['exception' => $e]); - } - } - } - private function processInput(): void { if (null === $this->stdout || !\is_resource($this->stdout)) { @@ -270,6 +243,26 @@ private function processInput(): void $this->handleMessage($trimmed); } } + + // Only on an empty read, so an answer arriving with the end of output is taken first. + if (('' === $data || false === $data) && \is_resource($this->stdout) && feof($this->stdout)) { + $this->failPending('The server process closed its output; it is no longer running.', [self::CONNECTION_LOST => true]); + } + } + + /** + * @param array|null $data + */ + private function failPending(string $reason, ?array $data = null): void + { + if (null === $this->state) { + return; + } + + foreach ($this->state->getPendingRequests() as $pending) { + $requestId = $pending['request_id']; + $this->state->storeResponse($requestId, (new Error($requestId, Error::INTERNAL_ERROR, $reason, $data))->jsonSerialize()); + } } /** @@ -288,15 +281,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/Client/Transport/TransportInterface.php b/src/Client/Transport/TransportInterface.php index b554c8f40..5ca65fcd7 100644 --- a/src/Client/Transport/TransportInterface.php +++ b/src/Client/Transport/TransportInterface.php @@ -30,6 +30,11 @@ */ interface TransportInterface { + /** + * Error data key marking an error the transport filed because the connection is gone. + */ + public const CONNECTION_LOST = 'connectionLost'; + /** * Connect to the MCP server and perform initialization handshake. * @@ -54,12 +59,9 @@ public function send(string $data): void; * The transport starts the fiber, runs its internal loop, and resumes * the fiber when a response arrives or timeout occurs. * - * During the loop, the transport checks session for progress data and - * executes the callback if provided. - * * @param McpFiber $fiber The fiber to execute * @param (callable(float $progress, ?float $total, ?string $message): void)|null $onProgress - * Optional callback for progress updates + * Unused: the protocol delivers progress as it is parsed * * @return Response>|Error The response or error */ diff --git a/tests/Integration/ElicitationTest.php b/tests/Integration/ElicitationTest.php index dc1684796..b421ff5ee 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; @@ -87,6 +89,24 @@ 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() + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->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 776f45131..0760289b9 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 000000000..fd0cbcdab --- /dev/null +++ b/tests/Integration/Fixture/http.php @@ -0,0 +1,40 @@ +setServerInfo('integration-server', '1.0.0') + ->setInstructions('Be brief.') + // `php -S` keeps nothing between requests. + ->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 7cca7c8f7..c2539cabf 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,29 @@ 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]; + yield 'both unconfigured' => [null, null, ProtocolVersion::latestHandshake()]; // Whichever end of the supported range it sits at. foreach (ProtocolVersion::handshakeVersions() as $version) { @@ -59,23 +59,50 @@ 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]; + + yield 'server without the modern era' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_11_25, true]; + yield 'server without the modern era, client falling back further' => [ProtocolVersion::V2026_07_28, null, ProtocolVersion::V2025_06_18, true, ProtocolVersion::V2025_06_18]; + yield 'server without the modern era pinning a revision' => [ProtocolVersion::V2026_07_28, 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', $this->clientBuilder()->setProtocolVersion(ProtocolVersion::V2026_07_28), self::environment(null, true)); + + $this->assertSame(ProtocolVersion::V2025_11_25, $client->getProtocolVersion()); + $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()->setProtocolVersion(ProtocolVersion::V2026_07_28)->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()); $serverInfo = $client->getServerInfo(); $this->assertNotNull($serverInfo); $this->assertSame('integration-server', $serverInfo->name); @@ -84,6 +111,35 @@ public function testServerInfoIsExchanged(): void $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 handshake carries the server capabilities to the client')] public function testServerCapabilitiesAreExchanged(): void { diff --git a/tests/Integration/HttpNegotiationTest.php b/tests/Integration/HttpNegotiationTest.php new file mode 100644 index 000000000..fc08bc069 --- /dev/null +++ b/tests/Integration/HttpNegotiationTest.php @@ -0,0 +1,163 @@ +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, ProtocolVersion::V2026_07_28, 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, ProtocolVersion::V2026_07_28, 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, ProtocolVersion::V2026_07_28, 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()); + + $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()->setProtocolVersion(ProtocolVersion::V2026_07_28)->setFallbackProtocolVersion(null)->setMaxRetries(0)->build(); + + $this->expectException(ConnectionException::class); + $this->expectExceptionMessage('without a handshake fallback: "server/discover" was answered with error -32022'); + + $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 4ad33d422..05d05f359 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,15 @@ protected function clientBuilder(): ClientBuilder ->setRequestTimeout(self::TIMEOUT); } + /** + * @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 09487185a..6da507422 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,23 @@ 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()); + + $client->setLoggingLevel(LoggingLevel::Info); + $client->callTool('work'); $this->assertSame([[LoggingLevel::Info, 'starting work']], $logged); diff --git a/tests/Integration/SamplingTest.php b/tests/Integration/SamplingTest.php index 9de36bf7a..2b61acb05 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; @@ -98,6 +100,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/Unit/Client/ConfigurationTest.php b/tests/Unit/Client/ConfigurationTest.php index ae50a4991..52111d4ae 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,23 @@ public static function provideNonPositiveTimeouts(): iterable yield 'negative' => [-1]; } + #[TestDox('falls back to the newest handshake revision by default')] + public function testDefaultsToTheNewestHandshakeFallback(): void + { + $config = new Configuration(new Implementation('client', '1.0.0'), new ClientCapabilities()); + + $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 ad2503425..ece4b405b 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -21,6 +21,7 @@ use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Enum\LoggingLevel; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; @@ -83,32 +84,166 @@ 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()); - $this->assertNull($protocol->getState()->getServerCapabilities()); + + // 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('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)); + + // Times the probe out by hand, like 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 connection lost during the probe fails the attempt instead of falling back')] + public function testLostConnectionDuringProbeDoesNotFallBack(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, ignoreDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $fiber = new \Fiber(static fn () => $protocol->initialize($config)); + $suspended = $fiber->start(); + $fiber->resume(new Error($suspended['request_id'], Error::INTERNAL_ERROR, 'The server process closed its output; it is no longer running.', [TransportInterface::CONNECTION_LOST => true])); + + $this->assertTrue($fiber->isTerminated()); + $error = $fiber->getReturn(); + $this->assertInstanceOf(Error::class, $error); + $this->assertStringContainsString('no longer running', $error->message); + $this->assertSame(['server/discover'], $transport->methods); } - #[TestDox('refuses to continue when discovery shows the server has no modern revision')] - public function testDiscoveryWithoutAModernRevisionFails(): void + #[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()); + } + + /** + * @return iterable + */ + public static function provideFallbacks(): iterable + { + yield 'with a fallback' => [ProtocolVersion::V2025_11_25, true]; + yield 'modern-only' => [null, false]; + } + + #[TestDox('an answer to the probe that names no revision is no evidence of the modern era ($_dataName)')] + #[DataProvider('provideFallbacks')] + public function testDiscoveryWithoutRevisionsIsNotModernEvidence(?ProtocolVersion $fallback, bool $connects): void + { + $transport = new RecordingTransport(ProtocolVersion::V2025_11_25->value, discoveryWithoutVersions: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28, $fallback)); + + $result = $protocol->initialize($config); + + $this->assertNotSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); + $this->assertSame($connects, $result instanceof Response); + $this->assertSame($connects, $protocol->getState()->isInitialized()); + } + + #[TestDox('a handshake refused because the server already settled on the modern era probes again')] + public function testLateModernSettlementIsProbedAgain(): void + { + $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')] @@ -213,12 +348,32 @@ public function testReconnectResetsToolCatalog(): void $this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect'); } + #[TestDox('reconnecting forgets the log level asked for on the previous connection')] + public function testReconnectResetsLogLevel(): void + { + $protocol = new Protocol(); + $config = $this->createConfiguration(ProtocolVersion::V2026_07_28); + + $protocol->connect(new RecordingTransport(ProtocolVersion::V2026_07_28->value), $config); + $protocol->initialize($config); + $protocol->setLogLevel(LoggingLevel::Debug); + + $protocol->connect($transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value), $config); + $protocol->initialize($config); + + $this->assertNotSame([], $transport->metas); + foreach ($transport->metas as $meta) { + $this->assertArrayNotHasKey(RequestMeta::LOG_LEVEL, $meta); + } + } + #[TestDox('an empty inputResponses map is retried as a JSON object, never an array')] 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); @@ -392,12 +547,13 @@ private function waitPastDeadline(): void $this->assertGreaterThanOrEqual($boundary, microtime(true), 'The clock must pass the per-call deadline for this assertion to be about the deadline.'); } - 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, ); } } @@ -421,10 +577,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' => []]); @@ -495,9 +657,16 @@ 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, + private readonly bool $discoveryWithoutVersions = false, ) { } @@ -520,6 +689,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, @@ -534,19 +710,33 @@ 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; + } + + if ($this->discoveryWithoutVersions) { + $this->answer($message['id'], []); 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'], ]); @@ -564,6 +754,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 c18aac490..207923b89 100644 --- a/tests/Unit/Client/Transport/HttpTransportTest.php +++ b/tests/Unit/Client/Transport/HttpTransportTest.php @@ -13,13 +13,20 @@ use Mcp\Client; use Mcp\Client\CancellationTokenInterface; +use Mcp\Client\Configuration; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\HttpTransport; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; use Mcp\Exception\RequestCancelledException; use Mcp\Exception\TimeoutException; +use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\Notification\LoggingMessageNotification; use Nyholm\Psr7\Factory\Psr17Factory; use Nyholm\Psr7\Response; use PHPUnit\Framework\Attributes\DataProvider; @@ -87,8 +94,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(); @@ -131,8 +140,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(); @@ -145,6 +156,169 @@ 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 'a body under the request id that is no JSON-RPC message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"message":"Bad Request"}']; + yield 'an error under the request id without a message' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"error":{"code":-32600}}']; + yield 'a null result under the request id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":null}']; + yield 'a scalar result under the request id' => [400, ['Content-Type' => 'application/json'], '{"jsonrpc":"2.0","id":1,"result":"nope"}']; + yield 'an error under the request id without the JSON-RPC version' => [400, ['Content-Type' => 'application/json'], '{"id":1,"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') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->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') + ->setProtocolVersion(ProtocolVersion::V2026_07_28) + ->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); + } + + /** + * @return iterable + */ + public static function refusedResponseProvider(): iterable + { + yield 'an empty body' => ['']; + yield 'an error under the same id' => ['{"jsonrpc":"2.0","id":1,"error":{"code":-32600,"message":"Bad Request"}}']; + } + + #[DataProvider('refusedResponseProvider')] + #[TestDox('a refused answer to a server request never answers a client request sharing its id: $_dataName')] + public function testRefusedResponseLeavesClientRequestsAlone(string $body): void + { + $httpClient = $this->createMock(ClientInterface::class); + $httpClient->method('sendRequest')->willReturn(new Response(404, ['Content-Type' => 'application/json'], $body)); + + $transport = new HttpTransport('https://example.test/mcp', [], $httpClient, $this->factory, $this->factory); + $state = new ClientState(); + $transport->setState($state); + $dispatched = []; + $transport->onMessage(static function (string $message) use (&$dispatched): void { + $dispatched[] = $message; + }); + $state->addPendingRequest(1, 120); + + $transport->send('{"jsonrpc":"2.0","id":1,"result":{}}'); + + $this->assertNull($state->consumeResponse(1)); + $this->assertSame([], $dispatched); + } + + #[TestDox('progress and other notifications on one stream reach the caller in the order they were sent')] + public function testProgressKeepsItsPlaceAmongNotifications(): void + { + $order = []; + $protocol = new Protocol(notificationHandlers: [new LoggingNotificationHandler(static function (LoggingMessageNotification $n) use (&$order): void { + $order[] = 'log '.$n->data; + })]); + $transport = $this->createTransport(); + $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); + $protocol->setProgressCallback(static function (float $progress) use (&$order): void { + $order[] = 'progress '.$progress; + }); + + $this->setActiveStream($transport, $this->factory->createStream( + 'data: {"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'."\n\n" + .'data: {"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'."\n\n", + )); + $this->invokeProcessSseStream($transport); + + $this->assertSame(['progress 1', 'log done'], $order); + } + #[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 ed7dc405e..5b85a05ee 100644 --- a/tests/Unit/Client/Transport/StdioTransportTest.php +++ b/tests/Unit/Client/Transport/StdioTransportTest.php @@ -11,10 +11,21 @@ namespace Mcp\Tests\Unit\Client\Transport; +use Mcp\Client; +use Mcp\Client\Configuration; +use Mcp\Client\Handler\Notification\LoggingNotificationHandler; +use Mcp\Client\Protocol; use Mcp\Client\State\ClientState; use Mcp\Client\Transport\StdioTransport; +use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\ClientCapabilities; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Notification\LoggingMessageNotification; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -70,6 +81,90 @@ 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); + $this->assertSame([TransportInterface::CONNECTION_LOST => true], $response->data); + } + + /** + * @return iterable + */ + public static function progressAmongNotificationsProvider(): iterable + { + $progress = '{"jsonrpc":"2.0","method":"notifications/progress","params":{"progressToken":"t","progress":1}}'; + $log = '{"jsonrpc":"2.0","method":"notifications/message","params":{"level":"info","data":"done"}}'; + + yield 'one per line' => [$progress."\n".$log."\n"]; + yield 'in one batch' => ['['.$progress.','.$log.']'."\n"]; + } + + #[DataProvider('progressAmongNotificationsProvider')] + #[TestDox('progress and other notifications read in one go reach the caller in the order they were sent: $_dataName')] + public function testProgressKeepsItsPlaceAmongNotifications(string $lines): void + { + $order = []; + $protocol = new Protocol(notificationHandlers: [new LoggingNotificationHandler(static function (LoggingMessageNotification $n) use (&$order): void { + $order[] = 'log '.$n->data; + })]); + $transport = new StdioTransport(command: 'true'); + $protocol->connect($transport, new Configuration(new Implementation('test', '1.0.0'), new ClientCapabilities())); + $protocol->setProgressCallback(static function (float $progress) use (&$order): void { + $order[] = 'progress '.$progress; + }); + + // The partial line keeps the stream open, so the read is about ordering and not the server leaving. + $this->setStdout($transport, $this->stream($lines.'{"partial":')); + $this->invokeProcessInput($transport); + + $this->assertSame(['progress 1', 'log done'], $order); + } + + #[TestDox('a final answer read together with the end of the output is kept, not overwritten by the failure')] + public function testFinalAnswerBeforeClosedOutputIsKept(): void + { + $transport = new StdioTransport(command: 'true'); + $state = new ClientState(); + $transport->setState($state); + $transport->onMessage(static function (string $message) use ($state): void { + $state->storeResponse(1, json_decode($message, true, flags: \JSON_THROW_ON_ERROR)); + }); + $state->addPendingRequest(1, 120); + + $this->setStdout($transport, $this->stream('{"jsonrpc":"2.0","id":1,"result":{}}'."\n")); + $this->invokeProcessInput($transport); + + $this->assertInstanceOf(Response::class, $state->consumeResponse(1)); + } + + #[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 3ecdb0026..424614908 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; @@ -55,6 +56,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()->setProtocolVersion(ProtocolVersion::V2026_07_28)->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 { @@ -171,6 +185,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'; @@ -189,7 +206,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]) { @@ -224,6 +241,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' => [