diff --git a/CHANGELOG.md b/CHANGELOG.md index 5467968e..06280c6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file. 0.8.0 ----- +* Speak the 2026-07-28 lifecycle from the client: `Client` opens with `server/discover` instead of `initialize` on that revision, stamps each request's `_meta` with the protocol version, its own capabilities and client info, and sends the standard `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers an intermediary routes on — the last from the new `Mcp\Client\Stateless\ToolCatalog`, which knows from the tool list which arguments a call must mirror. An `input_required` result is answered automatically by `InputRequestResolver`, which asks the host's elicitation, sampling and roots handlers and retries the same request with `inputResponses` and the `requestState` the server sent. `Mcp\Schema\Wire\McpHeader` holds the header names and the `=?base64?…?=` sentinel both sides share. * Serve both protocol eras from one endpoint: `StreamableHttpTransport` classifies each request — a `2026-07-28` envelope, an `initialize` handshake, or a session-bound follow-up — through the new `Mcp\Server\Wire\InboundClassifier` and routes it to the dispatcher that owns it, so a single URL answers a modern client and a handshake-era one alike. `Server::builder()->build()` now carries both dispatchers; `Builder::withoutModernEra()` opts out and `Builder::setModernVersions()` narrows what the modern leg answers for. `Mcp\Server\InputRequiredShim` lets a handler written for multi round-trip requests also serve a handshake-era client, by turning each ask into the request/response exchange that era has. * Carry W3C trace context through a request (SEP-414): `traceparent`, `tracestate` and `baggage` in a request's `_meta` are exposed to handlers as `RequestContext::getTraceContext()` and echoed onto the notifications that request causes, so a span stays joined across the response stream. Values pass through exactly as they arrived, and no OpenTelemetry dependency is added. * Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it". diff --git a/examples/server/client-communication/ClientAwareService.php b/examples/server/client-communication/ClientAwareService.php index 1e895c48..01cfc07d 100644 --- a/examples/server/client-communication/ClientAwareService.php +++ b/examples/server/client-communication/ClientAwareService.php @@ -12,8 +12,13 @@ namespace Mcp\Example\Server\ClientCommunication; use Mcp\Capability\Attribute\McpTool; +use Mcp\Schema\Content\SamplingMessage; use Mcp\Schema\Content\TextContent; use Mcp\Schema\Enum\LoggingLevel; +use Mcp\Schema\Enum\Role; +use Mcp\Schema\Request\CreateSamplingMessageRequest; +use Mcp\Schema\Request\ListRootsRequest; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -31,10 +36,14 @@ public function __construct( * Demonstrates the server side of the "roots" client capability: the tool * issues a roots/list request that the client answers from its own handler. * - * @return array{status: string, message: string, roots?: list} + * Written the 2026-07-28 way: the ask is returned and the answer read off + * the retry. A handshake-era client reaches the same tool — the SDK sends + * the `roots/list` request over that connection and re-enters this method. + * + * @return array{status: string, message: string, roots?: list}|InputRequiredResult */ #[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')] - public function inspectWorkspaceRoots(RequestContext $context): array + public function inspectWorkspaceRoots(RequestContext $context): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); @@ -45,7 +54,11 @@ public function inspectWorkspaceRoots(RequestContext $context): array ]; } - $result = $clientGateway->listRoots(); + $result = $context->getInputContext()?->rootsResult('roots'); + + if (null === $result) { + return new InputRequiredResult(['roots' => new ListRootsRequest()]); + } $roots = []; foreach ($result->roots as $root) { @@ -62,35 +75,47 @@ public function inspectWorkspaceRoots(RequestContext $context): array } /** - * @return array{incident: string, recommended_actions: string, model: string} + * @return array{incident: string, recommended_actions: string, model: string}|InputRequiredResult */ #[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')] - public function coordinateIncident(RequestContext $context, string $incidentTitle): array + public function coordinateIncident(RequestContext $context, string $incidentTitle): array|InputRequiredResult { $clientGateway = $context->getClientGateway(); - $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); - $steps = [ - 'Collecting telemetry', - 'Assessing scope', - 'Coordinating responders', - ]; + // A retry re-enters this method from the top, so the triage work below + // must run only once: check for the answer first, before repeating logs, + // progress notifications and simulated work the client already saw. + $result = $context->getInputContext()?->samplingResult('recommendation'); - foreach ($steps as $index => $step) { - $progress = ($index + 1) / \count($steps); + if (null === $result) { + $clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle)); - $clientGateway->progress($progress, 1, $step); + $steps = [ + 'Collecting telemetry', + 'Assessing scope', + 'Coordinating responders', + ]; - usleep(180_000); // Simulate work being done - } + foreach ($steps as $index => $step) { + $progress = ($index + 1) / \count($steps); + + $clientGateway->progress($progress, 1, $step); - $prompt = \sprintf( - 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', - $incidentTitle, - implode(', ', $steps) - ); + usleep(180_000); // Simulate work being done + } - $result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]); + $prompt = \sprintf( + 'Provide a concise response strategy for incident "%s" based on the steps completed: %s.', + $incidentTitle, + implode(', ', $steps) + ); + + return new InputRequiredResult(['recommendation' => new CreateSamplingMessageRequest( + messages: [new SamplingMessage(Role::User, new TextContent($prompt))], + maxTokens: 350, + temperature: 0.5, + )]); + } $recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : ''; diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index aaa1328a..95a19d78 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,6 +17,9 @@ use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition; +use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Result\ElicitResult; +use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\RequestContext; use Psr\Log\LoggerInterface; @@ -43,10 +46,10 @@ public function __construct( * - String field with date format for reservation date * - Enum field for dietary restrictions with human-readable labels * - * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult */ #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] - public function bookRestaurant(RequestContext $context, string $restaurantName): array + public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -55,8 +58,6 @@ public function bookRestaurant(RequestContext $context, string $restaurantName): ]; } - $client = $context->getClientGateway(); - $this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName)); $schema = new ElicitationSchema( @@ -85,12 +86,19 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], required: ['party_size', 'date'], ); - $result = $client->elicit( - message: \sprintf('Please provide your reservation details for %s:', $restaurantName), - requestedSchema: $schema, - timeout: 120, + $result = $this->ask( + $context, + 'details', + \sprintf('Please provide your reservation details for %s:', $restaurantName), + $schema, ); + // Modern era, first round: the ask travels back as the result and the + // client retries this whole call carrying the answer. + if ($result instanceof InputRequiredResult) { + return $result; + } + if ($result->isDeclined()) { $this->logger->info('User declined to provide reservation details.'); @@ -154,10 +162,10 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], * * Demonstrates the simplest elicitation pattern - a yes/no confirmation. * - * @return array{status: string, message: string} + * @return array{status: string, message: string}|InputRequiredResult */ #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] - public function confirmAction(RequestContext $context, string $actionDescription): array + public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -166,8 +174,6 @@ public function confirmAction(RequestContext $context, string $actionDescription ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'confirm' => new BooleanSchemaDefinition( @@ -179,11 +185,17 @@ public function confirmAction(RequestContext $context, string $actionDescription required: ['confirm'], ); - $result = $client->elicit( - message: \sprintf('Are you sure you want to: %s?', $actionDescription), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'confirmation', + \sprintf('Are you sure you want to: %s?', $actionDescription), + $schema, ); + if ($result instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'not_confirmed', @@ -222,10 +234,10 @@ public function confirmAction(RequestContext $context, string $actionDescription * * Demonstrates elicitation with optional fields and enum with labels. * - * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult */ #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] - public function collectFeedback(RequestContext $context, string $topic): array + public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -234,8 +246,6 @@ public function collectFeedback(RequestContext $context, string $topic): array ]; } - $client = $context->getClientGateway(); - $schema = new ElicitationSchema( properties: [ 'rating' => new EnumSchemaDefinition( @@ -253,11 +263,17 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent' required: ['rating'], ); - $result = $client->elicit( - message: \sprintf('Please provide your feedback about: %s', $topic), - requestedSchema: $schema, + $result = $this->ask( + $context, + 'feedback', + \sprintf('Please provide your feedback about: %s', $topic), + $schema, ); + if ($result instanceof InputRequiredResult) { + return $result; + } + if (!$result->isAccepted()) { return [ 'status' => 'skipped', @@ -288,4 +304,26 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent' ], ]; } + + /** + * Ask the user one question. + * + * Written the way revision 2026-07-28 asks: the question is *returned*, the + * client answers it and retries the whole call, and the answer comes back + * through the input context under the same key. Nothing here names an era — + * on a handshake-era connection the SDK fulfils the same ask over that + * connection's own channel and re-enters the tool with the answer. + * + * The caller gets an {@see ElicitResult} once there is one, or an + * {@see InputRequiredResult} to hand straight back to its own caller. + */ + private function ask( + RequestContext $context, + string $key, + string $message, + ElicitationSchema $schema, + ): ElicitResult|InputRequiredResult { + return $context->getInputContext()?->elicitResult($key) + ?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]); + } } diff --git a/src/Client.php b/src/Client.php index ed5abc6f..532f60a2 100644 --- a/src/Client.php +++ b/src/Client.php @@ -175,7 +175,16 @@ public function listTools(?string $cursor = null): ListToolsResult $response = $this->sendRequest($request); - return ListToolsResult::fromArray($response->result); + $result = $response->result; + + // Filtered before parsing, because parsing is where a malformed + // `x-mcp-header` annotation throws. One broken definition must cost the + // caller that tool, not the whole listing (SEP-2243). + if (\is_array($result['tools'] ?? null)) { + $result['tools'] = $this->protocol->getToolCatalog()->record($result['tools']); + } + + return ListToolsResult::fromArray($result); } /** @@ -188,6 +197,15 @@ public function listTools(?string $cursor = null): ListToolsResult */ public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult { + $catalog = $this->protocol->getToolCatalog(); + + // A tool the listing showed to be malformed is refused here rather than + // sent: the client cannot produce the headers its annotations demand, + // so the call could only go out misdescribed (SEP-2243). + if ($catalog->isRejected($name)) { + throw new RuntimeException(\sprintf('Refusing to call tool "%s": its "x-mcp-header" annotations are invalid (%s).', $name, $catalog->reasonFor($name))); + } + $request = new CallToolRequest($name, $arguments); $response = $this->sendRequest($request, $onProgress); diff --git a/src/Client/Protocol.php b/src/Client/Protocol.php index 47acde7d..281aedc8 100644 --- a/src/Client/Protocol.php +++ b/src/Client/Protocol.php @@ -16,16 +16,25 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface; use Mcp\Client\State\ClientState; use Mcp\Client\State\ClientStateInterface; +use Mcp\Client\Stateless\HeaderFactory; +use Mcp\Client\Stateless\InputRequestResolver; +use Mcp\Client\Stateless\RequestEnvelope; +use Mcp\Client\Stateless\ToolCatalog; +use Mcp\Client\Transport\HeaderAwareTransportInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\JsonRpc\MessageFactory; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\Implementation; use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\Notification; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Notification\InitializedNotification; +use Mcp\Schema\Request\DiscoverRequest; use Mcp\Schema\Request\InitializeRequest; use Mcp\Schema\Result\InitializeResult; +use Mcp\Server\Stateless\RequestMeta; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; @@ -41,6 +50,16 @@ */ class Protocol { + /** + * How many times a request may be re-sent before the client gives up. + * + * Both loops that re-send are bounded by it: a server that keeps asking for + * input, and one that keeps rejecting the offered revision. Neither is + * expected to run more than a round or two, so the cap is only there to + * stop a broken or hostile server from spinning the client forever. + */ + private const MAX_ROUND_TRIPS = 10; + private ?TransportInterface $transport = null; private ClientStateInterface $state; private MessageFactory $messageFactory; @@ -49,6 +68,21 @@ class Protocol /** @var NotificationHandlerInterface[] */ private array $notificationHandlers; + /** Set only when the configured revision has no handshake. */ + private ?RequestEnvelope $envelope = null; + + private ?HeaderFactory $headers = null; + + private ToolCatalog $tools; + + private readonly InputRequestResolver $inputRequests; + + /** + * Progress tokens are only required to be unique within a connection, and + * a retry keeps the caller's one — the work being reported on is the same. + */ + private int $progressTokens = 0; + /** * @param RequestHandlerInterface[] $requestHandlers * @param NotificationHandlerInterface[] $notificationHandlers @@ -67,6 +101,20 @@ public function __construct( new ProgressNotificationHandler($this->state), ...$notificationHandlers, ]; + + $this->tools = new ToolCatalog($this->logger); + $this->inputRequests = new InputRequestResolver($this->requestHandlers, $this->logger); + } + + /** + * What the client knows about the server's tools, from `tools/list`. + * + * Kept on the protocol rather than the facade because it is what makes the + * SEP-2243 headers derivable at send time. + */ + public function getToolCatalog(): ToolCatalog + { + return $this->tools; } /** @@ -80,18 +128,57 @@ public function __construct( public function connect(TransportInterface $transport, Configuration $config): void { $this->transport = $transport; + + // A fresh catalog per connection: it is what a server told this client + // about its tools, and a server reached by reconnecting — the same one + // or another — has said nothing yet. + $this->tools = new ToolCatalog($this->logger); + + if ($config->protocolVersion->isModern()) { + $this->envelope = new RequestEnvelope( + $config->protocolVersion, + $config->capabilities, + $config->clientInfo, + ); + $this->headers = new HeaderFactory($this->tools); + } + $transport->setState($this->state); $transport->onInitialize(fn () => $this->initialize($config)); $transport->onMessage($this->processMessage(...)); $transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e])); + if ($transport instanceof HeaderAwareTransportInterface) { + $transport->onHeaders($this->headersFor(...)); + } + $this->logger->info('Protocol connected to transport', ['transport' => $transport::class]); } /** - * Perform the MCP initialization handshake. + * The headers belonging to an encoded message, for a transport that has any. * - * Sends InitializeRequest and waits for response, then sends InitializedNotification. + * @return array + */ + private function headersFor(string $payload): array + { + if (null === $this->headers || null === $this->envelope) { + return []; + } + + $decoded = json_decode($payload, true); + + return \is_array($decoded) + ? $this->headers->forMessage($decoded, $this->envelope->protocolVersion()) + : []; + } + + /** + * 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()}. * * @param Configuration $config The client configuration * @@ -99,18 +186,12 @@ public function connect(TransportInterface $transport, Configuration $config): v */ public function initialize(Configuration $config): Response|Error { - $offered = $config->protocolVersion; - if ($offered->isModern()) { - // Only handshake era spec versions need the initialize call, so if we - // end up here, we fall back to the latest handshake version. - $offered = ProtocolVersion::latestHandshake(); - - $this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [ - 'configured' => $config->protocolVersion->value, - 'offered' => $offered->value, - ]); + if (null !== $this->envelope) { + return $this->discover($config); } + $offered = $config->protocolVersion; + $request = new InitializeRequest( $offered->value, $config->capabilities, @@ -156,12 +237,127 @@ public function initialize(Configuration $config): Response|Error return $response; } + /** + * Stand in for the handshake in the modern era. + * + * There is nothing to negotiate: the revision travels on every request, so + * the connection is usable the moment the transport is. `server/discover` + * is only asked because the facade exposes `getServerInfo()`, and a server + * that will not answer it still serves every other method — so a failure + * here is logged and the connection proceeds. + * + * @return Response> + */ + private function discover(Configuration $config): Response + { + $this->state->setProtocolVersion($config->protocolVersion); + $this->state->setInitialized(true); + + $response = $this->request(new DiscoverRequest(), $config->initTimeout); + + if ($response instanceof Error) { + $this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [ + 'code' => $response->code, + 'message' => $response->message, + ]); + + return new Response(0, []); + } + + $this->readDiscovery($response->result); + + return $response; + } + + /** + * Read defensively: `server/discover` is optional, so a server may answer + * with something that is not a DiscoverResult at all, and none of it is + * load-bearing for the requests that follow. + * + * @param array $result + */ + private function readDiscovery(array $result): void + { + // Identity is wire vocabulary in this revision, so it rides in `_meta` + // rather than the result body. The top level is read as a fallback + // because that is where the handshake era put it. + $meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : []; + $serverInfo = $meta[RequestMeta::SERVER_INFO] ?? $result['serverInfo'] ?? null; + + if (\is_array($serverInfo)) { + try { + $this->state->setServerInfo(Implementation::fromArray($serverInfo)); + } catch (\Throwable $e) { + $this->logger->debug('Ignoring unreadable serverInfo from "server/discover".', ['exception' => $e]); + } + } + + if (\is_string($result['instructions'] ?? null)) { + $this->state->setInstructions($result['instructions']); + } + + $this->reconcileVersion($result['supportedVersions'] ?? null); + + $this->logger->info('Discovery complete', [ + 'supportedVersions' => $result['supportedVersions'] ?? null, + ]); + } + + /** + * Move to a revision the server actually speaks, if it said which. + * + * `server/discover` reports rather than negotiates, so a client that asked + * for something the server does not list learns it here — and learning it + * now is far better than a stream of refusals later. A server that stays + * silent about its versions is left alone; the method is optional and + * saying nothing is not the same as saying no. + */ + private function reconcileVersion(mixed $supportedVersions): void + { + if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) { + return; + } + + $current = $this->envelope->protocolVersion(); + + if (\in_array($current->value, $supportedVersions, true)) { + return; + } + + foreach ($supportedVersions as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + if (null === $version || !$version->isModern()) { + continue; + } + + $this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [ + 'configured' => $current->value, + 'using' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return; + } + + // 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)); + } + /** * Send a request to the server and wait for response. * * If a response is immediately available (sync HTTP), returns it. * Otherwise, suspends the Fiber and waits for the transport to resume it. * + * In the modern era this is also where the two loops that re-send live: + * answering a server's request for input (SEP-2322), and retrying under a + * revision the server accepts (SEP-2575). Both re-send the same call, so + * they belong together and above the single exchange. + * * @param Request $request The request to send * @param int $timeout The timeout in seconds * @param bool $withProgress Whether to attach a progress token to the request @@ -170,18 +366,126 @@ public function initialize(Configuration $config): Response|Error */ public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error { - $requestId = $this->state->nextRequestId(); - $request = $request->withId($requestId); + $payload = $request->withId(0)->jsonSerialize(); + unset($payload['id']); if ($withProgress) { - $progressToken = "prog-{$requestId}"; - $request = $request->withMeta(['progressToken' => $progressToken]); + $payload = self::withMeta($payload, ['progressToken' => 'prog-'.++$this->progressTokens]); + } + + if (null === $this->envelope) { + return $this->exchange($payload, $timeout); + } + + for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) { + $response = $this->exchange($payload, $timeout); + + if ($response instanceof Error) { + $retry = $this->withAcceptedVersion($response); + + if (null === $retry) { + return $response; + } + + continue; + } + + $asked = InputRequestResolver::asked($response->result); + + if (null === $asked) { + return $response; + } + + // A fresh `inputResponses`/`requestState` pair every round, never + // merged with the last: the answers belong to the ask that just + // arrived, and carrying an old one forward is how state leaks + // between rounds. + // + // Cast to object: inputResponses is a JSON object keyed by the + // server's ids, but a PHP array with no entries or with sequential + // numeric-string keys encodes as a JSON array instead. + $payload['params'] = [ + ...($payload['params'] ?? []), + 'inputResponses' => (object) $this->inputRequests->resolve($asked), + ]; + + unset($payload['params']['requestState']); + + // Echoed byte-for-byte, and only when the server sent one: the + // value is the server's to read, and inventing or reshaping it + // would break whatever it encodes. + if (\is_string($response->result['requestState'] ?? null)) { + $payload['params']['requestState'] = $response->result['requestState']; + } + + $this->logger->debug('Retrying request with resolved input', [ + 'method' => $payload['method'] ?? null, + 'round' => $attempt + 1, + ]); + } + + return Error::forInternalError(\sprintf('Server asked for input more than %d times without completing the request.', self::MAX_ROUND_TRIPS)); + } + + /** + * Switches the offered revision when the server refuses the current one, + * or null when there is nothing to retry with. + * + * @param Error $error the server's refusal + */ + private function withAcceptedVersion(Error $error): ?ProtocolVersion + { + if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code || null === $this->envelope) { + return null; } + $data = \is_array($error->data) ? $error->data : []; + $supported = \is_array($data['supported'] ?? null) ? $data['supported'] : []; + $current = $this->envelope->protocolVersion(); + + foreach ($supported as $candidate) { + $version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null; + + // Only another modern revision is reachable from here: falling back + // to a handshake era one would mean opening a connection this + // transport already decided it was not going to open. + if (null === $version || !$version->isModern() || $version === $current) { + continue; + } + + $this->logger->info('Server rejected the offered protocol revision, retrying with one it supports.', [ + 'offered' => $current->value, + 'retrying' => $version->value, + ]); + + $this->envelope = $this->envelope->withProtocolVersion($version); + $this->state->setProtocolVersion($version); + + return $version; + } + + return null; + } + + /** + * One request on the wire: assign an id, send, and wait for its answer. + * + * A retry gets a new id, because the previous one is spent — the server has + * already answered it, and reusing it would make the two indistinguishable. + * + * @param array $payload + * + * @return Response>|Error + */ + private function exchange(array $payload, int $timeout): Response|Error + { + $requestId = $this->state->nextRequestId(); + $payload['id'] = $requestId; + $this->state->addPendingRequest($requestId, $timeout); try { - $this->sendRequest($request); + $this->send($payload, 'request'); $immediate = $this->state->consumeResponse($requestId); if (null !== $immediate) { @@ -205,28 +509,48 @@ public function request(Request $request, int $timeout, bool $withProgress = fal } /** - * Send a request to the server. + * Send a notification to the server (fire and forget). */ - private function sendRequest(Request $request): void + public function sendNotification(Notification $notification): void { - $this->logger->debug('Sending request', [ - 'id' => $request->getId(), - 'method' => $request::getMethod(), + $this->send($notification->jsonSerialize(), 'notification'); + } + + /** + * Encode and hand a message to the transport, stamping the per-request + * envelope on the way out when the revision calls for one. + * + * @param array $payload + */ + private function send(array $payload, string $kind): void + { + if (null !== $this->envelope) { + $payload = $this->envelope->stamp($payload); + } + + $this->logger->debug('Sending '.$kind, [ + 'id' => $payload['id'] ?? null, + 'method' => $payload['method'] ?? null, ]); - $encoded = json_encode($request, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + $this->transport?->send(json_encode($payload, \JSON_THROW_ON_ERROR)); } /** - * Send a notification to the server (fire and forget). + * @param array $payload + * @param array $meta + * + * @return array */ - public function sendNotification(Notification $notification): void + private static function withMeta(array $payload, array $meta): array { - $this->logger->debug('Sending notification', ['method' => $notification::getMethod()]); + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $existing = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; - $encoded = json_encode($notification, \JSON_THROW_ON_ERROR); - $this->transport?->send($encoded); + $params['_meta'] = [...$existing, ...$meta]; + $payload['params'] = $params; + + return $payload; } /** diff --git a/src/Client/Stateless/HeaderFactory.php b/src/Client/Stateless/HeaderFactory.php new file mode 100644 index 00000000..3640cf93 --- /dev/null +++ b/src/Client/Stateless/HeaderFactory.php @@ -0,0 +1,75 @@ + + */ +final class HeaderFactory +{ + public function __construct( + private readonly ToolCatalog $tools, + ) { + } + + /** + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function forMessage(array $payload, ProtocolVersion $protocolVersion): array + { + $method = $payload['method'] ?? null; + + // A response to a server-initiated request carries no method to mirror; + // the version header is unconditional and still applies. + if (!\is_string($method)) { + return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value]; + } + + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : null; + + $headers = [ + McpHeader::PROTOCOL_VERSION => $protocolVersion->value, + McpHeader::METHOD => $method, + ]; + + if (null !== $name = McpHeader::nameFor($method, $params)) { + // Tool and prompt names are only SHOULD-constrained to header-safe + // characters and a resource URI is not constrained at all, so + // anything unsafe is wrapped rather than dropped or mangled. + $headers[McpHeader::NAME] = McpHeader::encode($name) ?? $name; + } + + if ('tools/call' === $method && \is_string($params['name'] ?? null)) { + $arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : []; + + foreach ($this->tools->headersFor($params['name'], $arguments) as $suffix => $value) { + $headers[McpHeader::PARAM_PREFIX.$suffix] = $value; + } + } + + return $headers; + } +} diff --git a/src/Client/Stateless/InputRequestResolver.php b/src/Client/Stateless/InputRequestResolver.php new file mode 100644 index 00000000..a592542d --- /dev/null +++ b/src/Client/Stateless/InputRequestResolver.php @@ -0,0 +1,204 @@ + + */ +final class InputRequestResolver +{ + /** + * The only requests a server may park in `inputRequests`. Anything else is + * a server fault, and answering it would be inventing protocol. + * + * @var array> + */ + private const RESOLVABLE = [ + 'elicitation/create' => ElicitRequest::class, + 'sampling/createMessage' => CreateSamplingMessageRequest::class, + 'roots/list' => ListRootsRequest::class, + ]; + + /** + * @param RequestHandlerInterface[] $handlers + */ + public function __construct( + private readonly array $handlers, + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Reads the `inputRequests` map off a result, or null when the result is + * not an ask. + * + * A result with no `resultType` MUST be read as complete, so an absent + * member is not an ask — only the explicit `input_required` is. + * + * @param array $result + * + * @return array|null + */ + public static function asked(array $result): ?array + { + if (($result['resultType'] ?? null) !== 'input_required') { + return null; + } + + return \is_array($result['inputRequests'] ?? null) ? $result['inputRequests'] : []; + } + + /** + * Resolves every ask into the `inputResponses` map the retry carries. + * + * Keys are the server's, and each answer goes back under the key it was + * asked under — the client never reorders or renames them, since that map + * is how the server correlates answers to questions. + * + * @param array $inputRequests + * + * @return array + * + * @throws RuntimeException when an ask cannot be answered at all + */ + public function resolve(array $inputRequests): array + { + $responses = []; + + foreach ($inputRequests as $key => $ask) { + $responses[(string) $key] = $this->answer((string) $key, $ask); + } + + return $responses; + } + + /** + * @return array + */ + private function answer(string $key, mixed $ask): array + { + if (!\is_array($ask) || !\is_string($ask['method'] ?? null)) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" without a method to answer.', $key)); + } + + $method = $ask['method']; + $class = self::RESOLVABLE[$method] ?? null; + + if (null === $class) { + throw new RuntimeException(\sprintf('Server asked for input under "%s" using "%s", which is not a request a client can answer.', $key, $method)); + } + + $params = $ask['params'] ?? null; + if ($params instanceof \stdClass) { + $params = (array) $params; + } + + // The ask is a bare method/params pair, but the handlers speak in + // messages. The id never reaches the wire — the answer is keyed by + // $key — so any id will do. + $request = $class::fromArray([ + 'jsonrpc' => '2.0', + 'id' => $key, + 'method' => $method, + 'params' => \is_array($params) ? $params : null, + ]); + + $this->logger->debug('Resolving multi round-trip input request', [ + 'key' => $key, + 'method' => $method, + ]); + + $result = $this->dispatch($request); + + if ($result instanceof Error) { + throw new RuntimeException(\sprintf('Cannot answer the server\'s "%s" input request under "%s": %s', $method, $key, $result->message)); + } + + return $result; + } + + /** + * @return array|Error + */ + private function dispatch(Request $request): array|Error + { + foreach ($this->handlers as $handler) { + if (!$handler->supports($request)) { + continue; + } + + try { + $response = $handler->handle($request); + } catch (\Throwable $e) { + $this->logger->error('Input request handler failed', [ + 'method' => $request::getMethod(), + 'exception' => $e, + ]); + + return Error::forInternalError($e->getMessage(), $request->getId()); + } + + if ($response instanceof Error) { + return $response; + } + + return self::resultOf($response); + } + + return Error::forMethodNotFound( + \sprintf('Client does not handle "%s" requests.', $request::getMethod()), + $request->getId(), + ); + } + + /** + * @param Response $response + * + * @return array + */ + private static function resultOf(Response $response): array + { + $result = $response->result; + + if ($result instanceof \JsonSerializable) { + $result = $result->jsonSerialize(); + } + + if ($result instanceof \stdClass) { + $result = (array) $result; + } + + return \is_array($result) ? $result : []; + } +} diff --git a/src/Client/Stateless/RequestEnvelope.php b/src/Client/Stateless/RequestEnvelope.php new file mode 100644 index 00000000..fcc1cac4 --- /dev/null +++ b/src/Client/Stateless/RequestEnvelope.php @@ -0,0 +1,78 @@ + + */ +final class RequestEnvelope +{ + public function __construct( + private readonly ProtocolVersion $protocolVersion, + private readonly ClientCapabilities $capabilities, + private readonly Implementation $clientInfo, + ) { + } + + public function protocolVersion(): ProtocolVersion + { + return $this->protocolVersion; + } + + public function withProtocolVersion(ProtocolVersion $protocolVersion): self + { + return new self($protocolVersion, $this->capabilities, $this->clientInfo); + } + + /** + * Merges the envelope into an encoded message, preserving whatever `_meta` + * the caller already put there — a `progressToken` most of all, which would + * otherwise be dropped and take every progress notification with it. + * + * @param array $payload a serialized JSON-RPC message + * + * @return array + */ + public function stamp(array $payload): array + { + $params = \is_array($payload['params'] ?? null) ? $payload['params'] : []; + $meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : []; + + $params['_meta'] = [ + ...$meta, + RequestMeta::PROTOCOL_VERSION => $this->protocolVersion->value, + // ClientCapabilities encodes an empty set as `{}` already; `[]` + // would reach the server as a JSON array and fail its check. + RequestMeta::CLIENT_CAPABILITIES => $this->capabilities, + RequestMeta::CLIENT_INFO => $this->clientInfo, + ]; + + $payload['params'] = $params; + + return $payload; + } +} diff --git a/src/Client/Stateless/ToolCatalog.php b/src/Client/Stateless/ToolCatalog.php new file mode 100644 index 00000000..3348db00 --- /dev/null +++ b/src/Client/Stateless/ToolCatalog.php @@ -0,0 +1,206 @@ + + */ +final class ToolCatalog +{ + /** @var array> tool name to input schema */ + private array $schemas = []; + + /** @var array tool name to the reason it was refused */ + private array $rejected = []; + + public function __construct( + private readonly LoggerInterface $logger = new NullLogger(), + ) { + } + + /** + * Records a listing page and returns the tools a caller may actually use. + * + * A tool whose annotations are malformed is dropped from the result, which + * is how the client "rejects" it: it never reaches the caller, so it cannot + * be called, and the tools listed beside it are untouched. + * + * @param list> $tools raw `tools/list` entries + * + * @return list> + */ + public function record(array $tools): array + { + $usable = []; + + foreach ($tools as $tool) { + $name = $tool['name'] ?? null; + $schema = $tool['inputSchema'] ?? null; + + if (!\is_string($name) || !\is_array($schema)) { + $usable[] = $tool; + + continue; + } + + unset($this->rejected[$name], $this->schemas[$name]); + + if (null !== $reason = McpHeader::checkAnnotations($schema)) { + $this->rejected[$name] = $reason; + + $this->logger->warning('Excluding tool with an invalid "x-mcp-header" annotation', [ + 'tool' => $name, + 'reason' => $reason, + ]); + + continue; + } + + $this->schemas[$name] = $schema; + $usable[] = $tool; + } + + return $usable; + } + + /** + * Whether the client refuses to call this tool. + * + * Only a tool that was listed and failed validation is refused; an unknown + * name is not, since the client may legitimately call a tool it never + * listed and the server is the authority on whether it exists. + */ + public function isRejected(string $name): bool + { + return isset($this->rejected[$name]); + } + + public function reasonFor(string $name): ?string + { + return $this->rejected[$name] ?? null; + } + + /** + * The `Mcp-Param-*` headers a call to $name must carry, given its arguments. + * + * An argument that is absent or null contributes no header — the + * specification reads a missing header as a missing value, so sending an + * empty one would assert something different. + * + * @param array $arguments + * + * @return array + */ + public function headersFor(string $name, array $arguments): array + { + $schema = $this->schemas[$name] ?? null; + + if (null === $schema) { + return []; + } + + $headers = []; + + foreach (self::annotations($schema) as $header => $path) { + $value = self::valueAt($arguments, $path); + + if (null === $value) { + continue; + } + + $encoded = McpHeader::encode($value); + + if (null === $encoded) { + continue; + } + + $headers[$header] = $encoded; + } + + return $headers; + } + + /** + * Every `x-mcp-header` annotation in $schema, as header name to the property + * path it mirrors. + * + * Only statically reachable properties count, matching the server's reader: + * a chain through `items`, a composition keyword or a `$ref` cannot be + * resolved without the instance, so an annotation there is out of bounds. + * + * @param array $schema + * @param list $path + * + * @return array> + */ + private static function annotations(array $schema, array $path = []): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $here = [...$path, (string) $property]; + + if (\is_string($definition['x-mcp-header'] ?? null)) { + $found[$definition['x-mcp-header']] = $here; + } + + $found = [...$found, ...self::annotations($definition, $here)]; + } + + return $found; + } + + /** + * @param array $arguments + * @param list $path + */ + private static function valueAt(array $arguments, array $path): mixed + { + $node = $arguments; + + foreach ($path as $segment) { + if (!\is_array($node) || !\array_key_exists($segment, $node)) { + return null; + } + + $node = $node[$segment]; + } + + return $node; + } +} diff --git a/src/Client/Transport/HeaderAwareTransportInterface.php b/src/Client/Transport/HeaderAwareTransportInterface.php new file mode 100644 index 00000000..998a0446 --- /dev/null +++ b/src/Client/Transport/HeaderAwareTransportInterface.php @@ -0,0 +1,37 @@ + + */ +interface HeaderAwareTransportInterface extends TransportInterface +{ + /** + * Register the source of per-message headers. + * + * @param callable(string $payload): array $callback receives the encoded message + */ + public function onHeaders(callable $callback): void; +} diff --git a/src/Client/Transport/HttpTransport.php b/src/Client/Transport/HttpTransport.php index ddb662f7..b5499e85 100644 --- a/src/Client/Transport/HttpTransport.php +++ b/src/Client/Transport/HttpTransport.php @@ -32,7 +32,7 @@ * * @author Kyrian Obikwelu */ -class HttpTransport extends BaseTransport +class HttpTransport extends BaseTransport implements HeaderAwareTransportInterface { private ClientInterface $httpClient; private RequestFactoryInterface $requestFactory; @@ -40,6 +40,9 @@ class HttpTransport extends BaseTransport private ?string $sessionId = null; + /** @var (callable(string): array)|null */ + private $headerCallback; + /** @var McpFiber|null */ private ?\Fiber $activeFiber = null; @@ -113,6 +116,11 @@ public function connect(): void $this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]); } + public function onHeaders(callable $callback): void + { + $this->headerCallback = $callback; + } + public function send(string $data): void { $request = $this->requestFactory->createRequest('POST', $this->endpoint) @@ -124,6 +132,13 @@ public function send(string $data): void $request = $request->withHeader('Mcp-Session-Id', $this->sessionId); } + // Protocol-derived first, so an explicitly configured header still wins: + // the caller passing one is making a deliberate choice about this + // connection, and a proxy credential is the usual reason. + foreach ($this->protocolHeaders($data) as $name => $value) { + $request = $request->withHeader($name, $value); + } + foreach ($this->headers as $name => $value) { $request = $request->withHeader($name, $value); } @@ -199,6 +214,27 @@ public function close(): void $this->handleClose('Transport closed'); } + /** + * @return array + */ + private function protocolHeaders(string $payload): array + { + if (!\is_callable($this->headerCallback)) { + return []; + } + + try { + return ($this->headerCallback)($payload); + } catch (\Throwable $e) { + // Headers mirror the body; failing to derive them is a bug worth + // reporting, but dropping the request would be a worse outcome than + // sending it the way an earlier revision would have. + $this->logger->error('Could not derive protocol headers', ['exception' => $e]); + + return []; + } + } + private function tick(): void { $this->processSSEStream(); diff --git a/src/Schema/Request/DiscoverRequest.php b/src/Schema/Request/DiscoverRequest.php new file mode 100644 index 00000000..24852bc6 --- /dev/null +++ b/src/Schema/Request/DiscoverRequest.php @@ -0,0 +1,43 @@ + + */ +final class DiscoverRequest extends Request +{ + public static function getMethod(): string + { + return 'server/discover'; + } + + protected static function fromParams(?array $params): static + { + return new self(); + } + + protected function getParams(): ?array + { + return null; + } +} diff --git a/src/Schema/Tool.php b/src/Schema/Tool.php index 4fd87f41..1e939f6d 100644 --- a/src/Schema/Tool.php +++ b/src/Schema/Tool.php @@ -12,6 +12,7 @@ namespace Mcp\Schema; use Mcp\Exception\InvalidArgumentException; +use Mcp\Schema\Wire\McpHeader; /** * Definition for a tool the client can call. @@ -130,94 +131,11 @@ public function __construct( // the whole tool definition invalid, so it is refused where the tool // is defined rather than discovered when a header comparison // mysteriously fails. - if (null !== $reason = $this->checkHeaderAnnotations()) { + if (null !== $reason = McpHeader::checkAnnotations($this->inputSchema)) { throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason)); } } - /** - * Validates every `x-mcp-header` annotation reachable through `properties` - * in an input schema (SEP-2243). - * - * The value becomes an HTTP field name, so it has to be one; it has to be - * unique case-insensitively, or two arguments would fight over one header; - * and it may only sit on a primitive that is not `number`, because a float - * has no single decimal spelling for a receiver to compare against. - * - * @return string|null the reason it is invalid, or null when every annotation is well-formed - */ - private function checkHeaderAnnotations(): ?string - { - $seen = []; - - foreach ($this->headerAnnotations($this->inputSchema) as [$name, $type, $path]) { - if ('' === $name) { - return \sprintf('the annotation at "%s" is empty', $path); - } - - // RFC 9110 tchar; excludes CR, LF and every other control character. - if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { - return \sprintf('"%s" is not a valid HTTP field name', $name); - } - - $folded = strtolower($name); - if (isset($seen[$folded])) { - return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); - } - $seen[$folded] = $path; - - if ('number' === $type) { - return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); - } - - if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { - return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); - } - } - - return null; - } - - /** - * Every annotation reachable through `properties` alone, as name, declared - * type and dotted path. - * - * @param array $schema - * - * @return list - */ - private function headerAnnotations(array $schema, string $prefix = ''): array - { - $properties = $schema['properties'] ?? null; - - if (!\is_array($properties)) { - return []; - } - - $found = []; - - foreach ($properties as $property => $definition) { - if (!\is_array($definition)) { - continue; - } - - $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; - $annotation = $definition['x-mcp-header'] ?? null; - - if (null !== $annotation) { - if (!\is_string($annotation)) { - $found[] = ['', null, $path]; - } else { - $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; - } - } - - $found = [...$found, ...$this->headerAnnotations($definition, $path)]; - } - - return $found; - } - /** * @param ToolData $data */ diff --git a/src/Schema/Wire/McpHeader.php b/src/Schema/Wire/McpHeader.php new file mode 100644 index 00000000..e73907b3 --- /dev/null +++ b/src/Schema/Wire/McpHeader.php @@ -0,0 +1,217 @@ + + */ +final class McpHeader +{ + public const METHOD = 'Mcp-Method'; + public const NAME = 'Mcp-Name'; + public const PARAM_PREFIX = 'Mcp-Param-'; + public const PROTOCOL_VERSION = 'MCP-Protocol-Version'; + + /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ + private const BASE64_PREFIX = '=?base64?'; + private const BASE64_SUFFIX = '?='; + + /** + * The subject of a request, per method. Anything unlisted is exempt. + * + * @param array|null $params + */ + public static function nameFor(string $method, ?array $params): ?string + { + $value = match ($method) { + 'tools/call', 'prompts/get' => $params['name'] ?? null, + 'resources/read' => $params['uri'] ?? null, + 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, + default => null, + }; + + return \is_string($value) ? $value : null; + } + + /** + * Renders a mirrored argument as a header value, wrapping it when it is not + * header-safe. + * + * Booleans travel as `true`/`false` and integers as decimal digits, both of + * which are always safe. A string is wrapped when it carries a control + * character, anything outside US-ASCII, or leading or trailing whitespace — + * the last because a receiver is entitled to trim the value (RFC 9110 + * §5.5), which would otherwise silently change it. + * + * Returns null for a value that cannot be mirrored at all. + */ + public static function encode(mixed $value): ?string + { + $rendered = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + \is_int($value) => (string) $value, + \is_string($value) => $value, + default => null, + }; + + if (null === $rendered) { + return null; + } + + return self::isSafe($rendered) ? $rendered : self::wrap($rendered); + } + + /** + * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. + * + * Strict: PHP's decoder accepts mispadded input and returns plausible + * bytes, which would turn a corrupted header into a silent mismatch. + * Null when the wrapper is present but its contents are not valid Base64. + */ + public static function decode(string $value): ?string + { + if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { + return $value; + } + + $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); + + $decoded = base64_decode($encoded, true); + + if (false === $decoded || base64_encode($decoded) !== $encoded) { + return null; + } + + return $decoded; + } + + /** + * Validates every `x-mcp-header` annotation reachable through `properties` + * in an input schema (SEP-2243). + * + * The value becomes an HTTP field name, so it has to be one; it has to be + * unique case-insensitively, or two arguments would fight over one header; + * and it may only sit on a primitive that is not `number`, because a float + * has no single decimal spelling for a receiver to compare against. + * + * @param array $schema + * + * @return string|null the reason it is invalid, or null when every annotation is well-formed + */ + public static function checkAnnotations(array $schema): ?string + { + $seen = []; + + foreach (self::annotations($schema) as [$name, $type, $path]) { + if ('' === $name) { + return \sprintf('the annotation at "%s" is empty', $path); + } + + // RFC 9110 tchar; excludes CR, LF and every other control character. + if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) { + return \sprintf('"%s" is not a valid HTTP field name', $name); + } + + $folded = strtolower($name); + if (isset($seen[$folded])) { + return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path); + } + $seen[$folded] = $path; + + if ('number' === $type) { + return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path); + } + + if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) { + return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path); + } + } + + return null; + } + + /** + * Every annotation reachable through `properties` alone, as name, declared + * type and dotted path. + * + * @param array $schema + * + * @return list + */ + public static function annotations(array $schema, string $prefix = ''): array + { + $properties = $schema['properties'] ?? null; + + if (!\is_array($properties)) { + return []; + } + + $found = []; + + foreach ($properties as $property => $definition) { + if (!\is_array($definition)) { + continue; + } + + $path = '' === $prefix ? (string) $property : $prefix.'.'.$property; + $annotation = $definition['x-mcp-header'] ?? null; + + if (null !== $annotation) { + if (!\is_string($annotation)) { + $found[] = ['', null, $path]; + } else { + $found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path]; + } + } + + $found = [...$found, ...self::annotations($definition, $path)]; + } + + return $found; + } + + private static function wrap(string $value): string + { + return self::BASE64_PREFIX.base64_encode($value).self::BASE64_SUFFIX; + } + + /** + * Printable US-ASCII with no leading or trailing whitespace. Interior + * spaces are fine; a tab is not, since it is a control character that + * field parsers are allowed to fold. + * + * A literal that already has the wrapper's shape is not safe either: sent + * unchanged, {@see self::decode()} would unwrap it as if it were Base64 + * and hand back something other than the literal. + */ + private static function isSafe(string $value): bool + { + if ($value !== trim($value)) { + return false; + } + + if (str_starts_with($value, self::BASE64_PREFIX) && str_ends_with($value, self::BASE64_SUFFIX)) { + return false; + } + + return 1 === preg_match('/^[\x20-\x7E]*$/', $value); + } +} diff --git a/src/Server/Stateless/StandardHeaderValidator.php b/src/Server/Stateless/StandardHeaderValidator.php index 07e1a176..0d548730 100644 --- a/src/Server/Stateless/StandardHeaderValidator.php +++ b/src/Server/Stateless/StandardHeaderValidator.php @@ -13,6 +13,7 @@ use Mcp\Capability\RegistryInterface; use Mcp\Exception\ToolNotFoundException; +use Mcp\Schema\Wire\McpHeader; /** * Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243). @@ -25,13 +26,9 @@ */ final class StandardHeaderValidator { - public const METHOD_HEADER = 'Mcp-Method'; - public const NAME_HEADER = 'Mcp-Name'; - public const PARAM_HEADER_PREFIX = 'Mcp-Param-'; - - /** Wrapper marking a header value as Base64 of its UTF-8 representation. */ - private const BASE64_PREFIX = '=?base64?'; - private const BASE64_SUFFIX = '?='; + public const METHOD_HEADER = McpHeader::METHOD; + public const NAME_HEADER = McpHeader::NAME; + public const PARAM_HEADER_PREFIX = McpHeader::PARAM_PREFIX; public function __construct( private readonly ?RegistryInterface $registry = null, @@ -113,14 +110,7 @@ private function checkName(string $method, ?array $params, array $headers): ?str */ public static function nameFor(string $method, ?array $params): ?string { - $value = match ($method) { - 'tools/call', 'prompts/get' => $params['name'] ?? null, - 'resources/read' => $params['uri'] ?? null, - 'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null, - default => null, - }; - - return \is_string($value) ? $value : null; + return McpHeader::nameFor($method, $params); } /** @@ -285,26 +275,10 @@ private function checkParam(string $headerName, array $headers, mixed $argument) /** * Unwraps a `=?base64?…?=` value, or returns a plain value unchanged. - * - * Strict: PHP's decoder accepts mispadded input and returns plausible - * bytes, which would turn a corrupted header into a silent mismatch. - * Null when the wrapper is present but its contents are not valid Base64. */ public static function decode(string $value): ?string { - if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) { - return $value; - } - - $encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX)); - - $decoded = base64_decode($encoded, true); - - if (false === $decoded || base64_encode($decoded) !== $encoded) { - return null; - } - - return $decoded; + return McpHeader::decode($value); } /** diff --git a/tests/Conformance/client.php b/tests/Conformance/client.php index 74ac8e90..9832f47d 100644 --- a/tests/Conformance/client.php +++ b/tests/Conformance/client.php @@ -16,6 +16,7 @@ use Mcp\Client\Transport\HttpTransport; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ElicitAction; +use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Request\ElicitRequest; @@ -30,35 +31,53 @@ exit(1); } +// The runner names the revision it is testing; without honouring it the client +// would open every scenario with `initialize` and never reach the modern wire. +$version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '') + ?? ProtocolVersion::V2025_11_25; + +// Scenario-specific data (tool arguments, credentials) the runner passes in. +$context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true); +$context = is_array($context) ? $context : []; + @mkdir(__DIR__.'/logs', 0777, true); $logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true); -$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url)); +$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s, version=%s', $scenario, $url, $version->value)); $builder = Client::builder() ->setClientInfo('mcp-conformance-test-client', '1.0.0') + ->setProtocolVersion($version) ->setInitTimeout(30) ->setRequestTimeout(60) ->setLogger($logger); -if ('elicitation-sep1034-client-defaults' === $scenario) { +/** + * Accepts every elicitation with an empty payload. + * + * Enough for the scenarios here, which check that the client asked and echoed + * correctly rather than what a user would have typed. + */ +$acceptElicitation = new class($logger) implements RequestHandlerInterface { + public function __construct(private readonly Psr\Log\LoggerInterface $logger) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + $this->logger->info('Received elicitation request, accepting with empty content'); + + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } +}; + +if (in_array($scenario, ['elicitation-sep1034-client-defaults', 'sep-2322-client-request-state'], true)) { $builder->setCapabilities(new ClientCapabilities(elicitation: true)); - $builder->addRequestHandler(new class($logger) implements RequestHandlerInterface { - public function __construct(private readonly Psr\Log\LoggerInterface $logger) - { - } - - public function supports(Request $request): bool - { - return $request instanceof ElicitRequest; - } - - public function handle(Request $request): Response - { - $this->logger->info('Received elicitation request, accepting with empty content'); - - return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); - } - }); + $builder->addRequestHandler($acceptElicitation); } $client = $builder->build(); @@ -76,9 +95,11 @@ public function handle(Request $request): Response break; case 'tools_call': - $toolName = $toolsResult->tools[0]->name ?? 'test-tool'; - $client->callTool($toolName, []); - $logger->info(sprintf('Called tool: %s', $toolName)); + // The scenario asserts both arguments arrive as numbers, so the + // call has to be made by name with real values rather than + // whatever tool happens to be listed first. + $client->callTool('add_numbers', ['a' => 2, 'b' => 3]); + $logger->info('Called tool: add_numbers'); break; case 'elicitation-sep1034-client-defaults': @@ -87,6 +108,74 @@ public function handle(Request $request): Response $logger->info(sprintf('Called tool: %s', $toolName)); break; + case 'json-schema-2020-12-preservation': + // Round-trips the focal tool's inputSchema back through the echo + // tool so the harness can diff what survived the client's parsing + // (SEP-1613 keywords, plus the SEP-2106 vocabulary). + $focal = null; + foreach ($toolsResult->tools as $tool) { + if ('json_schema_2020_12_tool' === $tool->name) { + $focal = $tool; + break; + } + } + + if (null === $focal) { + throw new RuntimeException('Mock server did not advertise json_schema_2020_12_tool.'); + } + + $client->callTool('json_schema_echo', ['schema' => $focal->inputSchema]); + $logger->info('Echoed the observed inputSchema back via json_schema_echo'); + break; + + case 'http-standard-headers': + // Exercises every method that carries an Mcp-Method or Mcp-Name + // header, including the ones whose subject needs Base64 wrapping. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, []); + } + + $resources = $client->listResources(); + foreach ($resources->resources as $resource) { + $client->readResource($resource->uri); + } + + $prompts = $client->listPrompts(); + foreach ($prompts->prompts as $prompt) { + $client->getPrompt($prompt->name, []); + } + + $logger->info('Exercised every header-carrying method'); + break; + + case 'http-custom-headers': + // The runner supplies the exact argument values, each chosen to hit + // a different corner of the encoding rules. + foreach ($context['toolCalls'] ?? [] as $call) { + $client->callTool($call['name'], $call['arguments'] ?? []); + $logger->info(sprintf('Called tool: %s', $call['name'])); + } + break; + + case 'http-invalid-tool-headers': + // Only the tools that survived the listing are callable; calling + // any of the malformed ones is the failure this scenario looks for. + foreach ($toolsResult->tools as $tool) { + $client->callTool($tool->name, ['region' => 'us-west1']); + $logger->info(sprintf('Called tool: %s', $tool->name)); + } + break; + + case 'sep-2322-client-request-state': + // Each tool drives one rule: echo the state back, omit it when none + // was sent, keep an unrelated call clean, and treat a result with + // no resultType as complete. + foreach (['test_mrtr_echo_state', 'test_mrtr_unrelated', 'test_mrtr_no_state', 'test_mrtr_no_result_type'] as $tool) { + $client->callTool($tool, []); + $logger->info(sprintf('Called tool: %s', $tool)); + } + break; + default: $logger->warning(sprintf('Unknown scenario: %s', $scenario)); break; diff --git a/tests/Conformance/conformance-baseline-2025-11-25.yml b/tests/Conformance/conformance-baseline-2025-11-25.yml index eb9db4ca..c57855e8 100644 --- a/tests/Conformance/conformance-baseline-2025-11-25.yml +++ b/tests/Conformance/conformance-baseline-2025-11-25.yml @@ -1,8 +1,6 @@ client: - elicitation-sep1034-client-defaults - sse-retry - - tools_call:tool-add-numbers - - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 diff --git a/tests/Conformance/conformance-baseline-2026-07-28.yml b/tests/Conformance/conformance-baseline-2026-07-28.yml index b41bdc91..1d19102a 100644 --- a/tests/Conformance/conformance-baseline-2026-07-28.yml +++ b/tests/Conformance/conformance-baseline-2026-07-28.yml @@ -1,13 +1,5 @@ client: - - request-metadata - - http-standard-headers - - http-custom-headers - http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate - - http-invalid-tool-headers:sep-2243-client-reject-invalid-tool - - sep-2322-client-request-state - - tools_call:tool-add-numbers - - json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found - - json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed - auth/metadata-default - auth/metadata-var1 - auth/metadata-var2 diff --git a/tests/Integration/DualEraElicitationTest.php b/tests/Integration/DualEraElicitationTest.php new file mode 100644 index 00000000..b2a443d5 --- /dev/null +++ b/tests/Integration/DualEraElicitationTest.php @@ -0,0 +1,105 @@ + true, + 'party_size' => 4, + 'date' => '2026-09-01', + 'dietary' => 'vegan', + 'rating' => '5', + 'comments' => 'Excellent', + ]; + + protected static function server(): string + { + return __DIR__.'/../../examples/server/elicitation/server.php'; + } + + protected static function portBase(): int + { + return 9500; + } + + #[DataProvider('provideEras')] + #[TestDox('a confirmation is collected on $_dataName')] + public function testConfirmAction(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']); + + $this->assertStringContainsString('Action confirmed', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a multi-field form is collected on $_dataName')] + public function testBookRestaurant(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']); + + $this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('feedback with an optional field is collected on $_dataName')] + public function testCollectFeedback(ProtocolVersion $era): void + { + $client = $this->connect($era, elicitation: true); + + $result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']); + + $this->assertStringContainsString('Thank you for your feedback', self::text($result)); + + $client->disconnect(); + } + + #[DataProvider('provideEras')] + #[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')] + public function testWithoutTheCapability(ProtocolVersion $era): void + { + $client = $this->connect($era); + + // Both eras refuse an ask the client cannot answer. The handshake era + // finds out from the capability the client declared at initialize, the + // modern era from the envelope on this very request — and the example + // says the same thing either way. + try { + $answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything'])); + $this->assertStringContainsString('does not support elicitation', $answer); + } catch (\Throwable $e) { + $this->assertStringContainsString('did not declare it can provide', $e->getMessage()); + } + + $client->disconnect(); + } +} diff --git a/tests/Integration/DualEraExampleTestCase.php b/tests/Integration/DualEraExampleTestCase.php new file mode 100644 index 00000000..b7671b86 --- /dev/null +++ b/tests/Integration/DualEraExampleTestCase.php @@ -0,0 +1,144 @@ + + */ +abstract class DualEraExampleTestCase extends TestCase +{ + /** Answers whatever the server elicits, so a tool that asks can complete. */ + protected const ANSWERS = []; + + private ?Process $server = null; + private int $port; + + /** Absolute path to the example's server script. */ + abstract protected static function server(): string; + + /** Port range base, so concurrent test classes do not collide. */ + abstract protected static function portBase(): int; + + protected function setUp(): void + { + // PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on + // PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the + // extra workers exist to avoid: every request below hangs to the test + // timeout instead of running. Skip until either PHP 8.1 is dropped or + // this suite stops depending on `php -S` for multi-worker concurrency. + if (\PHP_VERSION_ID < 80200) { + $this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.'); + } + + $this->port = static::portBase() + (getmypid() % 200); + + // More than one worker because the handshake era needs it: a tool that + // asks the client mid-call holds its SSE response open while the client + // POSTs the answer on a second connection. One worker deadlocks there — + // a property of `php -S`, not of the server. + $this->server = new Process( + ['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()], + env: ['PHP_CLI_SERVER_WORKERS' => '4'], + ); + $this->server->start(); + + $deadline = microtime(true) + 5; + while (microtime(true) < $deadline) { + if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) { + return; + } + + usleep(50_000); + } + + $this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput())); + } + + protected function tearDown(): void + { + $this->server?->stop(); + } + + /** + * @return iterable + */ + public static function provideEras(): iterable + { + yield 'the handshake era' => [ProtocolVersion::V2025_11_25]; + yield 'the modern era' => [ProtocolVersion::V2026_07_28]; + } + + protected static function text(CallToolResult $result): string + { + $first = $result->content[0] ?? null; + + self::assertInstanceOf(TextContent::class, $first); + + return $first->text; + } + + protected function connect(ProtocolVersion $era, bool $elicitation = false): Client + { + $builder = Client::builder() + ->setClientInfo('dual-era-integration-client', '1.0.0') + ->setProtocolVersion($era) + ->setRequestTimeout(10); + + if ($elicitation) { + $answers = static::ANSWERS; + + $builder->setCapabilities(new ClientCapabilities(elicitation: true)); + $builder->addRequestHandler(new class($answers) implements RequestHandlerInterface { + /** @param array $answers */ + public function __construct(private readonly array $answers) + { + } + + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers)); + } + }); + } + + $client = $builder->build(); + $client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port))); + + return $client; + } +} diff --git a/tests/Integration/HandshakeTest.php b/tests/Integration/HandshakeTest.php index 5e0455ff..5d213209 100644 --- a/tests/Integration/HandshakeTest.php +++ b/tests/Integration/HandshakeTest.php @@ -59,11 +59,16 @@ 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]; - // Neither side reaches the modern era through `initialize`, so - // configuring it falls back to the handshake set on both ends. - yield 'client configured modern' => [ProtocolVersion::V2026_07_28, null, $latest]; + // 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 'both configured modern' => [ProtocolVersion::V2026_07_28, ProtocolVersion::V2026_07_28, $latest]; } #[TestDox('the handshake carries the server identity to the client')] diff --git a/tests/Unit/Client/ProtocolTest.php b/tests/Unit/Client/ProtocolTest.php index 4ac40fdb..ee421258 100644 --- a/tests/Unit/Client/ProtocolTest.php +++ b/tests/Unit/Client/ProtocolTest.php @@ -15,6 +15,7 @@ use Mcp\Client\Protocol; use Mcp\Client\State\ClientStateInterface; use Mcp\Client\Transport\TransportInterface; +use Mcp\Exception\ConnectionException; use Mcp\Exception\LogicException; use Mcp\Schema\ClientCapabilities; use Mcp\Schema\Enum\ProtocolVersion; @@ -22,6 +23,8 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Schema\JsonRpc\MessageInterface; use Mcp\Schema\JsonRpc\Response; +use Mcp\Schema\Request\PingRequest; +use Mcp\Server\Stateless\RequestMeta; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\TestDox; use PHPUnit\Framework\TestCase; @@ -42,20 +45,64 @@ public function testOffersConfiguredVersion(): void $this->assertSame(ProtocolVersion::V2025_06_18->value, $transport->offeredVersion); } - #[TestDox('never offers a modern version over the initialize handshake, and warns about it')] - public function testDoesNotOfferModernVersionOverHandshake(): void + #[TestDox('never sends "initialize" on a modern revision, which removed it')] + public function testModernRevisionSkipsTheHandshake(): void { - $transport = new RecordingTransport(ProtocolVersion::latestHandshake()->value); - $protocol = new Protocol(logger: $logger = new CollectingLogger()); + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); $protocol->initialize($config); - $this->assertSame(ProtocolVersion::latestHandshake()->value, $transport->offeredVersion); - $this->assertSame([[ - 'configured' => ProtocolVersion::V2026_07_28->value, - 'offered' => ProtocolVersion::latestHandshake()->value, - ]], $logger->warnings); + $this->assertNotContains('initialize', $transport->methods); + $this->assertNotContains('notifications/initialized', $transport->methods); + $this->assertSame(ProtocolVersion::V2026_07_28, $protocol->getState()->getProtocolVersion()); + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('carries the revision, capabilities and client info on every modern request')] + public function testModernRequestsCarryTheEnvelope(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertNotSame([], $transport->metas); + + foreach ($transport->metas as $meta) { + $this->assertSame(ProtocolVersion::V2026_07_28->value, $meta[RequestMeta::PROTOCOL_VERSION] ?? null); + $this->assertArrayHasKey(RequestMeta::CLIENT_CAPABILITIES, $meta); + $this->assertSame('client-app', $meta[RequestMeta::CLIENT_INFO]['name'] ?? null); + } + } + + #[TestDox('a server that refuses "server/discover" still leaves a usable connection')] + public function testDiscoveryFailureIsNotFatal(): void + { + $transport = new RecordingTransport(ProtocolVersion::V2026_07_28->value, refuseDiscovery: true); + $protocol = new Protocol(); + $protocol->connect($transport, $config = $this->createConfiguration(ProtocolVersion::V2026_07_28)); + + $protocol->initialize($config); + + $this->assertTrue($protocol->getState()->isInitialized()); + } + + #[TestDox('refuses to continue when discovery shows the server has no modern revision')] + public function testDiscoveryWithoutAModernRevisionFails(): 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'); + + $protocol->initialize($config); } #[TestDox('accepts a counter-offer the SDK can speak and records it as negotiated')] @@ -126,6 +173,41 @@ public function testErrorResponseWithIdIsStoredForItsPendingRequest(): void $this->assertSame(Error::METHOD_NOT_FOUND, $response->code); } + #[TestDox('reconnecting starts with a fresh tool catalog, not the previous server\'s verdicts')] + public function testReconnectResetsToolCatalog(): void + { + $protocol = new Protocol(); + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config = $this->createConfiguration(ProtocolVersion::V2025_11_25)); + + $protocol->getToolCatalog()->record([[ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ]]); + + $this->assertTrue($protocol->getToolCatalog()->isRejected('broken')); + + $protocol->connect(new RecordingTransport(ProtocolVersion::V2025_11_25->value), $config); + + $this->assertFalse($protocol->getToolCatalog()->isRejected('broken'), 'the previous server\'s verdict must not survive a reconnect'); + } + + #[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)); + + $result = $protocol->request(new PingRequest(), 5); + + $this->assertInstanceOf(Response::class, $result); + $this->assertStringContainsString('"inputResponses":{}', $transport->retryBody); + $this->assertStringNotContainsString('"inputResponses":[]', $transport->retryBody); + } + private function createConfiguration(ProtocolVersion $protocolVersion): Configuration { return new Configuration( @@ -137,38 +219,164 @@ private function createConfiguration(ProtocolVersion $protocolVersion): Configur } /** - * Transport that answers the `initialize` request inline with a canned - * `protocolVersion`, so the handshake resolves without a Fiber round-trip. + * Answers the first request with an empty `input_required` ask and the retry + * with success, capturing the retry's raw body so the test can inspect how + * `inputResponses` was actually encoded on the wire. */ -final class RecordingTransport implements TransportInterface +final class InputRequiredRoundTripTransport implements TransportInterface { - public ?string $offeredVersion = null; + public string $retryBody = ''; + private int $calls = 0; private ClientStateInterface $state; - public function __construct(private readonly string $counterOffer) + public function setState(ClientStateInterface $state): void { + $this->state = $state; } public function send(string $data): void { - /** @var array{id: int|string, method: string, params?: array{protocolVersion?: string}} $message */ + /** @var array{id: int} $message */ $message = json_decode($data, true); + $id = $message['id']; + + if (0 === $this->calls++) { + $this->answer($id, ['resultType' => 'input_required', 'inputRequests' => []]); - if ('initialize' !== ($message['method'] ?? null)) { return; } - $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + $this->retryBody = $data; + + $this->answer($id, ['resultType' => 'complete']); + } - $this->state->storeResponse($message['id'], [ + /** + * @param array $result + */ + private function answer(int $id, array $result): void + { + $this->state->storeResponse($id, [ 'jsonrpc' => MessageInterface::JSONRPC_VERSION, - 'id' => $message['id'], - 'result' => [ + 'id' => $id, + 'result' => $result, + ]); + } + + public function connect(): void + { + } + + public function close(): void + { + } + + public function runRequest(\Fiber $fiber, ?callable $onProgress = null): Response|Error + { + throw new LogicException('Not used in this test.'); + } + + public function onInitialize(callable $callback): void + { + } + + public function onMessage(callable $callback): void + { + } + + public function onError(callable $callback): void + { + } + + public function onClose(callable $callback): void + { + } +} + +/** + * Transport that answers inline, so a request resolves without a Fiber + * round-trip: `initialize` with a canned `protocolVersion`, and + * `server/discover` with a minimal modern-era answer. + */ +final class RecordingTransport implements TransportInterface +{ + public ?string $offeredVersion = null; + + /** @var list every method that reached the wire, in order */ + public array $methods = []; + + /** @var list> the `_meta` each request carried */ + public array $metas = []; + + private ClientStateInterface $state; + + public function __construct( + private readonly string $counterOffer, + private readonly bool $refuseDiscovery = false, + ) { + } + + public function send(string $data): void + { + /** @var array{id?: int|string, method?: string, params?: array} $message */ + $message = json_decode($data, true); + $method = $message['method'] ?? null; + + if (!\is_string($method)) { + return; + } + + $this->methods[] = $method; + $this->metas[] = $message['params']['_meta'] ?? []; + + if (!isset($message['id'])) { + return; + } + + if ('initialize' === $method) { + $this->offeredVersion = $message['params']['protocolVersion'] ?? null; + + $this->answer($message['id'], [ 'protocolVersion' => $this->counterOffer, 'capabilities' => [], 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], - ], + ]); + + return; + } + + if ('server/discover' !== $method) { + return; + } + + if ($this->refuseDiscovery) { + $this->state->storeResponse($message['id'], [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $message['id'], + 'error' => ['code' => -32601, 'message' => 'Method not found'], + ]); + + return; + } + + $this->answer($message['id'], [ + 'resultType' => 'complete', + 'supportedVersions' => [$this->counterOffer], + 'capabilities' => [], + 'serverInfo' => ['name' => 'server', 'version' => '1.2.3'], + ]); + } + + /** + * @param array $result + */ + private function answer(int|string $id, array $result): void + { + $this->state->storeResponse($id, [ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => $id, + 'result' => $result, ]); } diff --git a/tests/Unit/Client/Stateless/HeaderFactoryTest.php b/tests/Unit/Client/Stateless/HeaderFactoryTest.php new file mode 100644 index 00000000..ea4a3a43 --- /dev/null +++ b/tests/Unit/Client/Stateless/HeaderFactoryTest.php @@ -0,0 +1,124 @@ +headersFor(['method' => 'tools/list', 'params' => []]); + + $this->assertSame('2026-07-28', $headers['MCP-Protocol-Version']); + $this->assertSame('tools/list', $headers['Mcp-Method']); + $this->assertArrayNotHasKey('Mcp-Name', $headers); + } + + #[TestDox('names the subject of a request that addresses one')] + public function testNameHeader(): void + { + $payload = ['method' => 'tools/call', 'params' => ['name' => 'search', 'arguments' => []]]; + + $this->assertSame('search', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('wraps a subject that is not header-safe, and the server unwraps it')] + public function testUnsafeNameRoundTrips(): void + { + $payload = ['method' => 'resources/read', 'params' => ['uri' => 'file:///café.txt']]; + + $this->assertSame('=?base64?ZmlsZTovLy9jYWbDqS50eHQ=?=', $this->headersFor($payload)['Mcp-Name']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('mirrors annotated tool arguments the server can verify')] + public function testMirroredArguments(): void + { + $payload = [ + 'method' => 'tools/call', + 'params' => [ + 'name' => 'search', + 'arguments' => ['region' => ' padded ', 'priority' => 7], + ], + ]; + + $headers = $this->headersFor($payload); + + $this->assertSame('=?base64?IHBhZGRlZCA=?=', $headers['Mcp-Param-Region']); + $this->assertSame('7', $headers['Mcp-Param-Priority']); + $this->assertNull($this->validate($payload)); + } + + #[TestDox('a response carries the revision but nothing to mirror')] + public function testResponseCarriesOnlyTheVersion(): void + { + $headers = $this->headersFor(['id' => 1, 'result' => []]); + + $this->assertSame(['MCP-Protocol-Version' => '2026-07-28'], $headers); + } + + /** + * @param array $payload + * + * @return array + */ + private function headersFor(array $payload): array + { + return (new HeaderFactory($this->catalog()))->forMessage($payload, ProtocolVersion::V2026_07_28); + } + + /** + * @param array $payload + * + * @return string|null the server's reason to reject, or null when it agrees + */ + private function validate(array $payload): ?string + { + return (new StandardHeaderValidator())->validate( + $payload['method'], + $payload['params'] ?? null, + $this->headersFor($payload), + ); + } + + private function catalog(): ToolCatalog + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + ], + ], + ]]); + + return $catalog; + } +} diff --git a/tests/Unit/Client/Stateless/InputRequestResolverTest.php b/tests/Unit/Client/Stateless/InputRequestResolverTest.php new file mode 100644 index 00000000..536aa312 --- /dev/null +++ b/tests/Unit/Client/Stateless/InputRequestResolverTest.php @@ -0,0 +1,119 @@ +assertNull(InputRequestResolver::asked(['content' => []])); + $this->assertNull(InputRequestResolver::asked(['resultType' => 'complete'])); + } + + #[TestDox('an input_required result is an ask, even with an empty map')] + public function testInputRequiredIsAnAsk(): void + { + $this->assertSame([], InputRequestResolver::asked(['resultType' => 'input_required'])); + $this->assertSame( + ['confirm' => ['method' => 'elicitation/create']], + InputRequestResolver::asked([ + 'resultType' => 'input_required', + 'inputRequests' => ['confirm' => ['method' => 'elicitation/create']], + ]), + ); + } + + #[TestDox('answers each ask under the key the server asked it under')] + public function testAnswersAreKeyedByTheServersKey(): void + { + $responses = $this->resolver()->resolve([ + 'confirm' => self::elicitation('Confirm?'), + 'name' => self::elicitation('Your name?'), + ]); + + $this->assertSame(['confirm', 'name'], array_keys($responses)); + $this->assertSame('accept', $responses['confirm']['action']); + } + + #[TestDox('refuses an ask the client has no handler for')] + public function testUnhandledAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Client does not handle "elicitation/create" requests.'); + + (new InputRequestResolver([]))->resolve(['confirm' => self::elicitation('Confirm?')]); + } + + #[TestDox('refuses an ask that is not a request a client can answer')] + public function testUnanswerableMethodIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('which is not a request a client can answer'); + + $this->resolver()->resolve(['x' => ['method' => 'tools/call', 'params' => []]]); + } + + #[TestDox('refuses an ask with no method to answer')] + public function testMethodlessAskIsRefused(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('without a method to answer'); + + $this->resolver()->resolve(['x' => ['params' => []]]); + } + + /** + * @return array + */ + private static function elicitation(string $message): array + { + return [ + 'method' => 'elicitation/create', + 'params' => [ + 'message' => $message, + 'requestedSchema' => [ + 'type' => 'object', + 'properties' => ['confirmed' => ['type' => 'boolean']], + ], + ], + ]; + } + + private function resolver(): InputRequestResolver + { + return new InputRequestResolver([ + new class implements RequestHandlerInterface { + public function supports(Request $request): bool + { + return $request instanceof ElicitRequest; + } + + public function handle(Request $request): Response + { + return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, [])); + } + }, + ]); + } +} diff --git a/tests/Unit/Client/Stateless/ToolCatalogTest.php b/tests/Unit/Client/Stateless/ToolCatalogTest.php new file mode 100644 index 00000000..2b878373 --- /dev/null +++ b/tests/Unit/Client/Stateless/ToolCatalogTest.php @@ -0,0 +1,124 @@ +record([self::annotatedTool()]); + + $this->assertSame( + ['Region' => 'us-west1', 'Priority' => '7', 'Verbose' => 'false'], + $catalog->headersFor('search', ['region' => 'us-west1', 'priority' => 7, 'verbose' => false]), + ); + } + + #[TestDox('an argument that is absent or null contributes no header')] + public function testOmittedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + // An omitted header is how "no value" is said; an empty one would + // assert that the argument was present and empty. + $this->assertSame( + ['Region' => 'us-west1'], + $catalog->headersFor('search', ['region' => 'us-west1', 'verbose' => null]), + ); + } + + #[TestDox('an unannotated argument is never mirrored')] + public function testUnannotatedArgumentsAreNotMirrored(): void + { + $catalog = new ToolCatalog(); + $catalog->record([self::annotatedTool()]); + + $this->assertSame([], $catalog->headersFor('search', ['query' => 'SELECT 1'])); + } + + #[TestDox('a tool the client never listed is not second-guessed')] + public function testUnknownToolIsNeitherMirroredNorRejected(): void + { + $catalog = new ToolCatalog(); + + $this->assertSame([], $catalog->headersFor('unlisted', ['region' => 'x'])); + $this->assertFalse($catalog->isRejected('unlisted')); + } + + #[TestDox('a malformed annotation drops that tool and leaves the rest usable')] + public function testMalformedToolIsDroppedAlone(): void + { + $catalog = new ToolCatalog(); + + $usable = $catalog->record([ + self::annotatedTool(), + [ + 'name' => 'broken', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['data' => ['type' => 'object', 'x-mcp-header' => 'Data']], + ], + ], + ]); + + $this->assertSame(['search'], array_column($usable, 'name')); + $this->assertTrue($catalog->isRejected('broken')); + $this->assertFalse($catalog->isRejected('search')); + $this->assertStringContainsString('only string, integer and boolean', (string) $catalog->reasonFor('broken')); + } + + #[TestDox('a later listing replaces what was known about a tool')] + public function testRelistingReplacesTheVerdict(): void + { + $catalog = new ToolCatalog(); + $catalog->record([[ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => ['region' => ['type' => 'object', 'x-mcp-header' => 'Region']], + ], + ]]); + + $this->assertTrue($catalog->isRejected('search')); + + $catalog->record([self::annotatedTool()]); + + $this->assertFalse($catalog->isRejected('search')); + $this->assertSame(['Region' => 'eu'], $catalog->headersFor('search', ['region' => 'eu'])); + } + + /** + * @return array + */ + private static function annotatedTool(): array + { + return [ + 'name' => 'search', + 'inputSchema' => [ + 'type' => 'object', + 'properties' => [ + 'region' => ['type' => 'string', 'x-mcp-header' => 'Region'], + 'priority' => ['type' => 'integer', 'x-mcp-header' => 'Priority'], + 'verbose' => ['type' => 'boolean', 'x-mcp-header' => 'Verbose'], + 'query' => ['type' => 'string'], + ], + ], + ]; + } +} diff --git a/tests/Unit/Schema/Wire/McpHeaderTest.php b/tests/Unit/Schema/Wire/McpHeaderTest.php new file mode 100644 index 00000000..7b8d98e8 --- /dev/null +++ b/tests/Unit/Schema/Wire/McpHeaderTest.php @@ -0,0 +1,93 @@ + + */ + public static function provideValues(): iterable + { + yield 'plain ascii' => ['us-west1', 'us-west1']; + yield 'empty string' => ['', '']; + yield 'interior spaces stay plain' => ['us west 1', 'us west 1']; + yield 'integer' => [42, '42']; + yield 'boolean true' => [true, 'true']; + yield 'boolean false' => [false, 'false']; + yield 'non-ascii is wrapped' => ['Hello, 世界', '=?base64?SGVsbG8sIOS4lueVjA==?=']; + yield 'leading space is wrapped' => [' us-west1', '=?base64?IHVzLXdlc3Qx?=']; + yield 'trailing space is wrapped' => ['us-west1 ', '=?base64?dXMtd2VzdDEg?=']; + yield 'newline is wrapped' => ["line1\nline2", '=?base64?bGluZTEKbGluZTI=?=']; + yield 'tab is wrapped' => ["\tindented", '=?base64?CWluZGVudGVk?=']; + yield 'a literal already shaped like the wrapper is wrapped again' => ['=?base64?SGVsbG8=?=', '=?base64?PT9iYXNlNjQ/U0dWc2JHOD0/PQ==?=']; + } + + #[DataProvider('provideValues')] + #[TestDox('renders a mirrored argument as a header value')] + public function testEncode(mixed $value, string $expected): void + { + $this->assertSame($expected, McpHeader::encode($value)); + } + + #[DataProvider('provideValues')] + #[TestDox('what the client wraps, the server recovers unchanged')] + public function testRoundTrip(mixed $value, string $encoded): void + { + $expected = match (true) { + \is_bool($value) => $value ? 'true' : 'false', + default => (string) $value, + }; + + $this->assertSame($expected, McpHeader::decode($encoded)); + } + + #[TestDox('a value that cannot be mirrored gets no header at all')] + public function testUnmirrorableValue(): void + { + // A float has no single decimal spelling for a receiver to compare + // against, which is why SEP-2243 forbids the annotation on `number`. + $this->assertNull(McpHeader::encode(3.14159)); + $this->assertNull(McpHeader::encode(['a'])); + $this->assertNull(McpHeader::encode(null)); + } + + #[TestDox('a corrupted wrapper is refused rather than silently decoded')] + public function testCorruptWrapperIsRefused(): void + { + $this->assertNull(McpHeader::decode('=?base64?not valid base64!?=')); + } + + #[TestDox('names the subject of the methods that address one, and nothing else')] + public function testNameFor(): void + { + $this->assertSame('my-tool', McpHeader::nameFor('tools/call', ['name' => 'my-tool'])); + $this->assertSame('my-prompt', McpHeader::nameFor('prompts/get', ['name' => 'my-prompt'])); + $this->assertSame('file:///x', McpHeader::nameFor('resources/read', ['uri' => 'file:///x'])); + $this->assertSame('t-1', McpHeader::nameFor('tasks/get', ['taskId' => 't-1'])); + + $this->assertNull(McpHeader::nameFor('tools/list', [])); + $this->assertNull(McpHeader::nameFor('tools/call', null)); + } +}