From 77aaa3bc00d7b3e1f68adc8106bbd879bc9be5fd Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:38:06 +0200 Subject: [PATCH 01/11] [Server] Echo the request id when refusing a request without a session --- src/Server/Protocol.php | 11 ++++++++++- tests/Unit/Server/ProtocolTest.php | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index 19e88852..feba82d9 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -722,7 +722,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/tests/Unit/Server/ProtocolTest.php b/tests/Unit/Server/ProtocolTest.php index 44422992..0946b9c7 100644 --- a/tests/Unit/Server/ProtocolTest.php +++ b/tests/Unit/Server/ProtocolTest.php @@ -243,7 +243,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) { From b8e2dd89c1315eb7953f70b1bb668db39b6aade9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:41:11 +0200 Subject: [PATCH 02/11] [Server] Answer modern-era messages from a transport without headers --- src/Server/Stateless/StatelessProtocol.php | 44 ++++++++-- .../Stateless/StatelessProtocolTest.php | 80 +++++++++++++++++++ 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 4d6a8917..6bbc9d7e 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -154,6 +154,28 @@ private function requiresTransportHeaders(): bool * @param AccessToken|null $accessToken the token the request was authorized with, if the transport authorizes */ public function handle(string $body, array $headers = [], ?AccessToken $accessToken = null): StatelessResult + { + return $this->answer($body, $headers, true, $accessToken); + } + + /** + * 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, ?AccessToken $accessToken = null): StatelessResult { try { /** @var array|null $decoded */ @@ -209,13 +231,13 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo 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); } @@ -224,7 +246,7 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo 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)) { @@ -234,7 +256,7 @@ public function handle(string $body, array $headers = [], ?AccessToken $accessTo ); } - return $this->dispatch($method, $decoded, $meta, $id, self::acceptsEventStream($headers), $accessToken); + return $this->dispatch($method, $decoded, $meta, $id, !$headerLayer || self::acceptsEventStream($headers), $accessToken); } /** @@ -267,14 +289,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), @@ -307,8 +329,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); @@ -317,7 +341,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 [ @@ -352,6 +376,10 @@ private function listen(?array $params, string|int $id): StatelessResult yield null; + if (!$paced) { + continue; + } + if (connection_aborted()) { return; } diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 5770b178..d0b2312b 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -1016,6 +1016,86 @@ 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()); + + $stream = $result->frames; + $this->assertNotNull($stream); + + $frames = $stream(); + $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 { From fd40b9d0c78a9baf9f7527807a4c2f2e3a7aa3d2 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:41:11 +0200 Subject: [PATCH 03/11] [Server] Serve both protocol eras over stdio --- src/Server/Transport/StdioTransport.php | 177 ++++++++++++++- .../Server/Transport/StdioDualEraTest.php | 206 ++++++++++++++++++ 2 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 tests/Unit/Server/Transport/StdioDualEraTest.php diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 92a49887..524d295f 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -12,32 +12,65 @@ 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 * * @phpstan-import-type McpFiber from TransportInterface * * @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; /** @var positive-int */ private readonly int $maxLineBytes; + 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. + * + * @var array> + */ + private array $streams = []; + /** * @param resource $input * @param resource $output @@ -55,6 +88,8 @@ 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)); } @@ -62,6 +97,11 @@ public function __construct( $this->maxLineBytes = $maxLineBytes; } + public function connectStateless(StatelessProtocol $protocol): void + { + $this->stateless = $protocol; + } + public function send(string $data, array $context): void { if (isset($context['session_id'])) { @@ -79,6 +119,7 @@ public function listen(): int while (!feof($this->input) && RunnerState::RUNNING === $this->runnerControl->getState()) { $this->processInput(); $this->processFiber(); + $this->processStreams(); $this->flushOutgoingMessages(); } @@ -125,8 +166,140 @@ 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); + + if (null !== $request && 'initialize' === ($request['method'] ?? null)) { + $offered = $request['params']['protocolVersion'] ?? null; + + $this->writeError(Error::forUnsupportedProtocolVersion(\is_string($offered) ? $offered : '', $this->stateless->supportedVersions(), $request['id'])); + + return; } + + // 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[$requestId])) { + unset($this->streams[$requestId]); + $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[$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.', ['request_id' => $id, 'exception' => $e]); + unset($this->streams[$id]); + + continue; + } + + if (!$frames->valid()) { + unset($this->streams[$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/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php new file mode 100644 index 00000000..3a07e71d --- /dev/null +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -0,0 +1,206 @@ +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']); + } + + 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+'); + $this->assertNotFalse($input); + // A file rather than memory: running the server closes its streams. + $outputFile = tempnam(sys_get_temp_dir(), 'mcp-stdio'); + $output = fopen($outputFile, 'w'); + $this->assertNotFalse($output); + + 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))), + ); + } +} From 066b809379b38b63c92a777e152905b476e13dc1 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:57:02 +0200 Subject: [PATCH 04/11] [Server] Name the served revisions when refusing a bare initialize --- src/Server/Stateless/StatelessProtocol.php | 11 ++++++++++ src/Server/Transport/StdioTransport.php | 8 -------- .../Stateless/StatelessProtocolTest.php | 20 ++++++++++++++++++- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index 6bbc9d7e..655428b7 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -225,6 +225,17 @@ private function answer(string $body, array $headers, bool $headerLayer, ?Access 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) { diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 524d295f..c96934f6 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -228,14 +228,6 @@ private function routeModern(string $message, mixed $decoded, ?array $request): { \assert(null !== $this->stateless); - if (null !== $request && 'initialize' === ($request['method'] ?? null)) { - $offered = $request['params']['protocolVersion'] ?? null; - - $this->writeError(Error::forUnsupportedProtocolVersion(\is_string($offered) ? $offered : '', $this->stateless->supportedVersions(), $request['id'])); - - return; - } - // 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'])) { diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index d0b2312b..da42dc61 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. * From cd4d75c8fa8026473a810a2a93fe38d32c467842 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 00:57:03 +0200 Subject: [PATCH 05/11] [Server] Cover subscriptions/listen over stdio --- .../Server/Transport/StdioDualEraTest.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 3a07e71d..94cade58 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -13,6 +13,7 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Error; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\Builder; use Mcp\Server\RequestContext; @@ -111,6 +112,25 @@ public function testModernProgressIsStreamed(): void $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); + } + private static function builder(): Builder { return Server::builder() From 4736c1b16c75a8bd59e82a79f518db76c95d4888 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:05:12 +0200 Subject: [PATCH 06/11] [Server] Keep string and integer request ids apart on stdio --- src/Server/Transport/StdioTransport.php | 18 +++++++++---- .../Server/Transport/StdioDualEraTest.php | 26 +++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index c96934f6..442815c7 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -67,7 +67,10 @@ class StdioTransport extends BaseTransport implements StatelessAwareTransportInt * answer: a `subscriptions/listen` for as long as it lasts, and a request * whose handler streams notifications until its result is in. * - * @var array> + * 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 = []; @@ -233,8 +236,8 @@ private function routeModern(string $message, mixed $decoded, ?array $request): 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[$requestId])) { - unset($this->streams[$requestId]); + 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]); } @@ -249,7 +252,7 @@ private function routeModern(string $message, mixed $decoded, ?array $request): if ($result->isStream()) { \assert(null !== $result->frames && null !== $request); - $this->streams[$request['id']] = ($result->frames)(); + $this->streams[self::streamKey($request['id'])] = ($result->frames)(); return; } @@ -277,7 +280,7 @@ private function processStreams(): void $this->writeLine(json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)); } } catch (\Throwable $e) { - $this->logger->error('StdioTransport ended a stream that failed.', ['request_id' => $id, 'exception' => $e]); + $this->logger->error('StdioTransport ended a stream that failed.', ['stream' => $id, 'exception' => $e]); unset($this->streams[$id]); continue; @@ -289,6 +292,11 @@ private function processStreams(): void } } + 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)); diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 94cade58..69cda116 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -131,6 +131,32 @@ public function testListenStreamIsAcknowledged(): void $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+'); + $this->assertNotFalse($input); + $this->assertNotFalse($output); + + 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() From 68fa9a9bac3f71b114a140a443d3f952b6f227fa Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:12:24 +0200 Subject: [PATCH 07/11] [Docs] Document both protocol eras over stdio --- CHANGELOG.md | 2 ++ docs/protocol-versions.md | 5 ++++- docs/run/protocol-eras.md | 25 +++++++++++++++++++++++++ examples/server/bootstrap.php | 8 ++++---- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1ea471..13902ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,8 @@ All notable changes to `mcp/sdk` will be documented in this file. * [BC Break] `ProtectedResourceMetadata` requires `$resource`, serves at the path derived from it (RFC 9728 §3.1) and requires https except for loopback hosts; drops localized, policy, ToS, extra fields and `$metadataPaths`. * [BC Break] Add `ScopePolicy` as third argument of `AuthorizationMiddleware`, answering `403 insufficient_scope` per method and tool, with scope hierarchies; the `resource_metadata` challenge URL comes from the configured resource instead of the `Host` header. * Expose `WWW-Authenticate` in the default `CorsMiddleware`. +* 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. +* 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. 0.8.0 ----- diff --git a/docs/protocol-versions.md b/docs/protocol-versions.md index 994eec53..4dec7b83 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. @@ -150,7 +151,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 a5200a8c..9a667b91 100644 --- a/docs/run/protocol-eras.md +++ b/docs/run/protocol-eras.md @@ -104,6 +104,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 4b53e610..1c61fba0 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 */ From 97f653bb8c885f1038abad4d3e069a68ad754b2c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Wed, 7 Oct 2026 23:50:26 +0200 Subject: [PATCH 08/11] [Server] Write a stream frame before resuming its handler on stdio --- src/Server/Transport/StdioTransport.php | 9 +++++-- .../Server/Transport/StdioDualEraTest.php | 25 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 442815c7..6dc5164d 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -271,13 +271,18 @@ private function processStreams(): void try { while ($frames->valid()) { $frame = $frames->current(); + + // Written before the stream is resumed: resuming runs the + // handler on to its next frame, which may take a while. + if (null !== $frame) { + $this->writeLine(json_encode($frame, \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES)); + } + $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]); diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 69cda116..7c3c8950 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -157,6 +157,31 @@ public function testStreamsAreKeyedByIdType(): void $this->assertSame(['s:5'], array_keys($streams)); } + #[TestDox('a frame is written before its stream is resumed, so a slow handler does not hold back its progress')] + public function testFrameIsWrittenBeforeTheStreamResumes(): void + { + $output = fopen('php://memory', 'r+'); + $input = fopen('php://memory', 'r'); + $this->assertNotFalse($output); + $this->assertNotFalse($input); + $writtenBeforeResuming = null; + + $frames = (static function () use ($output, &$writtenBeforeResuming): \Generator { + yield ['jsonrpc' => '2.0', 'method' => 'notifications/progress', 'params' => ['progressToken' => 'p', 'progress' => 1]]; + + // Where a handler would carry on with its slow work. + $writtenBeforeResuming = ftell($output) > 0; + + yield ['jsonrpc' => '2.0', 'id' => 1, 'result' => []]; + })(); + + $transport = new StdioTransport($input, $output); + (new \ReflectionProperty($transport, 'streams'))->setValue($transport, ['i:1' => $frames]); + (new \ReflectionMethod($transport, 'processStreams'))->invoke($transport); + + $this->assertTrue($writtenBeforeResuming); + } + private static function builder(): Builder { return Server::builder() From c745dac960a8cdd07e73d4d438b3a17b6e40a1a9 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 8 Oct 2026 00:47:06 +0200 Subject: [PATCH 09/11] [Server] Leave the stdio era open after refusing an unserved revision --- src/Server/Transport/StdioTransport.php | 9 ++++++ .../Server/Transport/StdioDualEraTest.php | 29 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 6dc5164d..7de2ee81 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -200,6 +200,15 @@ private function route(string $message): void return; } + if ($classification->modern && !\in_array(ProtocolVersion::tryFrom((string) $classification->claimedVersion), $this->stateless->supportedVersions(), true)) { + // The modern leg refuses it, naming what it serves; a client + // with no revision in common falls back to the handshake, so + // the era stays open for that. + $this->routeModern($message, $decoded, $request); + + return; + } + $this->modern = $classification->modern; $this->logger->info('StdioTransport settled the connection era.', [ diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 7c3c8950..a8984bf2 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -90,6 +90,31 @@ public function testHandshakeOnlyServerRefusesTheProbe(): void $this->assertSame(ProtocolVersion::V2025_11_25->value, $answers[2]['result']['protocolVersion']); } + #[TestDox('a probe claiming an unserved revision is refused, naming the served ones, and the handshake still follows')] + public function testUnservedModernProbeLeavesTheEraOpenForTheHandshake(): void + { + $answers = $this->serve(self::builder(), [ + self::modern(1, 'server/discover', version: '2027-01-01'), + self::initialize(2), + ]); + + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answers[1]['error']['code']); + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answers[1]['error']['data']['supported']); + $this->assertSame(ProtocolVersion::V2025_11_25->value, $answers[2]['result']['protocolVersion']); + } + + #[TestDox('a probe claiming an unserved revision leaves the modern era open to a served one')] + public function testUnservedModernProbeLeavesTheEraOpenForAServedRevision(): void + { + $answers = $this->serve(self::builder(), [ + self::modern(1, 'server/discover', version: '2027-01-01'), + self::modern(2, 'tools/call', ['name' => 'echo', 'arguments' => ['text' => 'hi']]), + ]); + + $this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answers[1]['error']['code']); + $this->assertSame('hi', $answers[2]['result']['content'][0]['text']); + } + #[TestDox('a probe without an envelope gets an error it can correlate, not silence')] public function testUnenvelopedRequestBeforeTheHandshakeIsAnswered(): void { @@ -200,10 +225,10 @@ private static function builder(): Builder * * @return array */ - private static function modern(int $id, string $method, array $params = []): array + private static function modern(int $id, string $method, array $params = [], ?string $version = null): array { $params['_meta'] = [ - RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::PROTOCOL_VERSION => $version ?? ProtocolVersion::V2026_07_28->value, RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), ...($params['_meta'] ?? []), ]; From 94c09a53a80fa12e515bcaf832dc75e58aa58c2c Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 8 Oct 2026 00:48:54 +0200 Subject: [PATCH 10/11] [Server] Settle the stdio era on a request, not a response --- src/Server/Transport/StdioTransport.php | 5 +++-- tests/Unit/Server/Transport/StdioDualEraTest.php | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index 7de2ee81..eff73bdc 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -189,7 +189,8 @@ private function route(string $message): void } $decoded = json_decode($message, true); - $request = \is_array($decoded) && !array_is_list($decoded) && isset($decoded['id']) ? $decoded : null; + // A response carries an id too, but only a request may settle the era. + $request = \is_array($decoded) && !array_is_list($decoded) && isset($decoded['id']) && \is_string($decoded['method'] ?? null) ? $decoded : null; if (null === $this->modern && null !== $request) { if ($classification->modern && null === $this->stateless) { @@ -213,7 +214,7 @@ private function route(string $message): void $this->logger->info('StdioTransport settled the connection era.', [ 'era' => $this->modern ? 'modern' : 'handshake', - 'opened_with' => $request['method'] ?? null, + 'opened_with' => $request['method'], ]); } diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index a8984bf2..1aaa665c 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -125,6 +125,17 @@ public function testUnenvelopedRequestBeforeTheHandshakeIsAnswered(): void $this->assertSame(Error::INVALID_REQUEST, $answers[1]['error']['code']); } + #[TestDox('a response is not a request, so one arriving first leaves the era open')] + public function testLeadingResponseDoesNotSettleTheEra(): void + { + $answers = $this->serve(self::builder(), [ + ['jsonrpc' => '2.0', 'id' => 'stray', 'result' => []], + self::modern(1, 'server/discover'), + ]); + + $this->assertSame([ProtocolVersion::V2026_07_28->value], $answers[1]['result']['supportedVersions']); + } + #[TestDox('a modern request streams its progress on the shared channel before its result')] public function testModernProgressIsStreamed(): void { From 087aaacb320d26ce336d65423ea7551e00228f45 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 8 Oct 2026 00:47:38 +0200 Subject: [PATCH 11/11] [Docs] Say what notifications/cancelled stops over stdio --- docs/run/protocol-eras.md | 11 ++++++----- src/Server/Transport/StdioTransport.php | 6 ++++-- tests/Unit/Server/Transport/StdioDualEraTest.php | 12 ++++++++++++ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/docs/run/protocol-eras.md b/docs/run/protocol-eras.md index 9a667b91..c4585e3e 100644 --- a/docs/run/protocol-eras.md +++ b/docs/run/protocol-eras.md @@ -120,11 +120,12 @@ connection gets `-32022` naming the modern revisions, an enveloped request on a 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. +On a modern connection everything shares the one channel, and requests are served one at a +time: a request's progress and log messages are written as its handler emits them, ahead of its +result, and the next message is read once that result is out. A `subscriptions/listen` is the +long-lived exception: it stays open alongside other requests, each of its messages tagged with +the subscription id, until the client sends `notifications/cancelled` for it, since there is no +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. diff --git a/src/Server/Transport/StdioTransport.php b/src/Server/Transport/StdioTransport.php index eff73bdc..f77fffad 100644 --- a/src/Server/Transport/StdioTransport.php +++ b/src/Server/Transport/StdioTransport.php @@ -241,8 +241,10 @@ private function routeModern(string $message, mixed $decoded, ?array $request): { \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. + // stdio has no stream to close, so this notification is how a client + // ends a subscriptions/listen; nothing more may be sent for it. Any + // other request has already run to its result by the time a + // cancellation for it is read. if (\is_array($decoded) && self::CANCELLED_NOTIFICATION === ($decoded['method'] ?? null) && !isset($decoded['id'])) { $requestId = $decoded['params']['requestId'] ?? null; diff --git a/tests/Unit/Server/Transport/StdioDualEraTest.php b/tests/Unit/Server/Transport/StdioDualEraTest.php index 1aaa665c..88163573 100644 --- a/tests/Unit/Server/Transport/StdioDualEraTest.php +++ b/tests/Unit/Server/Transport/StdioDualEraTest.php @@ -148,6 +148,18 @@ public function testModernProgressIsStreamed(): void $this->assertSame('counted', $lines[2]['result']['content'][0]['text']); } + #[TestDox('a modern request is served to its result before the next message is read, so a cancel for it comes too late')] + public function testRequestCompletesBeforeItsCancelIsRead(): void + { + $lines = $this->exchange(self::builder(), [ + self::modern(1, 'tools/call', ['name' => 'count', 'arguments' => [], '_meta' => ['progressToken' => 'p']]), + ['jsonrpc' => '2.0', 'method' => 'notifications/cancelled', 'params' => ['requestId' => 1]], + ]); + + $this->assertCount(3, $lines); + $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 {