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

* 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.
* Deliver notifications on a `subscriptions/listen` stream (SEP-2575), which previously acknowledged and then carried nothing for the rest of its life. New `Mcp\Server\Subscription\NotificationBusInterface` with two implementations — `InMemoryNotificationBus` for stdio and persistent runtimes, `Psr16NotificationBus` for PHP-FPM, where the worker holding the stream open and the worker publishing are different processes — set with `Builder::setNotificationBus()`. Registry changes are published automatically through a `PublishingEventDispatcher` that wraps whatever PSR-14 dispatcher was configured. `Builder::setSubscriptionLifetime()` replaces the hard-coded 30-second ceiling, where `0` means "until the client or the runtime ends it".
Expand Down
69 changes: 47 additions & 22 deletions examples/server/client-communication/ClientAwareService.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,13 @@
namespace Mcp\Example\Server\ClientCommunication;

use Mcp\Capability\Attribute\McpTool;
use Mcp\Schema\Content\SamplingMessage;
use Mcp\Schema\Content\TextContent;
use Mcp\Schema\Enum\LoggingLevel;
use Mcp\Schema\Enum\Role;
use Mcp\Schema\Request\CreateSamplingMessageRequest;
use Mcp\Schema\Request\ListRootsRequest;
use Mcp\Schema\Result\InputRequiredResult;
use Mcp\Server\RequestContext;
use Psr\Log\LoggerInterface;

Expand All @@ -31,10 +36,14 @@ public function __construct(
* Demonstrates the server side of the "roots" client capability: the tool
* issues a roots/list request that the client answers from its own handler.
*
* @return array{status: string, message: string, roots?: list<array{uri: string, name: string|null}>}
* Written the 2026-07-28 way: the ask is returned and the answer read off
* the retry. A handshake-era client reaches the same tool — the SDK sends
* the `roots/list` request over that connection and re-enters this method.
*
* @return array{status: string, message: string, roots?: list<array{uri: string, name: string|null}>}|InputRequiredResult
*/
#[McpTool(name: 'inspect_workspace_roots', description: 'Ask the client for its workspace roots via a roots/list request.')]
public function inspectWorkspaceRoots(RequestContext $context): array
public function inspectWorkspaceRoots(RequestContext $context): array|InputRequiredResult
{
$clientGateway = $context->getClientGateway();

Expand All @@ -45,7 +54,11 @@ public function inspectWorkspaceRoots(RequestContext $context): array
];
}

$result = $clientGateway->listRoots();
$result = $context->getInputContext()?->rootsResult('roots');

if (null === $result) {
return new InputRequiredResult(['roots' => new ListRootsRequest()]);
}

$roots = [];
foreach ($result->roots as $root) {
Expand All @@ -62,35 +75,47 @@ public function inspectWorkspaceRoots(RequestContext $context): array
}

/**
* @return array{incident: string, recommended_actions: string, model: string}
* @return array{incident: string, recommended_actions: string, model: string}|InputRequiredResult
*/
#[McpTool(name: 'coordinate_incident_response', description: 'Coordinate an incident response with logging, progress, and sampling.')]
public function coordinateIncident(RequestContext $context, string $incidentTitle): array
public function coordinateIncident(RequestContext $context, string $incidentTitle): array|InputRequiredResult
{
$clientGateway = $context->getClientGateway();
$clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle));

$steps = [
'Collecting telemetry',
'Assessing scope',
'Coordinating responders',
];
// A retry re-enters this method from the top, so the triage work below
// must run only once: check for the answer first, before repeating logs,
// progress notifications and simulated work the client already saw.
$result = $context->getInputContext()?->samplingResult('recommendation');

foreach ($steps as $index => $step) {
$progress = ($index + 1) / \count($steps);
if (null === $result) {
$clientGateway->log(LoggingLevel::Warning, \sprintf('Incident triage started: %s', $incidentTitle));

$clientGateway->progress($progress, 1, $step);
$steps = [
'Collecting telemetry',
'Assessing scope',
'Coordinating responders',
];

usleep(180_000); // Simulate work being done
}
foreach ($steps as $index => $step) {
$progress = ($index + 1) / \count($steps);

$clientGateway->progress($progress, 1, $step);

$prompt = \sprintf(
'Provide a concise response strategy for incident "%s" based on the steps completed: %s.',
$incidentTitle,
implode(', ', $steps)
);
usleep(180_000); // Simulate work being done
}

$result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]);
$prompt = \sprintf(
'Provide a concise response strategy for incident "%s" based on the steps completed: %s.',
$incidentTitle,
implode(', ', $steps)
);

return new InputRequiredResult(['recommendation' => new CreateSamplingMessageRequest(
messages: [new SamplingMessage(Role::User, new TextContent($prompt))],
maxTokens: 350,
temperature: 0.5,
)]);
}

$recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : '';

Expand Down
82 changes: 60 additions & 22 deletions examples/server/elicitation/ElicitationHandlers.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
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 @@ -43,10 +46,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}}
* @return array{status: string, message: string, booking?: array{party_size: int, date: string, dietary: string}}|InputRequiredResult
*/
#[McpTool(name: 'book_restaurant', description: 'Book a restaurant reservation, collecting details via elicitation.')]
public function bookRestaurant(RequestContext $context, string $restaurantName): array
public function bookRestaurant(RequestContext $context, string $restaurantName): array|InputRequiredResult
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
Expand All @@ -55,8 +58,6 @@ public function bookRestaurant(RequestContext $context, string $restaurantName):
];
}

$client = $context->getClientGateway();

$this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName));

$schema = new ElicitationSchema(
Expand Down Expand Up @@ -85,12 +86,19 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
required: ['party_size', 'date'],
);

$result = $client->elicit(
message: \sprintf('Please provide your reservation details for %s:', $restaurantName),
requestedSchema: $schema,
timeout: 120,
$result = $this->ask(
$context,
'details',
\sprintf('Please provide your reservation details for %s:', $restaurantName),
$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 @@ -154,10 +162,10 @@ enumNames: ['None', 'Vegetarian', 'Vegan', 'Gluten-Free', 'Halal', 'Kosher'],
*
* Demonstrates the simplest elicitation pattern - a yes/no confirmation.
*
* @return array{status: string, message: string}
* @return array{status: string, message: string}|InputRequiredResult
*/
#[McpTool(name: 'confirm_action', description: 'Request user confirmation before proceeding with an action.')]
public function confirmAction(RequestContext $context, string $actionDescription): array
public function confirmAction(RequestContext $context, string $actionDescription): array|InputRequiredResult
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
Expand All @@ -166,8 +174,6 @@ public function confirmAction(RequestContext $context, string $actionDescription
];
}

$client = $context->getClientGateway();

$schema = new ElicitationSchema(
properties: [
'confirm' => new BooleanSchemaDefinition(
Expand All @@ -179,11 +185,17 @@ public function confirmAction(RequestContext $context, string $actionDescription
required: ['confirm'],
);

$result = $client->elicit(
message: \sprintf('Are you sure you want to: %s?', $actionDescription),
requestedSchema: $schema,
$result = $this->ask(
$context,
'confirmation',
\sprintf('Are you sure you want to: %s?', $actionDescription),
$schema,
);

if ($result instanceof InputRequiredResult) {
return $result;
}

if (!$result->isAccepted()) {
return [
'status' => 'not_confirmed',
Expand Down Expand Up @@ -222,10 +234,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}}
* @return array{status: string, message: string, feedback?: array{rating: string, comments: string}}|InputRequiredResult
*/
#[McpTool(name: 'collect_feedback', description: 'Collect user feedback via elicitation form.')]
public function collectFeedback(RequestContext $context, string $topic): array
public function collectFeedback(RequestContext $context, string $topic): array|InputRequiredResult
{
if (!$context->getClientGateway()->supportsElicitation()) {
return [
Expand All @@ -234,8 +246,6 @@ public function collectFeedback(RequestContext $context, string $topic): array
];
}

$client = $context->getClientGateway();

$schema = new ElicitationSchema(
properties: [
'rating' => new EnumSchemaDefinition(
Expand All @@ -253,11 +263,17 @@ enumNames: ['1 - Poor', '2 - Fair', '3 - Good', '4 - Very Good', '5 - Excellent'
required: ['rating'],
);

$result = $client->elicit(
message: \sprintf('Please provide your feedback about: %s', $topic),
requestedSchema: $schema,
$result = $this->ask(
$context,
'feedback',
\sprintf('Please provide your feedback about: %s', $topic),
$schema,
);

if ($result instanceof InputRequiredResult) {
return $result;
}

if (!$result->isAccepted()) {
return [
'status' => 'skipped',
Expand Down Expand Up @@ -288,4 +304,26 @@ 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.
*/
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)]);
}
}
20 changes: 19 additions & 1 deletion src/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,16 @@ public function listTools(?string $cursor = null): ListToolsResult

$response = $this->sendRequest($request);

return ListToolsResult::fromArray($response->result);
$result = $response->result;

// Filtered before parsing, because parsing is where a malformed
// `x-mcp-header` annotation throws. One broken definition must cost the
// caller that tool, not the whole listing (SEP-2243).
if (\is_array($result['tools'] ?? null)) {
$result['tools'] = $this->protocol->getToolCatalog()->record($result['tools']);
}

return ListToolsResult::fromArray($result);
}

/**
Expand All @@ -188,6 +197,15 @@ public function listTools(?string $cursor = null): ListToolsResult
*/
public function callTool(string $name, array $arguments = [], ?callable $onProgress = null): CallToolResult
{
$catalog = $this->protocol->getToolCatalog();

// A tool the listing showed to be malformed is refused here rather than
// sent: the client cannot produce the headers its annotations demand,
// so the call could only go out misdescribed (SEP-2243).
if ($catalog->isRejected($name)) {
throw new RuntimeException(\sprintf('Refusing to call tool "%s": its "x-mcp-header" annotations are invalid (%s).', $name, $catalog->reasonFor($name)));
}

$request = new CallToolRequest($name, $arguments);

$response = $this->sendRequest($request, $onProgress);
Expand Down
Loading