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')]