[Server] Serve ClientGateway::elicit() under the modern lifecycle (#466)

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.
This commit is contained in:
Christopher Hertel 2026-08-20 01:25:59 +02:00 • committed by GitHub
commit e0b9c1a26f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 700 additions and 117 deletions

View file

@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
0.8.0 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. * 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. * 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. * 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.

View file

@ -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) | | [`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-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) | | [`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) | | [`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) | | [`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) | | [`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) |

View file

@ -3,13 +3,10 @@
MCP supports various ways a server can communicate back to a client on top of the main MCP supports various ways a server can communicate back to a client on top of the main
request-response flow. request-response flow.
> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server > **Protocol revision `2026-07-28`.** Logging, progress and notifications work as described
> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling, > below on every revision; under the modern lifecycle they travel on the request's own
> elicitation and roots are carried back inside the *result* instead, and > response stream and the client opts into each. Sampling is the exception — see its section.
> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there. > Asking the user something has a page of its own: [Asking for input](input-required.md).
> 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).
## ClientGateway ## ClientGateway
@ -64,7 +61,7 @@ strings.
## Sampling ## 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 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: execute "completions" or "generations" with a language model for them:

View file

@ -33,9 +33,9 @@ instead.
call, and sending notifications. call, and sending notifications.
* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client, * **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client,
not in your server's log file. not in your server's log file.
* **[Asking for input](input-required.md)** — returning an `InputRequiredResult` when a * **[Asking for input](input-required.md)** — `ClientGateway::elicit()`, or returning an
handler needs elicitation, sampling or roots. Written that way, one handler serves both `InputRequiredResult` when a handler needs several answers at once. Either way, one
[protocol eras](../protocol-versions.md). handler serves both [protocol eras](../protocol-versions.md).
Handlers that need application services (a database connection, an API client) get them Handlers that need application services (a database connection, an API client) get them
from the container instead; see from the container instead; see

View file

@ -1,16 +1,51 @@
# Asking for input # Asking for input
Some handlers cannot finish in one go: they need the user to confirm something, fill in a 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 form, name a directory, or have the client's model draft a paragraph. There are two ways to
is to **return** the ask — an `InputRequiredResult` naming what you need — and read the write that: ask for it, or return the ask.
answer off `RequestContext` when the call comes back.
Write it that way once and it serves both [protocol eras](../protocol-versions.md). ## Just asking
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 For elicitation, ask and use the answer:
(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 ```php
[What a handler forks on](#what-a-handler-forks-on). 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 ```php
use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\CallToolResult;
@ -63,49 +98,14 @@ form mode only.
## What not to call ## What not to call
`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the `ClientGateway::sample()` and `listRoots()` belong to the handshake era — revision
handshake era. Calling one under this revision raises a `LogicException` naming `2026-07-28` removed both outright, so calling one there raises a `LogicException`. Take what
`InputRequiredResult` as the replacement. 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.
## What a handler forks on ## Which revision called
Nothing. Tools, resources, prompts, structured output, progress and errors do not care Nothing above forks on it. `elicit()` works the same on every revision — only the mechanics
which era called, and neither does the one thing that looks like it should: **asking the underneath differ, and the SDK picks them. The one thing to keep in mind is the rule already
user something**. stated: a handler that asks may be entered again from the top, so let it repeat safely.
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.
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();
```
`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era
connection instead of being fulfilled behind your back.

View file

@ -17,9 +17,7 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\EnumSchemaDefinition; use Mcp\Schema\Elicitation\EnumSchemaDefinition;
use Mcp\Schema\Elicitation\NumberSchemaDefinition; use Mcp\Schema\Elicitation\NumberSchemaDefinition;
use Mcp\Schema\Elicitation\StringSchemaDefinition; use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult; use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\RequestContext; use Mcp\Server\RequestContext;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@ -46,10 +44,10 @@ final class ElicitationHandlers
* - String field with date format for reservation date * - String field with date format for reservation date
* - Enum field for dietary restrictions with human-readable labels * - 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.')] #[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()) { if (!$context->getClientGateway()->supportsElicitation()) {
return [ return [
@ -93,12 +91,6 @@ final class ElicitationHandlers
$schema, $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()) { if ($result->isDeclined()) {
$this->logger->info('User declined to provide reservation details.'); $this->logger->info('User declined to provide reservation details.');
@ -162,10 +154,10 @@ final class ElicitationHandlers
* *
* Demonstrates the simplest elicitation pattern - a yes/no confirmation. * 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.')] #[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()) { if (!$context->getClientGateway()->supportsElicitation()) {
return [ return [
@ -192,10 +184,6 @@ final class ElicitationHandlers
$schema, $schema,
); );
if ($result instanceof InputRequiredResult) {
return $result;
}
if (!$result->isAccepted()) { if (!$result->isAccepted()) {
return [ return [
'status' => 'not_confirmed', 'status' => 'not_confirmed',
@ -234,10 +222,10 @@ final class ElicitationHandlers
* *
* Demonstrates elicitation with optional fields and enum with labels. * 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.')] #[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()) { if (!$context->getClientGateway()->supportsElicitation()) {
return [ return [
@ -270,10 +258,6 @@ final class ElicitationHandlers
$schema, $schema,
); );
if ($result instanceof InputRequiredResult) {
return $result;
}
if (!$result->isAccepted()) { if (!$result->isAccepted()) {
return [ return [
'status' => 'skipped', 'status' => 'skipped',
@ -308,22 +292,19 @@ final class ElicitationHandlers
/** /**
* Ask the user one question. * Ask the user one question.
* *
* Written the way revision 2026-07-28 asks: the question is *returned*, the * One call, every revision. Where the client can be interrupted mid-tool it
* client answers it and retries the whole call, and the answer comes back * is, and this returns the answer to that; from 2026-07-28 on there is no
* through the input context under the same key. Nothing here names an era — * interrupting, so the SDK ends the call with the ask and returns here when
* on a handshake-era connection the SDK fulfils the same ask over that * the client re-sends it with the answer — which means everything above this
* connection's own channel and re-enters the tool with the answer. * line runs once per question. The `$key` is what ties an answer to the
* * question it belongs to across those rounds.
* The caller gets an {@see ElicitResult} once there is one, or an
* {@see InputRequiredResult} to hand straight back to its own caller.
*/ */
private function ask( private function ask(
RequestContext $context, RequestContext $context,
string $key, string $key,
string $message, string $message,
ElicitationSchema $schema, ElicitationSchema $schema,
): ElicitResult|InputRequiredResult { ): ElicitResult {
return $context->getInputContext()?->elicitResult($key) return $context->getClientGateway()->elicit($message, $schema, key: $key);
?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]);
} }
} }

View file

@ -192,17 +192,29 @@ class ClientGateway
* Requests additional information from the user via the client. The user can * Requests additional information from the user via the client. The user can
* accept (providing the requested data), decline, or cancel the request. * 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 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 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 * @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 * @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 @@ class ClientGateway
* the user's action; unlike form mode there is no content to read back, so * 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. * 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 ClientException if the client request results in an error message
* @throws InvalidArgumentException if the client did not declare url-mode elicitation * @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 // URL mode only exists from 2025-11-25 on, and only for clients declaring it
if (!$this->supportsElicitationUrl()) { if (!$this->supportsElicitationUrl()) {
throw new InvalidArgumentException('The client did not declare the "elicitation.url" capability, so it cannot be sent a url-mode elicitation.'); 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 @@ class ClientGateway
/** /**
* @throws ClientException if the client request results in an error message * @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) { if ($response instanceof Error) {
throw new ClientException($response); throw new ClientException($response);
@ -417,12 +433,28 @@ class ClientGateway
* @internal * @internal
*/ */
public function request(Request $request, int $timeout = 120): Response|Error 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<array<string, mixed>>|Error the peer's answer
*/
private function suspend(Request $request, int $timeout, ?string $key = null): Response|Error
{ {
$response = \Fiber::suspend([ $response = \Fiber::suspend([
'type' => 'request', 'type' => 'request',
'request' => $request, 'request' => $request,
'session_id' => $this->session->getId()->toRfc4122(), 'session_id' => $this->session->getId()->toRfc4122(),
'timeout' => $timeout, 'timeout' => $timeout,
'input_key' => $key,
]); ]);
if (!$response instanceof Response && !$response instanceof Error) { if (!$response instanceof Response && !$response instanceof Error) {

View file

@ -0,0 +1,169 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Server\Stateless;
use Mcp\Exception\LogicException;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\ElicitResult;
use Mcp\Schema\Result\InputRequiredResult;
/**
* Answers a handler's {@see \Mcp\Server\ClientGateway::elicit()} on a lifecycle
* that has no server-initiated requests.
*
* The 2026-07-28 revision carries an ask in the result and lets the client
* re-send the whole call with the answer, so the blocking call a handler makes
* cannot block: there is nobody to ask while the request is open. What there is
* instead is a way to end the request by asking, and to be entered again once
* the answer exists — which is the same call, resolved one round later.
*
* So an ask is served twice. The first time nothing has been answered, and the
* gateway's suspension becomes an {@see InputRequiredResult} that ends the
* request. The second time the handler runs from the top again, reaches the
* same ask, and this time the answer is here, so the call returns it and the
* handler continues past it as if it had never stopped. A handler written
* against the handshake era therefore serves this one unchanged — at the price
* of running once per ask, which is what makes anything it does before its last
* ask happen once per round.
*
* Answers are kept in the `requestState` because the client only ever echoes
* the round it just answered ({@see \Mcp\Client\Protocol}), so round three
* would otherwise no longer know what round one said. That state is signed and
* not encrypted, and it is the client's own answers travelling back to the
* client that gave them — but a handler that elicits something it would not
* hand back should ask for it in one round and use it in that same round.
*
* @author Christopher Hertel <mail@christopher-hertel.de>
*/
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<string, array<string, mixed>> raw answers, keyed as they were asked */
private array $answers = [];
/** @var array<string, mixed> the verified state this round arrived with */
private array $payload;
/** @var array<string, true> 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<string, mixed>|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);
}
}

View file

@ -24,6 +24,7 @@ use Mcp\Schema\JsonRpc\Request;
use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\JsonRpc\Response;
use Mcp\Schema\JsonRpc\ResultInterface; use Mcp\Schema\JsonRpc\ResultInterface;
use Mcp\Schema\Notification\LoggingMessageNotification; use Mcp\Schema\Notification\LoggingMessageNotification;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Result\DiscoverResult; use Mcp\Schema\Result\DiscoverResult;
use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\Configuration; use Mcp\Server\Configuration;
@ -549,9 +550,19 @@ final class StatelessProtocol
* *
* The fiber is what makes a handler's `$gateway->progress(...)` look * The fiber is what makes a handler's `$gateway->progress(...)` look
* synchronous while the caller decides where the notification goes. Server * synchronous while the caller decides where the notification goes. Server
* -to-client *requests* are refused rather than forwarded: this revision * -to-client *requests* are never forwarded: this revision carries what it
* carries those in the result (MRTR), and putting one on a response stream * needs in the result (MRTR), and putting a request on a response stream is
* is something the transport binding forbids outright. * 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<ResultInterface> $handler * @param RequestHandlerInterface<ResultInterface> $handler
* *
@ -560,10 +571,31 @@ final class StatelessProtocol
private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator private function run(RequestHandlerInterface $handler, Request $request, Session $session, RequestMeta $meta): \Generator
{ {
$fiber = new \Fiber(static fn (): mixed => $handler->handle($request, $session)); $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(); $suspended = $fiber->start();
while (!$fiber->isTerminated()) { 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); $notification = $this->readNotification($suspended, $meta);
if (null !== $notification) { if (null !== $notification) {
@ -588,7 +620,10 @@ final class StatelessProtocol
{ {
if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) { if (!\is_array($suspended) || 'notification' !== ($suspended['type'] ?? null)) {
if (\is_array($suspended) && 'request' === ($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; return null;
@ -612,6 +647,30 @@ final class StatelessProtocol
return $notification; 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 * The frames of a request-scoped response stream: the notifications the
* handler emits, then the response that ends it. * handler emits, then the response that ends it.

View file

@ -0,0 +1,151 @@
<?php
/*
* This file is part of the official PHP MCP SDK.
*
* A collaboration between Symfony and the PHP Foundation.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Mcp\Tests\Unit\Server\Stateless;
use Mcp\Exception\LogicException;
use Mcp\Schema\Elicitation\ElicitationSchema;
use Mcp\Schema\Elicitation\StringSchemaDefinition;
use Mcp\Schema\Enum\ElicitationMode;
use Mcp\Schema\Request\ElicitRequest;
use Mcp\Server\Stateless\ElicitationReplay;
use Mcp\Server\Stateless\InputContext;
use Mcp\Server\Stateless\RequestStateCodec;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
class ElicitationReplayTest extends TestCase
{
private const ACCEPTED = ['action' => '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());
}
}

View file

@ -24,6 +24,7 @@ use Mcp\Schema\Notification\PromptListChangedNotification;
use Mcp\Schema\Notification\ResourceUpdatedNotification; use Mcp\Schema\Notification\ResourceUpdatedNotification;
use Mcp\Schema\Notification\ToolListChangedNotification; use Mcp\Schema\Notification\ToolListChangedNotification;
use Mcp\Schema\Request\ElicitRequest; use Mcp\Schema\Request\ElicitRequest;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\InputRequiredResult; use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Schema\Result\ReadResourceResult; use Mcp\Schema\Result\ReadResourceResult;
use Mcp\Schema\ServerCapabilities; use Mcp\Schema\ServerCapabilities;
@ -97,14 +98,35 @@ class StatelessProtocolTest extends TestCase
) )
->addTool( ->addTool(
static function (RequestContext $context): string { static function (RequestContext $context): string {
// The pattern this revision replaced: kept as a fixture so $answer = $context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n']));
// the refusal has something to refuse.
$context->getClientGateway()->elicit('name?', new ElicitationSchema(['n' => new StringSchemaDefinition('N')], ['n'])); return 'hello '.($answer->content['n'] ?? '?');
},
name: 'elicits_directly',
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'; return 'unreachable';
}, },
name: 'elicits_directly', name: 'asks_for_roots',
description: 'Asks the client directly, which this revision forbids', description: 'Asks the client directly for something this revision removed',
) )
->addTool( ->addTool(
static fn (): InputRequiredResult => new InputRequiredResult([ static fn (): InputRequiredResult => new InputRequiredResult([
@ -414,15 +436,186 @@ class StatelessProtocolTest extends TestCase
$this->assertSame(Error::MISSING_REQUIRED_CLIENT_CAPABILITY, $body['error']['code']); $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 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); $body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
$this->assertSame(500, $result->httpStatus); $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<string, mixed> $params
*
* @return array<string, mixed>
*/
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')] #[TestDox('a request\'s trace context reaches the handler')]