From 2db0d04b68376a27cdb7317ec8eaeed91f8dbe47 Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 20 Aug 2026 00:07:18 +0200 Subject: [PATCH] [Server] Serve ClientGateway::elicit() under the modern lifecycle One handler asks the user something on any revision now. Where the client can be asked mid-request it still is; where it cannot, the ask becomes the input_required result that revision carries and the same call returns the answer once the client re-sends it. Costs one handler entry per ask, so side effects belong after the last question. Answers from earlier rounds travel in the requestState, which is why asking more than once needs Builder::setRequestState(). sample() and listRoots() still raise a LogicException there: that revision removed them outright. --- CHANGELOG.md | 1 + docs/examples.md | 2 +- docs/handlers/client-communication.md | 13 +- docs/handlers/index.md | 6 +- docs/handlers/input-required.md | 106 ++++----- .../elicitation/ElicitationHandlers.php | 47 ++-- src/Server/ClientGateway.php | 46 +++- src/Server/Stateless/ElicitationReplay.php | 169 ++++++++++++++ src/Server/Stateless/StatelessProtocol.php | 67 +++++- .../Stateless/ElicitationReplayTest.php | 151 +++++++++++++ .../Stateless/StatelessProtocolTest.php | 209 +++++++++++++++++- 11 files changed, 700 insertions(+), 117 deletions(-) create mode 100644 src/Server/Stateless/ElicitationReplay.php create mode 100644 tests/Unit/Server/Stateless/ElicitationReplayTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3cb3ed..598fc739 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 ----- +* Serve `ClientGateway::elicit()`/`elicitUrl()` under the 2026-07-28 lifecycle, so one handler asks the user something on any revision. Where the client can be asked mid-request it still is; where it cannot, `Mcp\Server\Stateless\ElicitationReplay` turns the ask into the `input_required` result that revision carries and returns from the same call once the client re-sends it with the answer — at the cost of entering the handler once per ask. Both methods take an optional `$key` naming an ask across those rounds, defaulting to its position in the handler. Answers from earlier rounds travel in the `requestState`, so a handler asking more than once needs `Builder::setRequestState()`. `sample()` and `listRoots()` still raise a `LogicException` there: that revision removed them outright. * 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. diff --git a/docs/examples.md b/docs/examples.md index 4eae5ba4..901b567d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser | [`env-variables`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/env-variables) | Configuring a server through environment variables | [Server builder](run/server-builder.md) | | [`client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) | Sampling, roots, progress and log messages from inside a handler | [Talking back to the client](handlers/client-communication.md) | | [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) | -| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `InputRequiredResult` and typed elicitation schemas | [Asking for input](handlers/input-required.md) | +| [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `ClientGateway::elicit()` and typed elicitation schemas, on either protocol era | [Asking for input](handlers/input-required.md) | | [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Registering handlers for custom JSON-RPC methods | [Custom message handlers](advanced/custom-handlers.md) | | [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | | [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | diff --git a/docs/handlers/client-communication.md b/docs/handlers/client-communication.md index 578d31ed..1b844096 100644 --- a/docs/handlers/client-communication.md +++ b/docs/handlers/client-communication.md @@ -3,13 +3,10 @@ MCP supports various ways a server can communicate back to a client on top of the main request-response flow. -> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server -> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, -> elicitation and roots are carried back inside the *result* instead, and -> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. -> Logging and progress still work as described below — they simply travel on the request's own -> response stream, and the client opts into each. See -> [Asking for input](input-required.md). +> **Protocol revision `2026-07-28`.** Logging, progress and notifications work as described +> below on every revision; under the modern lifecycle they travel on the request's own +> response stream and the client opts into each. Sampling is the exception — see its section. +> Asking the user something has a page of its own: [Asking for input](input-required.md). ## ClientGateway @@ -64,7 +61,7 @@ strings. ## Sampling -> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. Sampling keeps working until then; new integrations should call an LLM provider's API directly instead. +> **Deprecated** since protocol revision `2026-07-28` ([SEP-2577](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2577)), earliest removal `2027-07-28`. It keeps working on a handshake-era connection until then, but that revision removed server-initiated requests outright, so `sample()` — like `listRoots()` — raises a `LogicException` when a modern-era client made the call. New integrations should call an LLM provider's API directly instead. With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to execute "completions" or "generations" with a language model for them: diff --git a/docs/handlers/index.md b/docs/handlers/index.md index bb788141..e0db0a49 100644 --- a/docs/handlers/index.md +++ b/docs/handlers/index.md @@ -33,9 +33,9 @@ instead. call, and sending notifications. * **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, not in your server's log file. -* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a - handler needs elicitation, sampling or roots. Written that way, one handler serves both - [protocol eras](../protocol-versions.md). +* **[Asking for input](input-required.md)** — `ClientGateway::elicit()`, or returning an + `InputRequiredResult` when a handler needs several answers at once. Either way, one + handler serves both [protocol eras](../protocol-versions.md). Handlers that need application services (a database connection, an API client) get them from the container instead; see diff --git a/docs/handlers/input-required.md b/docs/handlers/input-required.md index 9cb3dd5e..39ebe113 100644 --- a/docs/handlers/input-required.md +++ b/docs/handlers/input-required.md @@ -1,16 +1,51 @@ # Asking for input Some handlers cannot finish in one go: they need the user to confirm something, fill in a -form, name a directory, or have the client's model draft a paragraph. The way to write that -is to **return** the ask — an `InputRequiredResult` naming what you need — and read the -answer off `RequestContext` when the call comes back. +form, name a directory, or have the client's model draft a paragraph. There are two ways to +write that: ask for it, or return the ask. -Write it that way once and it serves both [protocol eras](../protocol-versions.md). -Revision `2026-07-28` has no server-initiated requests at all, so the client retries the -original call carrying the answers; the specification calls that a multi round-trip request -(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own -channel instead. Your handler does not fork on which — see -[What a handler forks on](#what-a-handler-forks-on). +## Just asking + +For elicitation, ask and use the answer: + +```php +static function (RequestContext $context): string { + $answer = $context->getClientGateway()->elicit('Your name?', $schema, key: 'who'); + + return "Hello, {$answer->content['name']}!"; +} +``` + +`key` names an ask, so its answer keeps finding the question it belongs to. Leave it out and +asks are keyed by position — `elicitation_1`, `elicitation_2`, … — which holds as long as the +handler reaches them in the same order every time. + +**Write the handler so it can run more than once.** Some clients answer inside the open +request; others answer by re-sending the whole call, which enters your handler again from the +top, once per question. Everything above an ask therefore has to be safe to repeat — put side +effects after the last one, and re-derive where you are from the answers rather than from +anything you kept. + +Answers given in an earlier round travel in the [`requestState`](#requeststate), so a handler +asking more than once needs `Builder::setRequestState()` configured. +[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) +is written this way. + +## Returning the ask + +The explicit form: **return** an `InputRequiredResult` naming what you need, and read the +answer off `RequestContext` when the call comes back. It is more to write, and it is the only +way to ask several things in **one** round trip, to carry your own state, or to ask for +anything other than elicitation. + +> **Revision `2026-07-28`.** Multi round-trip requests (MRTR) are that revision's feature, and +> only there does the protocol itself carry this shape. Over a handshake-era connection the SDK +> emulates it: the input-required shim sends each ask as the real `elicitation/create` / +> `sampling/createMessage` / `roots/list` and re-enters your handler with the answers. That is +> on by default and bounded by `setInputRequiredLimits()` — each round holds the originating +> request open, so it holds a worker for as long as the user takes — and +> `withoutInputRequiredShim()` turns it off, after which such a handler fails there. See +> [Server builder](../run/server-builder.md). ```php use Mcp\Schema\Result\CallToolResult; @@ -63,49 +98,14 @@ form mode only. ## What not to call -`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the -handshake era. Calling one under this revision raises a `LogicException` naming -`InputRequiredResult` as the replacement. - -## What a handler forks on - -Nothing. Tools, resources, prompts, structured output, progress and errors do not care -which era called, and neither does the one thing that looks like it should: **asking the -user something**. - -Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the -answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era -connection the SDK's input-required shim fulfils the same ask over that connection's own -channel: each embedded request goes out as the real `elicitation/create` / -`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under -the keys it asked for. It is on by default; -[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) -and -[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication) -are written this way and name no era anywhere. +`ClientGateway::sample()` and `listRoots()` belong to the handshake era — revision +`2026-07-28` removed both outright, so calling one there raises a `LogicException`. Take what +they gave you from tool arguments, resource URIs or server configuration instead. `elicit()` +and `elicitUrl()` are unaffected: elicitation survived that revision, as an ask carried in the +result. -Two things to know about it. - -**Re-entry is re-execution.** The handler runs again from the top each round, so it has to -re-derive where it is from what came back rather than from anything it kept. That is already -true of the modern era — the client retries the whole call there — so a portable handler is -written that way regardless. It is only new if you were relying on `ClientGateway::elicit()` -suspending mid-body and keeping your locals; that keeps working untouched, since nothing here -runs unless a handler *returns* an ask. - -**Each round holds the request open.** The shim waits for the client's answer inside the -originating request, which on a process-per-request runtime means it holds a worker for as -long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that -leg, but the shim makes it reachable from handlers that never mention it — so size -`setInputRequiredLimits()` against your pool. - -```php -$server = Server::builder() - ->setServerInfo('My Server', '1.0.0') - // Re-entries per request, and seconds to wait for one answer. - ->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120) - ->build(); -``` +## Which revision called -`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era -connection instead of being fulfilled behind your back. +Nothing above forks on it. `elicit()` works the same on every revision — only the mechanics +underneath differ, and the SDK picks them. The one thing to keep in mind is the rule already +stated: a handler that asks may be entered again from the top, so let it repeat safely. diff --git a/examples/server/elicitation/ElicitationHandlers.php b/examples/server/elicitation/ElicitationHandlers.php index 95a19d78..9ae87f95 100644 --- a/examples/server/elicitation/ElicitationHandlers.php +++ b/examples/server/elicitation/ElicitationHandlers.php @@ -17,9 +17,7 @@ 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; @@ -46,10 +44,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}}|InputRequiredResult + * @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}} */ #[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')] - public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult + public function bookRestaurant(RequestContext $context, string $restaurantName): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -93,12 +91,6 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], $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.'); @@ -162,10 +154,10 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'], * * Demonstrates the simplest elicitation pattern - a yes/no confirmation. * - * @return array{status: string, message: string}|InputRequiredResult + * @return array{status: string, message: string} */ #[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')] - public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult + public function confirmAction(RequestContext $context, string $actionDescription): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -192,10 +184,6 @@ public function confirmAction(RequestContext $context, string $actionDescription $schema, ); - if ($result instanceof InputRequiredResult) { - return $result; - } - if (!$result->isAccepted()) { return [ 'status' => 'not_confirmed', @@ -234,10 +222,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}}|InputRequiredResult + * @return array{status: string, message: string, feedback?: array{rating: string, comments: string}} */ #[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')] - public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult + public function collectFeedback(RequestContext $context, string $topic): array { if (!$context->getClientGateway()->supportsElicitation()) { return [ @@ -270,10 +258,6 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent' $schema, ); - if ($result instanceof InputRequiredResult) { - return $result; - } - if (!$result->isAccepted()) { return [ 'status' => 'skipped', @@ -308,22 +292,19 @@ 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. + * One call, every revision. Where the client can be interrupted mid-tool it + * is, and this returns the answer to that; from 2026-07-28 on there is no + * interrupting, so the SDK ends the call with the ask and returns here when + * the client re-sends it with the answer — which means everything above this + * line runs once per question. The `$key` is what ties an answer to the + * question it belongs to across those rounds. */ 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)]); + ): ElicitResult { + return $context->getClientGateway()->elicit($message, $schema, key: $key); } } diff --git a/src/Server/ClientGateway.php b/src/Server/ClientGateway.php index 12756fc7..a60e539c 100644 --- a/src/Server/ClientGateway.php +++ b/src/Server/ClientGateway.php @@ -192,17 +192,29 @@ public function sample(array|Content|string $message, int $maxTokens = 1000, int * Requests additional information from the user via the client. The user can * accept (providing the requested data), decline, or cancel the request. * + * Serves every revision, by two different mechanics. Where the client can be + * asked while the request is open, it is: an `elicitation/create` goes out and + * this call returns its answer. From 2026-07-28 on there are no + * server-initiated requests, so the ask ends the request instead and the call + * returns once the client re-sends it with the answer — see + * {@see Stateless\ElicitationReplay} for what that costs, namely + * that the handler is entered once per ask. + * * @param string $message A human-readable message describing what information is needed * @param ElicitationSchema $requestedSchema The schema defining the fields to elicit from the user - * @param int $timeout The timeout in seconds + * @param int $timeout The timeout in seconds; unused where the answer arrives on a later request + * @param string|null $key Names this ask, so it keeps resolving to the same answer across the rounds + * a revision without server-initiated requests needs. Defaults to the ask's + * position in the handler, which only a handler asking in a different order + * every time needs to override. * * @return ElicitResult The elicitation response containing the user's action and any provided content * * @throws ClientException if the client request results in an error message */ - public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120): ElicitResult + public function elicit(string $message, ElicitationSchema $requestedSchema, int $timeout = 120, ?string $key = null): ElicitResult { - return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout); + return $this->sendElicitation(ElicitRequest::forForm($message, $requestedSchema), $timeout, $key); } /** @@ -213,17 +225,21 @@ public function elicit(string $message, ElicitationSchema $requestedSchema, int * the user's action; unlike form mode there is no content to read back, so * whatever the user did there has to be picked up through the URL's own channel. * + * Portable across revisions on the same terms as {@see self::elicit()}. + * + * @param string|null $key names this ask across the rounds of a multi round-trip call + * * @throws ClientException if the client request results in an error message * @throws InvalidArgumentException if the client did not declare url-mode elicitation */ - public function elicitUrl(string $message, string $url, int $timeout = 120): ElicitResult + public function elicitUrl(string $message, string $url, int $timeout = 120, ?string $key = null): ElicitResult { // URL mode only exists from 2025-11-25 on, and only for clients declaring it if (!$this->supportsElicitationUrl()) { throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); } - return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout); + return $this->sendElicitation(ElicitRequest::forUrl($message, $url), $timeout, $key); } /** @@ -386,9 +402,9 @@ private function hasSubCapability(string $capability, string $name): bool /** * @throws ClientException if the client request results in an error message */ - private function sendElicitation(ElicitRequest $request, int $timeout): ElicitResult + private function sendElicitation(ElicitRequest $request, int $timeout, ?string $key = null): ElicitResult { - $response = $this->request($request, $timeout); + $response = $this->suspend($request, $timeout, $key); if ($response instanceof Error) { throw new ClientException($response); @@ -417,12 +433,28 @@ private function sendElicitation(ElicitRequest $request, int $timeout): ElicitRe * @internal */ public function request(Request $request, int $timeout = 120): Response|Error + { + return $this->suspend($request, $timeout); + } + + /** + * Hands the request to whatever is driving this fiber and waits for its answer. + * + * @param string|null $key the name an elicitation's answer is filed under when + * the revision serving this call answers by asking + * ({@see Stateless\ElicitationReplay}); + * ignored by every leg that has a live client to ask + * + * @return Response>|Error the peer's answer + */ + private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error { $response = \Fiber::suspend([ 'type' => 'request', 'request' => $request, 'session_id' => $this->session->getId()->toRfc4122(), 'timeout' => $timeout, + 'input_key' => $key, ]); if (!$response instanceof Response && !$response instanceof Error) { diff --git a/src/Server/Stateless/ElicitationReplay.php b/src/Server/Stateless/ElicitationReplay.php new file mode 100644 index 00000000..fe936277 --- /dev/null +++ b/src/Server/Stateless/ElicitationReplay.php @@ -0,0 +1,169 @@ + + */ +final class ElicitationReplay +{ + /** + * The `requestState` member carrying answers from earlier rounds. Reserved, + * like every `_mcp.` key: a handler's own payload travels beside it. + */ + public const CARRIED_ANSWERS = '_mcp.answers'; + + /** @var array> raw answers, keyed as they were asked */ + private array $answers = []; + + /** @var array the verified state this round arrived with */ + private array $payload; + + /** @var array keys whose answer this run could not read */ + private array $rejected = []; + + private int $asked = 0; + + public function __construct(?InputContext $input, private readonly ?RequestStateCodec $codec = null) + { + $this->payload = $input?->requestState() ?? []; + + $carried = $this->payload[self::CARRIED_ANSWERS] ?? []; + + if (\is_array($carried)) { + $this->answers = array_filter($carried, \is_array(...)); + } + + // Lifted out, so what is sealed again is what this run could still use + // rather than whatever the last round happened to carry. + unset($this->payload[self::CARRIED_ANSWERS]); + + // What this round answered wins over what an earlier one did: a key is + // only re-asked because its old answer was not usable. + foreach ($input?->all() ?? [] as $key => $answer) { + if (\is_array($answer)) { + $this->answers[$key] = $answer; + } + } + } + + /** + * The key an ask is filed under: the one the handler named, or its position + * among this run's asks. + * + * Positional keys hold across rounds only because the handler reaches its + * asks in the same order every time — which it does whenever it is + * re-enterable at all. A handler whose asks depend on a coin flip should + * name them. + */ + public function key(?string $key): string + { + ++$this->asked; + + return $key ?? 'elicitation_'.$this->asked; + } + + /** + * The answer to `$key`, or null when there is none to give the handler. + * + * Null covers an answer that does not parse as well as one that never + * arrived: the specification says a server SHOULD ask again for what it + * still needs, and a malformed answer left the server still needing it. + * + * @return array|null + */ + public function answer(string $key, ElicitationMode $mode = ElicitationMode::Form): ?array + { + $answer = $this->answers[$key] ?? null; + + if (null === $answer) { + return null; + } + + try { + ElicitResult::fromArray($answer, $mode); + } catch (\Throwable) { + // Noted rather than dropped: `answer()` only reads, and what makes + // this one unreadable is the mode it was read under. Marking it + // keeps it out of the state, since it is about to be asked again. + $this->rejected[$key] = true; + + return null; + } + + unset($this->rejected[$key]); + + return $answer; + } + + /** + * The result that ends this request by asking, carrying everything already + * answered so the next round does not have to ask for it again. + * + * @throws LogicException when there is state to carry and no key to sign it with + */ + public function ask(string $key, ElicitRequest $request): InputRequiredResult + { + return new InputRequiredResult([$key => $request], $this->state()); + } + + private function state(): ?string + { + $payload = $this->payload; + $answers = array_diff_key($this->answers, $this->rejected); + + if ([] !== $answers) { + $payload[self::CARRIED_ANSWERS] = $answers; + } + + if ([] === $payload) { + return null; + } + + if (null === $this->codec) { + throw new LogicException('Carrying an answer to the next round of a multi round-trip call needs a signing key; call Builder::setRequestState() to configure one.'); + } + + return $this->codec->mint($payload); + } +} diff --git a/src/Server/Stateless/StatelessProtocol.php b/src/Server/Stateless/StatelessProtocol.php index b7469572..721c9a79 100644 --- a/src/Server/Stateless/StatelessProtocol.php +++ b/src/Server/Stateless/StatelessProtocol.php @@ -24,6 +24,7 @@ use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\Notification\LoggingMessageNotification; +use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Result\DiscoverResult; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Server\Configuration; @@ -549,9 +550,19 @@ private function unknownMethod(string $method, string|int $id): Error * * The fiber is what makes a handler's `$gateway->progress(...)` look * synchronous while the caller decides where the notification goes. Server - * -to-client *requests* are refused rather than forwarded: this revision - * carries those in the result (MRTR), and putting one on a response stream - * is something the transport binding forbids outright. + * -to-client *requests* are never forwarded: this revision carries what it + * needs in the result (MRTR), and putting a request on a response stream is + * something the transport binding forbids outright. + * + * An elicitation is answered here rather than refused. Already answered, it + * resumes the fiber and the handler runs on; not yet, and the ask becomes + * the result — abandoning the fiber, since this request has nothing left to + * say and the client will re-send it. Abandoning unwinds it, so a handler's + * `finally` still runs; what does not run is everything after the ask. + * + * That is what lets one handler serve both eras through + * {@see \Mcp\Server\ClientGateway::elicit()}; see {@see ElicitationReplay} + * for what it costs. * * @param RequestHandlerInterface $handler * @@ -560,10 +571,31 @@ private function unknownMethod(string $method, string|int $id): Error private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator { $fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); + $input = $session->get(InputContext::class); + $replay = new ElicitationReplay($input instanceof InputContext ? $input : null, $this->requestStateCodec); $suspended = $fiber->start(); while (!$fiber->isTerminated()) { + if (null !== $elicitation = self::readElicitation($suspended)) { + [$named, $elicit] = $elicitation; + $key = $replay->key($named); + + if (null === $answer = $replay->answer($key, $elicit->mode)) { + try { + return new Response($request->getId(), $replay->ask($key, $elicit)); + } catch (LogicException $e) { + $this->logger->error('A handler asked for input across rounds on a server with no requestState signing key.', ['exception' => $e]); + + return Error::forInternalError('The server could not carry its own state across a round of input.', $request->getId()); + } + } + + $suspended = $fiber->resume(new Response($request->getId(), $answer)); + + continue; + } + $notification = $this->readNotification($suspended, $meta); if (null !== $notification) { @@ -588,7 +620,10 @@ private function readNotification(mixed $suspended, RequestMeta $meta): ?Notific { if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) { if (\is_array($suspended) && 'request' === ($suspended['type'] ?? null)) { - throw new LogicException('This protocol revision has no server-initiated requests: return an InputRequiredResult naming what you need instead, and read the answers back through RequestContext::getInputContext(). See the multi round-trip requests pattern.'); + // Elicitation never reaches here — it is answered in run(). What + // is left are the kinds this revision removed outright, and no + // multi round-trip shape brings them back. + throw new LogicException('This protocol revision has no server-initiated requests: sampling and roots were removed with it, so take what you need through tool arguments, resource URIs or server configuration instead. Elicitation is the one ask that survived, as a multi round-trip request.'); } return null; @@ -612,6 +647,30 @@ private function readNotification(mixed $suspended, RequestMeta $meta): ?Notific return $notification; } + /** + * One fiber suspension read as an elicitation, or null when it is not one. + * + * @param mixed $suspended the payload {@see \Mcp\Server\ClientGateway} suspended with + * + * @return array{0: string|null, 1: ElicitRequest}|null the name the handler gave the ask, and the ask + */ + private static function readElicitation(mixed $suspended): ?array + { + if (!\is_array($suspended) || 'request' !== ($suspended['type'] ?? null)) { + return null; + } + + $request = $suspended['request'] ?? null; + + if (!$request instanceof ElicitRequest) { + return null; + } + + $key = $suspended['input_key'] ?? null; + + return [\is_string($key) ? $key : null, $request]; + } + /** * The frames of a request-scoped response stream: the notifications the * handler emits, then the response that ends it. diff --git a/tests/Unit/Server/Stateless/ElicitationReplayTest.php b/tests/Unit/Server/Stateless/ElicitationReplayTest.php new file mode 100644 index 00000000..83cc710f --- /dev/null +++ b/tests/Unit/Server/Stateless/ElicitationReplayTest.php @@ -0,0 +1,151 @@ + 'accept', 'content' => ['n' => 'ada']]; + + private static function codec(): RequestStateCodec + { + return new RequestStateCodec(str_repeat('k', 32)); + } + + private static function request(): ElicitRequest + { + return ElicitRequest::forForm('Who?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + } + + #[TestDox('an unnamed ask is filed under its position, a named one under its name')] + public function testKeysFallBackToPosition(): void + { + $replay = new ElicitationReplay(null); + + $this->assertSame('elicitation_1', $replay->key(null)); + $this->assertSame('seat', $replay->key('seat')); + // Counted even when named, so a position always means the same ask. + $this->assertSame('elicitation_3', $replay->key(null)); + } + + #[TestDox('an answer from this round is handed to the handler')] + public function testAnswerFromThisRound(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + $this->assertNull($replay->answer('other')); + } + + #[TestDox('an answer from an earlier round is read back out of the state')] + public function testAnswerCarriedInState(): void + { + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame(self::ACCEPTED, $replay->answer('who')); + } + + #[TestDox('what this round answered wins over what an earlier one did')] + public function testThisRoundOverridesTheCarriedAnswer(): void + { + $fresh = ['action' => 'accept', 'content' => ['n' => 'grace']]; + + $replay = new ElicitationReplay(new InputContext(['who' => $fresh], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ])); + + $this->assertSame($fresh, $replay->answer('who')); + } + + #[TestDox('an answer that does not parse counts as no answer, and is not carried on')] + public function testMalformedAnswerIsDropped(): void + { + // Accepted, but a form-mode acceptance has to carry content. + $replay = new ElicitationReplay(new InputContext(['who' => ['action' => 'accept']]), self::codec()); + + $this->assertNull($replay->answer('who')); + + $ask = $replay->ask('who', self::request()); + + $this->assertNull($ask->requestState); + } + + #[TestDox('an answer rejected this round is not carried into the next one')] + public function testRejectedAnswerIsNotCarriedOn(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext([], [ + ElicitationReplay::CARRIED_ANSWERS => ['who' => ['action' => 'accept']], + ]), $codec); + + $this->assertNull($replay->answer('who')); + $this->assertNull($replay->ask('who', self::request())->requestState); + } + + #[TestDox('a url-mode answer is read against its own mode')] + public function testUrlModeAnswer(): void + { + $replay = new ElicitationReplay(new InputContext(['consent' => ['action' => 'accept']])); + + // Content is required in form mode and forbidden in url mode, so the + // same answer only parses under the mode it was asked in. + $this->assertNull($replay->answer('consent')); + $this->assertSame(['action' => 'accept'], $replay->answer('consent', ElicitationMode::Url)); + } + + #[TestDox('a first ask carries no state, since nothing has been answered yet')] + public function testFirstAskCarriesNoState(): void + { + $ask = (new ElicitationReplay(null, self::codec()))->ask('who', self::request()); + + $this->assertSame(['who'], array_keys($ask->inputRequests)); + $this->assertNull($ask->requestState); + } + + #[TestDox('a later ask seals what is already answered, beside the handler\'s own payload')] + public function testLaterAskSealsTheAnswers(): void + { + $codec = self::codec(); + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED], ['booking' => 42]), $codec); + + $ask = $replay->ask('seat', self::request()); + + $this->assertIsString($ask->requestState); + $this->assertSame([ + 'booking' => 42, + ElicitationReplay::CARRIED_ANSWERS => ['who' => self::ACCEPTED], + ], $codec->verify($ask->requestState)); + } + + #[TestDox('there is nowhere to keep an answer without a signing key')] + public function testSealingNeedsASigningKey(): void + { + $replay = new ElicitationReplay(new InputContext(['who' => self::ACCEPTED])); + + $this->expectException(LogicException::class); + $this->expectExceptionMessageMatches('/setRequestState/'); + + $replay->ask('seat', self::request()); + } +} diff --git a/tests/Unit/Server/Stateless/StatelessProtocolTest.php b/tests/Unit/Server/Stateless/StatelessProtocolTest.php index 13265a7e..ba933d7d 100644 --- a/tests/Unit/Server/Stateless/StatelessProtocolTest.php +++ b/tests/Unit/Server/Stateless/StatelessProtocolTest.php @@ -24,6 +24,7 @@ use Mcp\Schema\Notification\ResourceUpdatedNotification; use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Request\ElicitRequest; +use Mcp\Schema\Request\ListRootsRequest; use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\ReadResourceResult; use Mcp\Schema\ServerCapabilities; @@ -97,14 +98,35 @@ static function (): never { ) ->addTool( static function (RequestContext $context): string { - // The pattern this revision replaced: kept as a fixture so - // the refusal has something to refuse. - $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + $answer = $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); - return 'unreachable'; + return 'hello '.($answer->content['n'] ?? '?'); }, name: 'elicits_directly', - description: 'Asks the client directly, which this revision forbids', + description: 'Asks through the gateway, the way a handshake-era handler does', + ) + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $gateway->progress(0, 100, 'starting'); + + $answer = $gateway->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); + + return 'hello '.($answer->content['n'] ?? '?'); + }, + name: 'elicits_after_progress', + description: 'Reports progress, then asks through the gateway', + ) + ->addTool( + static function (RequestContext $context): string { + // A kind this revision removed rather than reshaped: kept as + // a fixture so the refusal has something to refuse. + $context->getClientGateway()->request(new ListRootsRequest()); + + return 'unreachable'; + }, + name: 'asks_for_roots', + description: 'Asks the client directly for something this revision removed', ) ->addTool( static fn (): InputRequiredResult => new InputRequiredResult([ @@ -414,15 +436,186 @@ public function testEarlyFailureIsStillAStatusCode(): void $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']); } - #[TestDox('a server-initiated request is refused with the pattern that replaced it')] + #[TestDox('a server-initiated request this revision removed is refused')] public function testServerInitiatedRequestIsRefused(): void { - $result = self::callStreaming(self::protocol(), 'elicits_directly'); + $result = self::callStreaming(self::protocol(), 'asks_for_roots'); $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR); $this->assertSame(500, $result->httpStatus); - $this->assertStringContainsString('InputRequiredResult', $body['error']['message']); + $this->assertStringContainsString('no server-initiated requests', $body['error']['message']); + } + + #[TestDox('a gateway elicitation becomes the ask this revision carries in the result')] + public function testGatewayElicitationBecomesAnAsk(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + // Unnamed, so filed under its position among the handler's asks. + $this->assertSame('elicitation/create', $answer['body']['result']['inputRequests']['elicitation_1']['method']); + // Nothing is answered yet, so there is nothing to carry. + $this->assertArrayNotHasKey('requestState', $answer['body']['result']); + } + + #[TestDox('the retry resumes the gateway call the ask came from')] + public function testGatewayElicitationResumesOnRetry(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + 'inputResponses' => ['elicitation_1' => ['action' => 'accept', 'content' => ['n' => 'ada']]], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('hello ada', $answer['body']['result']['content'][0]['text']); + } + + #[TestDox('a gateway ask that follows a notification ends the stream it opened')] + public function testGatewayElicitationEndsAnOpenStream(): void + { + $result = self::callStreaming(self::protocol(), 'elicits_after_progress', [ + 'progressToken' => 'p1', + RequestMeta::CLIENT_CAPABILITIES => ['elicitation' => new \stdClass()], + ]); + + $this->assertTrue($result->isStream()); + + $frames = self::frames($result); + $this->assertSame('notifications/progress', $frames[0]['method']); + $this->assertSame('input_required', $frames[1]['result']['resultType']); + } + + #[TestDox('an ask the client did not declare it can answer is refused before it is minted')] + public function testUndeclaredGatewayElicitationIsRefused(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + ['name' => 'elicits_directly', 'arguments' => []], + ['Mcp-Name' => 'elicits_directly'], + ); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $answer['body']['error']['code']); + } + + /** + * A handler asking twice: the second round has to remember the first + * round's answer, which is what the `requestState` is for. + */ + private static function twoAskProtocol(bool $signed = true): StatelessProtocol + { + $builder = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addTool( + static function (RequestContext $context): string { + $gateway = $context->getClientGateway(); + $schema = new ElicitationSchema(['v' => new StringSchemaDefinition('V')], ['v']); + + $when = $gateway->elicit('When?', $schema, key: 'when'); + $seat = $gateway->elicit('Seat?', $schema, key: 'seat'); + + return $when->content['v'].'/'.$seat->content['v']; + }, + name: 'books_flight', + description: 'Asks twice before it answers', + ); + + if ($signed) { + $builder->setRequestState(str_repeat('k', 32)); + } + + return $builder->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * + * @return array + */ + private static function book(StatelessProtocol $protocol, array $params = []): array + { + return self::call( + $protocol, + 'tools/call', + ['name' => 'books_flight', 'arguments' => [], ...$params], + ['Mcp-Name' => 'books_flight'], + ['elicitation' => new \stdClass()], + ); + } + + #[TestDox('a handler asking twice is served one ask per round, remembering the first')] + public function testNamedAsksCarryAcrossRounds(): void + { + $protocol = self::twoAskProtocol(); + + $first = self::book($protocol); + $this->assertSame('input_required', $first['body']['result']['resultType']); + $this->assertSame(['when'], array_keys($first['body']['result']['inputRequests'])); + // Nothing answered yet, so nothing to carry. + $this->assertArrayNotHasKey('requestState', $first['body']['result']); + + $second = self::book($protocol, [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + $this->assertSame('input_required', $second['body']['result']['resultType']); + $this->assertSame(['seat'], array_keys($second['body']['result']['inputRequests'])); + $this->assertIsString($second['body']['result']['requestState']); + + // The client echoes the state and answers only what it was just asked. + $third = self::book($protocol, [ + 'inputResponses' => ['seat' => ['action' => 'accept', 'content' => ['v' => 'aisle']]], + 'requestState' => $second['body']['result']['requestState'], + ]); + $this->assertSame(200, $third['status']); + $this->assertSame('morning/aisle', $third['body']['result']['content'][0]['text']); + } + + #[TestDox('carrying an answer to the next round without a signing key fails loudly')] + public function testCarryingAnAnswerNeedsASigningKey(): void + { + $answer = self::book(self::twoAskProtocol(signed: false), [ + 'inputResponses' => ['when' => ['action' => 'accept', 'content' => ['v' => 'morning']]], + ]); + + $this->assertSame(400, $answer['status']); + $this->assertSame(Error::INTERNAL_ERROR, $answer['body']['error']['code']); + } + + #[TestDox('an answer that does not parse is asked for again')] + public function testMalformedAnswerIsAskedAgain(): void + { + $answer = self::call( + self::protocol(), + 'tools/call', + [ + 'name' => 'elicits_directly', + 'arguments' => [], + // Accepted, but a form-mode acceptance carries content. + 'inputResponses' => ['elicitation_1' => ['action' => 'accept']], + ], + ['Mcp-Name' => 'elicits_directly'], + ['elicitation' => new \stdClass()], + ); + + $this->assertSame(200, $answer['status']); + $this->assertSame('input_required', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('elicitation_1', $answer['body']['result']['inputRequests']); } #[TestDox('a request\'s trace context reaches the handler')]