Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
13 changes: 5 additions & 8 deletions docs/handlers/client-communication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions docs/handlers/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
106 changes: 53 additions & 53 deletions docs/handlers/input-required.md
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
47 changes: 14 additions & 33 deletions examples/server/elicitation/ElicitationHandlers.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 [
Expand Down Expand Up @@ -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.');

Expand Down Expand Up @@ -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 [
Expand All @@ -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',
Expand Down Expand Up @@ -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 [
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
}
}
Loading