Skip to content

Commit 2db0d04

Browse files
committed
[Server] Serve ClientGateway::elicit() under the modern lifecycle
One handler asks the user something on any revision now. Where the client can be asked mid-request it still is; where it cannot, the ask becomes the input_required result that revision carries and the same call returns the answer once the client re-sends it. Costs one handler entry per ask, so side effects belong after the last question. Answers from earlier rounds travel in the requestState, which is why asking more than once needs Builder::setRequestState(). sample() and listRoots() still raise a LogicException there: that revision removed them outright.
1 parent 0cd5d56 commit 2db0d04

11 files changed

Lines changed: 700 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.8.0
66
-----
77

8+
* 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.
89
* 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.
910
* 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.
1011
* 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.

docs/examples.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser
3030
| [`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) |
3131
| [`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) |
3232
| [`client-logging`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-logging) | Structured log notifications through the `ClientLogger` | [Logging](handlers/logging.md) |
33-
| [`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) |
33+
| [`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) |
3434
| [`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) |
3535
| [`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) |
3636
| [`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) |

docs/handlers/client-communication.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,10 @@
33
MCP supports various ways a server can communicate back to a client on top of the main
44
request-response flow.
55

6-
> **Protocol revision `2026-07-28`.** This page describes the handshake era, where a server
7-
> sends its own JSON-RPC requests to the client. The modern lifecycle removed that: sampling,
8-
> elicitation and roots are carried back inside the *result* instead, and
9-
> `ClientGateway::sample()`, `elicit()` and `listRoots()` raise a `LogicException` there.
10-
> Logging and progress still work as described below — they simply travel on the request's own
11-
> response stream, and the client opts into each. See
12-
> [Asking for input](input-required.md).
6+
> **Protocol revision `2026-07-28`.** Logging, progress and notifications work as described
7+
> below on every revision; under the modern lifecycle they travel on the request's own
8+
> response stream and the client opts into each. Sampling is the exception — see its section.
9+
> Asking the user something has a page of its own: [Asking for input](input-required.md).
1310
1411
## ClientGateway
1512

@@ -64,7 +61,7 @@ strings.
6461

6562
## Sampling
6663

67-
> **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.
64+
> **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.
6865
6966
With [sampling](https://modelcontextprotocol.io/specification/2025-11-25/client/sampling) servers can request clients to
7067
execute "completions" or "generations" with a language model for them:

docs/handlers/index.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ instead.
3333
call, and sending notifications.
3434
* **[Logging](logging.md)** — structured PSR-3 log messages that surface in the client,
3535
not in your server's log file.
36-
* **[Asking for input](input-required.md)**returning an `InputRequiredResult` when a
37-
handler needs elicitation, sampling or roots. Written that way, one handler serves both
38-
[protocol eras](../protocol-versions.md).
36+
* **[Asking for input](input-required.md)**`ClientGateway::elicit()`, or returning an
37+
`InputRequiredResult` when a handler needs several answers at once. Either way, one
38+
handler serves both [protocol eras](../protocol-versions.md).
3939

4040
Handlers that need application services (a database connection, an API client) get them
4141
from the container instead; see

docs/handlers/input-required.md

Lines changed: 53 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,51 @@
11
# Asking for input
22

33
Some handlers cannot finish in one go: they need the user to confirm something, fill in a
4-
form, name a directory, or have the client's model draft a paragraph. The way to write that
5-
is to **return** the ask — an `InputRequiredResult` naming what you need — and read the
6-
answer off `RequestContext` when the call comes back.
4+
form, name a directory, or have the client's model draft a paragraph. There are two ways to
5+
write that: ask for it, or return the ask.
76

8-
Write it that way once and it serves both [protocol eras](../protocol-versions.md).
9-
Revision `2026-07-28` has no server-initiated requests at all, so the client retries the
10-
original call carrying the answers; the specification calls that a multi round-trip request
11-
(MRTR). On a handshake-era connection the SDK fulfils the same ask over that connection's own
12-
channel instead. Your handler does not fork on which — see
13-
[What a handler forks on](#what-a-handler-forks-on).
7+
## Just asking
8+
9+
For elicitation, ask and use the answer:
10+
11+
```php
12+
static function (RequestContext $context): string {
13+
$answer = $context->getClientGateway()->elicit('Your name?', $schema, key: 'who');
14+
15+
return "Hello, {$answer->content['name']}!";
16+
}
17+
```
18+
19+
`key` names an ask, so its answer keeps finding the question it belongs to. Leave it out and
20+
asks are keyed by position — `elicitation_1`, `elicitation_2`, … — which holds as long as the
21+
handler reaches them in the same order every time.
22+
23+
**Write the handler so it can run more than once.** Some clients answer inside the open
24+
request; others answer by re-sending the whole call, which enters your handler again from the
25+
top, once per question. Everything above an ask therefore has to be safe to repeat — put side
26+
effects after the last one, and re-derive where you are from the answers rather than from
27+
anything you kept.
28+
29+
Answers given in an earlier round travel in the [`requestState`](#requeststate), so a handler
30+
asking more than once needs `Builder::setRequestState()` configured.
31+
[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation)
32+
is written this way.
33+
34+
## Returning the ask
35+
36+
The explicit form: **return** an `InputRequiredResult` naming what you need, and read the
37+
answer off `RequestContext` when the call comes back. It is more to write, and it is the only
38+
way to ask several things in **one** round trip, to carry your own state, or to ask for
39+
anything other than elicitation.
40+
41+
> **Revision `2026-07-28`.** Multi round-trip requests (MRTR) are that revision's feature, and
42+
> only there does the protocol itself carry this shape. Over a handshake-era connection the SDK
43+
> emulates it: the input-required shim sends each ask as the real `elicitation/create` /
44+
> `sampling/createMessage` / `roots/list` and re-enters your handler with the answers. That is
45+
> on by default and bounded by `setInputRequiredLimits()` — each round holds the originating
46+
> request open, so it holds a worker for as long as the user takes — and
47+
> `withoutInputRequiredShim()` turns it off, after which such a handler fails there. See
48+
> [Server builder](../run/server-builder.md).
1449
1550
```php
1651
use Mcp\Schema\Result\CallToolResult;
@@ -63,49 +98,14 @@ form mode only.
6398

6499
## What not to call
65100

66-
`ClientGateway::sample()`, `elicit()`, `elicitUrl()` and `listRoots()` belong to the
67-
handshake era. Calling one under this revision raises a `LogicException` naming
68-
`InputRequiredResult` as the replacement.
69-
70-
## What a handler forks on
71-
72-
Nothing. Tools, resources, prompts, structured output, progress and errors do not care
73-
which era called, and neither does the one thing that looks like it should: **asking the
74-
user something**.
75-
76-
Write it the 2026-07-28 way — return an `InputRequiredResult` naming what you need, read the
77-
answer off `RequestContext::getInputContext()` when the call comes back. On a handshake-era
78-
connection the SDK's input-required shim fulfils the same ask over that connection's own
79-
channel: each embedded request goes out as the real `elicitation/create` /
80-
`sampling/createMessage` / `roots/list`, and the handler is re-entered with the answers under
81-
the keys it asked for. It is on by default;
82-
[`examples/server/elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation)
83-
and
84-
[`examples/server/client-communication`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/client-communication)
85-
are written this way and name no era anywhere.
101+
`ClientGateway::sample()` and `listRoots()` belong to the handshake era — revision
102+
`2026-07-28` removed both outright, so calling one there raises a `LogicException`. Take what
103+
they gave you from tool arguments, resource URIs or server configuration instead. `elicit()`
104+
and `elicitUrl()` are unaffected: elicitation survived that revision, as an ask carried in the
105+
result.
86106

87-
Two things to know about it.
88-
89-
**Re-entry is re-execution.** The handler runs again from the top each round, so it has to
90-
re-derive where it is from what came back rather than from anything it kept. That is already
91-
true of the modern era — the client retries the whole call there — so a portable handler is
92-
written that way regardless. It is only new if you were relying on `ClientGateway::elicit()`
93-
suspending mid-body and keeping your locals; that keeps working untouched, since nothing here
94-
runs unless a handler *returns* an ask.
95-
96-
**Each round holds the request open.** The shim waits for the client's answer inside the
97-
originating request, which on a process-per-request runtime means it holds a worker for as
98-
long as the user takes. That is the same cost `ClientGateway::elicit()` already pays on that
99-
leg, but the shim makes it reachable from handlers that never mention it — so size
100-
`setInputRequiredLimits()` against your pool.
101-
102-
```php
103-
$server = Server::builder()
104-
->setServerInfo('My Server', '1.0.0')
105-
// Re-entries per request, and seconds to wait for one answer.
106-
->setInputRequiredLimits(maxRounds: 4, roundTimeout: 120)
107-
->build();
108-
```
107+
## Which revision called
109108

110-
`withoutInputRequiredShim()` turns it off, so such a handler fails on a handshake-era
111-
connection instead of being fulfilled behind your back.
109+
Nothing above forks on it. `elicit()` works the same on every revision — only the mechanics
110+
underneath differ, and the SDK picks them. The one thing to keep in mind is the rule already
111+
stated: a handler that asks may be entered again from the top, so let it repeat safely.

examples/server/elicitation/ElicitationHandlers.php

Lines changed: 14 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@
1717
use Mcp\Schema\Elicitation\EnumSchemaDefinition;
1818
use Mcp\Schema\Elicitation\NumberSchemaDefinition;
1919
use Mcp\Schema\Elicitation\StringSchemaDefinition;
20-
use Mcp\Schema\Request\ElicitRequest;
2120
use Mcp\Schema\Result\ElicitResult;
22-
use Mcp\Schema\Result\InputRequiredResult;
2321
use Mcp\Server\RequestContext;
2422
use Psr\Log\LoggerInterface;
2523

@@ -46,10 +44,10 @@ public function __construct(
4644
* - String field with date format for reservation date
4745
* - Enum field for dietary restrictions with human-readable labels
4846
*
49-
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult
47+
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}
5048
*/
5149
#[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')]
52-
public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult
50+
public function bookRestaurant(RequestContext $context, string $restaurantName): array
5351
{
5452
if (!$context->getClientGateway()->supportsElicitation()) {
5553
return [
@@ -93,12 +91,6 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
9391
$schema,
9492
);
9593

96-
// Modern era, first round: the ask travels back as the result and the
97-
// client retries this whole call carrying the answer.
98-
if ($result instanceof InputRequiredResult) {
99-
return $result;
100-
}
101-
10294
if ($result->isDeclined()) {
10395
$this->logger->info('User declined to provide reservation details.');
10496

@@ -162,10 +154,10 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
162154
*
163155
* Demonstrates the simplest elicitation pattern - a yes/no confirmation.
164156
*
165-
* @return array{status: string, message: string}|InputRequiredResult
157+
* @return array{status: string, message: string}
166158
*/
167159
#[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')]
168-
public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult
160+
public function confirmAction(RequestContext $context, string $actionDescription): array
169161
{
170162
if (!$context->getClientGateway()->supportsElicitation()) {
171163
return [
@@ -192,10 +184,6 @@ public function confirmAction(RequestContext $context, string $actionDescription
192184
$schema,
193185
);
194186

195-
if ($result instanceof InputRequiredResult) {
196-
return $result;
197-
}
198-
199187
if (!$result->isAccepted()) {
200188
return [
201189
'status' => 'not_confirmed',
@@ -234,10 +222,10 @@ public function confirmAction(RequestContext $context, string $actionDescription
234222
*
235223
* Demonstrates elicitation with optional fields and enum with labels.
236224
*
237-
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult
225+
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}
238226
*/
239227
#[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')]
240-
public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult
228+
public function collectFeedback(RequestContext $context, string $topic): array
241229
{
242230
if (!$context->getClientGateway()->supportsElicitation()) {
243231
return [
@@ -270,10 +258,6 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'
270258
$schema,
271259
);
272260

273-
if ($result instanceof InputRequiredResult) {
274-
return $result;
275-
}
276-
277261
if (!$result->isAccepted()) {
278262
return [
279263
'status' => 'skipped',
@@ -308,22 +292,19 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'
308292
/**
309293
* Ask the user one question.
310294
*
311-
* Written the way revision 2026-07-28 asks: the question is *returned*, the
312-
* client answers it and retries the whole call, and the answer comes back
313-
* through the input context under the same key. Nothing here names an era —
314-
* on a handshake-era connection the SDK fulfils the same ask over that
315-
* connection's own channel and re-enters the tool with the answer.
316-
*
317-
* The caller gets an {@see ElicitResult} once there is one, or an
318-
* {@see InputRequiredResult} to hand straight back to its own caller.
295+
* One call, every revision. Where the client can be interrupted mid-tool it
296+
* is, and this returns the answer to that; from 2026-07-28 on there is no
297+
* interrupting, so the SDK ends the call with the ask and returns here when
298+
* the client re-sends it with the answer — which means everything above this
299+
* line runs once per question. The `$key` is what ties an answer to the
300+
* question it belongs to across those rounds.
319301
*/
320302
private function ask(
321303
RequestContext $context,
322304
string $key,
323305
string $message,
324306
ElicitationSchema $schema,
325-
): ElicitResult|InputRequiredResult {
326-
return $context->getInputContext()?->elicitResult($key)
327-
?? new InputRequiredResult([$key => new ElicitRequest($message, $schema)]);
307+
): ElicitResult {
308+
return $context->getClientGateway()->elicit($message, $schema, key: $key);
328309
}
329310
}

0 commit comments

Comments
 (0)