[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
4 changed files with 700 additions and 117 deletions

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

View file

@ -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:

View file

@ -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

View file

@ -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.
`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.
## What a handler forks on
## Which revision called
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.
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.
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.