[Client] Speak the 2026-07-28 lifecycle (#456)
* [Client] Speak the 2026-07-28 lifecycle On that revision the client opens with server/discover instead of initialize, 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. ToolCatalog is what knows, from the tool list, which arguments a call has to mirror. An input_required result is answered rather than surfaced: InputRequestResolver asks the host's elicitation, sampling and roots handlers and retries the same request carrying inputResponses and the requestState the server sent. McpHeader holds the header names and the =?base64?…?= sentinel, so the two sides no longer keep their own copies of the same rules. * Fix CI baseline drift and address Copilot review findings Drop stale expected-failure entries the new client fixture now passes. Copilot review: fix McpHeader round-trip for a wrapper-shaped literal, encode empty/numeric-keyed inputResponses as a JSON object, stop the incident example repeating side effects on MRTR retry, and reset ToolCatalog on reconnect instead of carrying the previous server's verdicts. * Skip DualEraExampleTestCase on PHP 8.1 PHP_CLI_SERVER_WORKERS does not reliably fork multiple php -S workers there (php/php-src#9400), reproducing the single-worker deadlock the extra workers exist to avoid.
This commit is contained in:
parent
cbceef06eb
commit
8c06dcecc6
26 changed files with 2443 additions and 248 deletions
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -31,10 +36,14 @@ final class ClientAwareService
|
|||
* 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();
|
||||
|
||||
|
|
@ -45,7 +54,11 @@ final class ClientAwareService
|
|||
];
|
||||
}
|
||||
|
||||
$result = $clientGateway->listRoots();
|
||||
$result = $context->getInputContext()?->rootsResult('roots');
|
||||
|
||||
if (null === $result) {
|
||||
return new InputRequiredResult(['roots' => new ListRootsRequest()]);
|
||||
}
|
||||
|
||||
$roots = [];
|
||||
foreach ($result->roots as $root) {
|
||||
|
|
@ -62,36 +75,48 @@ final class ClientAwareService
|
|||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
usleep(180_000); // Simulate work being done
|
||||
}
|
||||
|
||||
$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,
|
||||
)]);
|
||||
}
|
||||
|
||||
$prompt = \sprintf(
|
||||
'Provide a concise response strategy for incident "%s" based on the steps completed: %s.',
|
||||
$incidentTitle,
|
||||
implode(', ', $steps)
|
||||
);
|
||||
|
||||
$result = $clientGateway->sample($prompt, 350, 90, ['temperature' => 0.5]);
|
||||
|
||||
$recommendation = $result->content instanceof TextContent ? trim((string) $result->content->text) : '';
|
||||
|
||||
$clientGateway->log(LoggingLevel::Info, \sprintf('Incident triage completed for %s', $incidentTitle));
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ use Mcp\Schema\Elicitation\ElicitationSchema;
|
|||
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;
|
||||
|
||||
|
|
@ -43,10 +46,10 @@ final class ElicitationHandlers
|
|||
* - 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 [
|
||||
|
|
@ -55,8 +58,6 @@ final class ElicitationHandlers
|
|||
];
|
||||
}
|
||||
|
||||
$client = $context->getClientGateway();
|
||||
|
||||
$this->logger->info(\sprintf('Starting reservation process for restaurant: %s', $restaurantName));
|
||||
|
||||
$schema = new ElicitationSchema(
|
||||
|
|
@ -85,12 +86,19 @@ final class ElicitationHandlers
|
|||
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.');
|
||||
|
||||
|
|
@ -154,10 +162,10 @@ final class ElicitationHandlers
|
|||
*
|
||||
* 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 [
|
||||
|
|
@ -166,8 +174,6 @@ final class ElicitationHandlers
|
|||
];
|
||||
}
|
||||
|
||||
$client = $context->getClientGateway();
|
||||
|
||||
$schema = new ElicitationSchema(
|
||||
properties: [
|
||||
'confirm' => new BooleanSchemaDefinition(
|
||||
|
|
@ -179,11 +185,17 @@ final class ElicitationHandlers
|
|||
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',
|
||||
|
|
@ -222,10 +234,10 @@ final class ElicitationHandlers
|
|||
*
|
||||
* 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 [
|
||||
|
|
@ -234,8 +246,6 @@ final class ElicitationHandlers
|
|||
];
|
||||
}
|
||||
|
||||
$client = $context->getClientGateway();
|
||||
|
||||
$schema = new ElicitationSchema(
|
||||
properties: [
|
||||
'rating' => new EnumSchemaDefinition(
|
||||
|
|
@ -253,11 +263,17 @@ final class ElicitationHandlers
|
|||
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',
|
||||
|
|
@ -288,4 +304,26 @@ final class ElicitationHandlers
|
|||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -175,7 +175,16 @@ class Client
|
|||
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -188,6 +197,15 @@ class Client
|
|||
*/
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -16,16 +16,25 @@ use Mcp\Client\Handler\Notification\ProgressNotificationHandler;
|
|||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\State\ClientState;
|
||||
use Mcp\Client\State\ClientStateInterface;
|
||||
use Mcp\Client\Stateless\HeaderFactory;
|
||||
use Mcp\Client\Stateless\InputRequestResolver;
|
||||
use Mcp\Client\Stateless\RequestEnvelope;
|
||||
use Mcp\Client\Stateless\ToolCatalog;
|
||||
use Mcp\Client\Transport\HeaderAwareTransportInterface;
|
||||
use Mcp\Client\Transport\TransportInterface;
|
||||
use Mcp\Exception\ConnectionException;
|
||||
use Mcp\JsonRpc\MessageFactory;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Implementation;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Notification;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Notification\InitializedNotification;
|
||||
use Mcp\Schema\Request\DiscoverRequest;
|
||||
use Mcp\Schema\Request\InitializeRequest;
|
||||
use Mcp\Schema\Result\InitializeResult;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
|
|
@ -41,6 +50,16 @@ use Psr\Log\NullLogger;
|
|||
*/
|
||||
class Protocol
|
||||
{
|
||||
/**
|
||||
* How many times a request may be re-sent before the client gives up.
|
||||
*
|
||||
* Both loops that re-send are bounded by it: a server that keeps asking for
|
||||
* input, and one that keeps rejecting the offered revision. Neither is
|
||||
* expected to run more than a round or two, so the cap is only there to
|
||||
* stop a broken or hostile server from spinning the client forever.
|
||||
*/
|
||||
private const MAX_ROUND_TRIPS = 10;
|
||||
|
||||
private ?TransportInterface $transport = null;
|
||||
private ClientStateInterface $state;
|
||||
private MessageFactory $messageFactory;
|
||||
|
|
@ -49,6 +68,21 @@ class Protocol
|
|||
/** @var NotificationHandlerInterface[] */
|
||||
private array $notificationHandlers;
|
||||
|
||||
/** Set only when the configured revision has no handshake. */
|
||||
private ?RequestEnvelope $envelope = null;
|
||||
|
||||
private ?HeaderFactory $headers = null;
|
||||
|
||||
private ToolCatalog $tools;
|
||||
|
||||
private readonly InputRequestResolver $inputRequests;
|
||||
|
||||
/**
|
||||
* Progress tokens are only required to be unique within a connection, and
|
||||
* a retry keeps the caller's one — the work being reported on is the same.
|
||||
*/
|
||||
private int $progressTokens = 0;
|
||||
|
||||
/**
|
||||
* @param RequestHandlerInterface<mixed>[] $requestHandlers
|
||||
* @param NotificationHandlerInterface[] $notificationHandlers
|
||||
|
|
@ -67,6 +101,20 @@ class Protocol
|
|||
new ProgressNotificationHandler($this->state),
|
||||
...$notificationHandlers,
|
||||
];
|
||||
|
||||
$this->tools = new ToolCatalog($this->logger);
|
||||
$this->inputRequests = new InputRequestResolver($this->requestHandlers, $this->logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the client knows about the server's tools, from `tools/list`.
|
||||
*
|
||||
* Kept on the protocol rather than the facade because it is what makes the
|
||||
* SEP-2243 headers derivable at send time.
|
||||
*/
|
||||
public function getToolCatalog(): ToolCatalog
|
||||
{
|
||||
return $this->tools;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,18 +128,57 @@ class Protocol
|
|||
public function connect(TransportInterface $transport, Configuration $config): void
|
||||
{
|
||||
$this->transport = $transport;
|
||||
|
||||
// A fresh catalog per connection: it is what a server told this client
|
||||
// about its tools, and a server reached by reconnecting — the same one
|
||||
// or another — has said nothing yet.
|
||||
$this->tools = new ToolCatalog($this->logger);
|
||||
|
||||
if ($config->protocolVersion->isModern()) {
|
||||
$this->envelope = new RequestEnvelope(
|
||||
$config->protocolVersion,
|
||||
$config->capabilities,
|
||||
$config->clientInfo,
|
||||
);
|
||||
$this->headers = new HeaderFactory($this->tools);
|
||||
}
|
||||
|
||||
$transport->setState($this->state);
|
||||
$transport->onInitialize(fn () => $this->initialize($config));
|
||||
$transport->onMessage($this->processMessage(...));
|
||||
$transport->onError(fn (\Throwable $e) => $this->logger->error('Transport error', ['exception' => $e]));
|
||||
|
||||
if ($transport instanceof HeaderAwareTransportInterface) {
|
||||
$transport->onHeaders($this->headersFor(...));
|
||||
}
|
||||
|
||||
$this->logger->info('Protocol connected to transport', ['transport' => $transport::class]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the MCP initialization handshake.
|
||||
* The headers belonging to an encoded message, for a transport that has any.
|
||||
*
|
||||
* Sends InitializeRequest and waits for response, then sends InitializedNotification.
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function headersFor(string $payload): array
|
||||
{
|
||||
if (null === $this->headers || null === $this->envelope) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($payload, true);
|
||||
|
||||
return \is_array($decoded)
|
||||
? $this->headers->forMessage($decoded, $this->envelope->protocolVersion())
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ready the connection for use.
|
||||
*
|
||||
* Up to 2025-11-25 that means the `initialize` handshake: offer a revision,
|
||||
* take the server's answer, confirm with `notifications/initialized`. From
|
||||
* 2026-07-28 there is no handshake at all — see {@see self::discover()}.
|
||||
*
|
||||
* @param Configuration $config The client configuration
|
||||
*
|
||||
|
|
@ -99,18 +186,12 @@ class Protocol
|
|||
*/
|
||||
public function initialize(Configuration $config): Response|Error
|
||||
{
|
||||
$offered = $config->protocolVersion;
|
||||
if ($offered->isModern()) {
|
||||
// Only handshake era spec versions need the initialize call, so if we
|
||||
// end up here, we fall back to the latest handshake version.
|
||||
$offered = ProtocolVersion::latestHandshake();
|
||||
|
||||
$this->logger->warning('Configured protocol version cannot be reached through the "initialize" handshake, offering the newest handshake revision instead.', [
|
||||
'configured' => $config->protocolVersion->value,
|
||||
'offered' => $offered->value,
|
||||
]);
|
||||
if (null !== $this->envelope) {
|
||||
return $this->discover($config);
|
||||
}
|
||||
|
||||
$offered = $config->protocolVersion;
|
||||
|
||||
$request = new InitializeRequest(
|
||||
$offered->value,
|
||||
$config->capabilities,
|
||||
|
|
@ -156,12 +237,127 @@ class Protocol
|
|||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand in for the handshake in the modern era.
|
||||
*
|
||||
* There is nothing to negotiate: the revision travels on every request, so
|
||||
* the connection is usable the moment the transport is. `server/discover`
|
||||
* is only asked because the facade exposes `getServerInfo()`, and a server
|
||||
* that will not answer it still serves every other method — so a failure
|
||||
* here is logged and the connection proceeds.
|
||||
*
|
||||
* @return Response<array<string, mixed>>
|
||||
*/
|
||||
private function discover(Configuration $config): Response
|
||||
{
|
||||
$this->state->setProtocolVersion($config->protocolVersion);
|
||||
$this->state->setInitialized(true);
|
||||
|
||||
$response = $this->request(new DiscoverRequest(), $config->initTimeout);
|
||||
|
||||
if ($response instanceof Error) {
|
||||
$this->logger->info('Server did not answer "server/discover"; continuing without its metadata.', [
|
||||
'code' => $response->code,
|
||||
'message' => $response->message,
|
||||
]);
|
||||
|
||||
return new Response(0, []);
|
||||
}
|
||||
|
||||
$this->readDiscovery($response->result);
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read defensively: `server/discover` is optional, so a server may answer
|
||||
* with something that is not a DiscoverResult at all, and none of it is
|
||||
* load-bearing for the requests that follow.
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*/
|
||||
private function readDiscovery(array $result): void
|
||||
{
|
||||
// Identity is wire vocabulary in this revision, so it rides in `_meta`
|
||||
// rather than the result body. The top level is read as a fallback
|
||||
// because that is where the handshake era put it.
|
||||
$meta = \is_array($result['_meta'] ?? null) ? $result['_meta'] : [];
|
||||
$serverInfo = $meta[RequestMeta::SERVER_INFO] ?? $result['serverInfo'] ?? null;
|
||||
|
||||
if (\is_array($serverInfo)) {
|
||||
try {
|
||||
$this->state->setServerInfo(Implementation::fromArray($serverInfo));
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->debug('Ignoring unreadable serverInfo from "server/discover".', ['exception' => $e]);
|
||||
}
|
||||
}
|
||||
|
||||
if (\is_string($result['instructions'] ?? null)) {
|
||||
$this->state->setInstructions($result['instructions']);
|
||||
}
|
||||
|
||||
$this->reconcileVersion($result['supportedVersions'] ?? null);
|
||||
|
||||
$this->logger->info('Discovery complete', [
|
||||
'supportedVersions' => $result['supportedVersions'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to a revision the server actually speaks, if it said which.
|
||||
*
|
||||
* `server/discover` reports rather than negotiates, so a client that asked
|
||||
* for something the server does not list learns it here — and learning it
|
||||
* now is far better than a stream of refusals later. A server that stays
|
||||
* silent about its versions is left alone; the method is optional and
|
||||
* saying nothing is not the same as saying no.
|
||||
*/
|
||||
private function reconcileVersion(mixed $supportedVersions): void
|
||||
{
|
||||
if (!\is_array($supportedVersions) || [] === $supportedVersions || null === $this->envelope) {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = $this->envelope->protocolVersion();
|
||||
|
||||
if (\in_array($current->value, $supportedVersions, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($supportedVersions as $candidate) {
|
||||
$version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null;
|
||||
|
||||
if (null === $version || !$version->isModern()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->warning('Server does not speak the configured revision; continuing on one it advertises.', [
|
||||
'configured' => $current->value,
|
||||
'using' => $version->value,
|
||||
]);
|
||||
|
||||
$this->envelope = $this->envelope->withProtocolVersion($version);
|
||||
$this->state->setProtocolVersion($version);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything it offers is handshake era, which this connection cannot
|
||||
// reach — it has already skipped the handshake.
|
||||
throw new ConnectionException(\sprintf('Server does not support any modern protocol revision (it advertises %s); the configured "%s" cannot be used against it.', implode(', ', array_map(strval(...), $supportedVersions)), $current->value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the server and wait for response.
|
||||
*
|
||||
* If a response is immediately available (sync HTTP), returns it.
|
||||
* Otherwise, suspends the Fiber and waits for the transport to resume it.
|
||||
*
|
||||
* In the modern era this is also where the two loops that re-send live:
|
||||
* answering a server's request for input (SEP-2322), and retrying under a
|
||||
* revision the server accepts (SEP-2575). Both re-send the same call, so
|
||||
* they belong together and above the single exchange.
|
||||
*
|
||||
* @param Request $request The request to send
|
||||
* @param int $timeout The timeout in seconds
|
||||
* @param bool $withProgress Whether to attach a progress token to the request
|
||||
|
|
@ -170,18 +366,126 @@ class Protocol
|
|||
*/
|
||||
public function request(Request $request, int $timeout, bool $withProgress = false): Response|Error
|
||||
{
|
||||
$requestId = $this->state->nextRequestId();
|
||||
$request = $request->withId($requestId);
|
||||
$payload = $request->withId(0)->jsonSerialize();
|
||||
unset($payload['id']);
|
||||
|
||||
if ($withProgress) {
|
||||
$progressToken = "prog-{$requestId}";
|
||||
$request = $request->withMeta(['progressToken' => $progressToken]);
|
||||
$payload = self::withMeta($payload, ['progressToken' => 'prog-'.++$this->progressTokens]);
|
||||
}
|
||||
|
||||
if (null === $this->envelope) {
|
||||
return $this->exchange($payload, $timeout);
|
||||
}
|
||||
|
||||
for ($attempt = 0; $attempt < self::MAX_ROUND_TRIPS; ++$attempt) {
|
||||
$response = $this->exchange($payload, $timeout);
|
||||
|
||||
if ($response instanceof Error) {
|
||||
$retry = $this->withAcceptedVersion($response);
|
||||
|
||||
if (null === $retry) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$asked = InputRequestResolver::asked($response->result);
|
||||
|
||||
if (null === $asked) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// A fresh `inputResponses`/`requestState` pair every round, never
|
||||
// merged with the last: the answers belong to the ask that just
|
||||
// arrived, and carrying an old one forward is how state leaks
|
||||
// between rounds.
|
||||
//
|
||||
// Cast to object: inputResponses is a JSON object keyed by the
|
||||
// server's ids, but a PHP array with no entries or with sequential
|
||||
// numeric-string keys encodes as a JSON array instead.
|
||||
$payload['params'] = [
|
||||
...($payload['params'] ?? []),
|
||||
'inputResponses' => (object) $this->inputRequests->resolve($asked),
|
||||
];
|
||||
|
||||
unset($payload['params']['requestState']);
|
||||
|
||||
// Echoed byte-for-byte, and only when the server sent one: the
|
||||
// value is the server's to read, and inventing or reshaping it
|
||||
// would break whatever it encodes.
|
||||
if (\is_string($response->result['requestState'] ?? null)) {
|
||||
$payload['params']['requestState'] = $response->result['requestState'];
|
||||
}
|
||||
|
||||
$this->logger->debug('Retrying request with resolved input', [
|
||||
'method' => $payload['method'] ?? null,
|
||||
'round' => $attempt + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return Error::forInternalError(\sprintf('Server asked for input more than %d times without completing the request.', self::MAX_ROUND_TRIPS));
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the offered revision when the server refuses the current one,
|
||||
* or null when there is nothing to retry with.
|
||||
*
|
||||
* @param Error $error the server's refusal
|
||||
*/
|
||||
private function withAcceptedVersion(Error $error): ?ProtocolVersion
|
||||
{
|
||||
if (Error::UNSUPPORTED_PROTOCOL_VERSION !== $error->code || null === $this->envelope) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = \is_array($error->data) ? $error->data : [];
|
||||
$supported = \is_array($data['supported'] ?? null) ? $data['supported'] : [];
|
||||
$current = $this->envelope->protocolVersion();
|
||||
|
||||
foreach ($supported as $candidate) {
|
||||
$version = \is_string($candidate) ? ProtocolVersion::tryFrom($candidate) : null;
|
||||
|
||||
// Only another modern revision is reachable from here: falling back
|
||||
// to a handshake era one would mean opening a connection this
|
||||
// transport already decided it was not going to open.
|
||||
if (null === $version || !$version->isModern() || $version === $current) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->logger->info('Server rejected the offered protocol revision, retrying with one it supports.', [
|
||||
'offered' => $current->value,
|
||||
'retrying' => $version->value,
|
||||
]);
|
||||
|
||||
$this->envelope = $this->envelope->withProtocolVersion($version);
|
||||
$this->state->setProtocolVersion($version);
|
||||
|
||||
return $version;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One request on the wire: assign an id, send, and wait for its answer.
|
||||
*
|
||||
* A retry gets a new id, because the previous one is spent — the server has
|
||||
* already answered it, and reusing it would make the two indistinguishable.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*
|
||||
* @return Response<array<string, mixed>>|Error
|
||||
*/
|
||||
private function exchange(array $payload, int $timeout): Response|Error
|
||||
{
|
||||
$requestId = $this->state->nextRequestId();
|
||||
$payload['id'] = $requestId;
|
||||
|
||||
$this->state->addPendingRequest($requestId, $timeout);
|
||||
|
||||
try {
|
||||
$this->sendRequest($request);
|
||||
$this->send($payload, 'request');
|
||||
|
||||
$immediate = $this->state->consumeResponse($requestId);
|
||||
if (null !== $immediate) {
|
||||
|
|
@ -204,29 +508,49 @@ class Protocol
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request to the server.
|
||||
*/
|
||||
private function sendRequest(Request $request): void
|
||||
{
|
||||
$this->logger->debug('Sending request', [
|
||||
'id' => $request->getId(),
|
||||
'method' => $request::getMethod(),
|
||||
]);
|
||||
|
||||
$encoded = json_encode($request, \JSON_THROW_ON_ERROR);
|
||||
$this->transport?->send($encoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the server (fire and forget).
|
||||
*/
|
||||
public function sendNotification(Notification $notification): void
|
||||
{
|
||||
$this->logger->debug('Sending notification', ['method' => $notification::getMethod()]);
|
||||
$this->send($notification->jsonSerialize(), 'notification');
|
||||
}
|
||||
|
||||
$encoded = json_encode($notification, \JSON_THROW_ON_ERROR);
|
||||
$this->transport?->send($encoded);
|
||||
/**
|
||||
* Encode and hand a message to the transport, stamping the per-request
|
||||
* envelope on the way out when the revision calls for one.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function send(array $payload, string $kind): void
|
||||
{
|
||||
if (null !== $this->envelope) {
|
||||
$payload = $this->envelope->stamp($payload);
|
||||
}
|
||||
|
||||
$this->logger->debug('Sending '.$kind, [
|
||||
'id' => $payload['id'] ?? null,
|
||||
'method' => $payload['method'] ?? null,
|
||||
]);
|
||||
|
||||
$this->transport?->send(json_encode($payload, \JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<string, mixed> $meta
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function withMeta(array $payload, array $meta): array
|
||||
{
|
||||
$params = \is_array($payload['params'] ?? null) ? $payload['params'] : [];
|
||||
$existing = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
|
||||
|
||||
$params['_meta'] = [...$existing, ...$meta];
|
||||
$payload['params'] = $params;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
75
src/Client/Stateless/HeaderFactory.php
Normal file
75
src/Client/Stateless/HeaderFactory.php
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Wire\McpHeader;
|
||||
|
||||
/**
|
||||
* The HTTP headers a modern-era client puts on every POST (SEP-2243, SEP-2575).
|
||||
*
|
||||
* These exist so an intermediary can route and authorize MCP traffic without
|
||||
* parsing the body. That only works if the headers cannot disagree with what
|
||||
* they mirror, so they are derived here from the very message that is about to
|
||||
* be sent — never from what the caller believes it is sending.
|
||||
*
|
||||
* @see \Mcp\Server\Stateless\StandardHeaderValidator the server that checks this
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class HeaderFactory
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ToolCatalog $tools,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload a serialized JSON-RPC message
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function forMessage(array $payload, ProtocolVersion $protocolVersion): array
|
||||
{
|
||||
$method = $payload['method'] ?? null;
|
||||
|
||||
// A response to a server-initiated request carries no method to mirror;
|
||||
// the version header is unconditional and still applies.
|
||||
if (!\is_string($method)) {
|
||||
return [McpHeader::PROTOCOL_VERSION => $protocolVersion->value];
|
||||
}
|
||||
|
||||
$params = \is_array($payload['params'] ?? null) ? $payload['params'] : null;
|
||||
|
||||
$headers = [
|
||||
McpHeader::PROTOCOL_VERSION => $protocolVersion->value,
|
||||
McpHeader::METHOD => $method,
|
||||
];
|
||||
|
||||
if (null !== $name = McpHeader::nameFor($method, $params)) {
|
||||
// Tool and prompt names are only SHOULD-constrained to header-safe
|
||||
// characters and a resource URI is not constrained at all, so
|
||||
// anything unsafe is wrapped rather than dropped or mangled.
|
||||
$headers[McpHeader::NAME] = McpHeader::encode($name) ?? $name;
|
||||
}
|
||||
|
||||
if ('tools/call' === $method && \is_string($params['name'] ?? null)) {
|
||||
$arguments = \is_array($params['arguments'] ?? null) ? $params['arguments'] : [];
|
||||
|
||||
foreach ($this->tools->headersFor($params['name'], $arguments) as $suffix => $value) {
|
||||
$headers[McpHeader::PARAM_PREFIX.$suffix] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
}
|
||||
204
src/Client/Stateless/InputRequestResolver.php
Normal file
204
src/Client/Stateless/InputRequestResolver.php
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Exception\RuntimeException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\CreateSamplingMessageRequest;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Request\ListRootsRequest;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* Answers a server's multi round-trip asks (SEP-2322).
|
||||
*
|
||||
* The modern era removed server-initiated requests, so a server that needs
|
||||
* something from the client says so in the result and the client re-sends the
|
||||
* original call carrying the answers. What used to be an inbound request the
|
||||
* client responded to is now an entry in `inputRequests` it resolves locally —
|
||||
* so the same handlers answer it, and a caller that registered an elicitation
|
||||
* callback keeps working across the revision boundary.
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class InputRequestResolver
|
||||
{
|
||||
/**
|
||||
* The only requests a server may park in `inputRequests`. Anything else is
|
||||
* a server fault, and answering it would be inventing protocol.
|
||||
*
|
||||
* @var array<string, class-string<Request>>
|
||||
*/
|
||||
private const RESOLVABLE = [
|
||||
'elicitation/create' => ElicitRequest::class,
|
||||
'sampling/createMessage' => CreateSamplingMessageRequest::class,
|
||||
'roots/list' => ListRootsRequest::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* @param RequestHandlerInterface<mixed>[] $handlers
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $handlers,
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the `inputRequests` map off a result, or null when the result is
|
||||
* not an ask.
|
||||
*
|
||||
* A result with no `resultType` MUST be read as complete, so an absent
|
||||
* member is not an ask — only the explicit `input_required` is.
|
||||
*
|
||||
* @param array<string, mixed> $result
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public static function asked(array $result): ?array
|
||||
{
|
||||
if (($result['resultType'] ?? null) !== 'input_required') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \is_array($result['inputRequests'] ?? null) ? $result['inputRequests'] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves every ask into the `inputResponses` map the retry carries.
|
||||
*
|
||||
* Keys are the server's, and each answer goes back under the key it was
|
||||
* asked under — the client never reorders or renames them, since that map
|
||||
* is how the server correlates answers to questions.
|
||||
*
|
||||
* @param array<string, mixed> $inputRequests
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*
|
||||
* @throws RuntimeException when an ask cannot be answered at all
|
||||
*/
|
||||
public function resolve(array $inputRequests): array
|
||||
{
|
||||
$responses = [];
|
||||
|
||||
foreach ($inputRequests as $key => $ask) {
|
||||
$responses[(string) $key] = $this->answer((string) $key, $ask);
|
||||
}
|
||||
|
||||
return $responses;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function answer(string $key, mixed $ask): array
|
||||
{
|
||||
if (!\is_array($ask) || !\is_string($ask['method'] ?? null)) {
|
||||
throw new RuntimeException(\sprintf('Server asked for input under "%s" without a method to answer.', $key));
|
||||
}
|
||||
|
||||
$method = $ask['method'];
|
||||
$class = self::RESOLVABLE[$method] ?? null;
|
||||
|
||||
if (null === $class) {
|
||||
throw new RuntimeException(\sprintf('Server asked for input under "%s" using "%s", which is not a request a client can answer.', $key, $method));
|
||||
}
|
||||
|
||||
$params = $ask['params'] ?? null;
|
||||
if ($params instanceof \stdClass) {
|
||||
$params = (array) $params;
|
||||
}
|
||||
|
||||
// The ask is a bare method/params pair, but the handlers speak in
|
||||
// messages. The id never reaches the wire — the answer is keyed by
|
||||
// $key — so any id will do.
|
||||
$request = $class::fromArray([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => $key,
|
||||
'method' => $method,
|
||||
'params' => \is_array($params) ? $params : null,
|
||||
]);
|
||||
|
||||
$this->logger->debug('Resolving multi round-trip input request', [
|
||||
'key' => $key,
|
||||
'method' => $method,
|
||||
]);
|
||||
|
||||
$result = $this->dispatch($request);
|
||||
|
||||
if ($result instanceof Error) {
|
||||
throw new RuntimeException(\sprintf('Cannot answer the server\'s "%s" input request under "%s": %s', $method, $key, $result->message));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|Error
|
||||
*/
|
||||
private function dispatch(Request $request): array|Error
|
||||
{
|
||||
foreach ($this->handlers as $handler) {
|
||||
if (!$handler->supports($request)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $handler->handle($request);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('Input request handler failed', [
|
||||
'method' => $request::getMethod(),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return Error::forInternalError($e->getMessage(), $request->getId());
|
||||
}
|
||||
|
||||
if ($response instanceof Error) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
return self::resultOf($response);
|
||||
}
|
||||
|
||||
return Error::forMethodNotFound(
|
||||
\sprintf('Client does not handle "%s" requests.', $request::getMethod()),
|
||||
$request->getId(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Response<mixed> $response
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function resultOf(Response $response): array
|
||||
{
|
||||
$result = $response->result;
|
||||
|
||||
if ($result instanceof \JsonSerializable) {
|
||||
$result = $result->jsonSerialize();
|
||||
}
|
||||
|
||||
if ($result instanceof \stdClass) {
|
||||
$result = (array) $result;
|
||||
}
|
||||
|
||||
return \is_array($result) ? $result : [];
|
||||
}
|
||||
}
|
||||
78
src/Client/Stateless/RequestEnvelope.php
Normal file
78
src/Client/Stateless/RequestEnvelope.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\Implementation;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
|
||||
/**
|
||||
* Stamps the per-request `_meta` that replaces the `initialize` handshake
|
||||
* (SEP-2575).
|
||||
*
|
||||
* The modern era has no connection-scoped negotiation, so what a handshake
|
||||
* would have said once now rides on every message: the revision this request
|
||||
* is written against, and what the client can be asked to do while answering
|
||||
* it. {@see RequestMeta} is the server's reader for the same envelope.
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#meta
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class RequestEnvelope
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ProtocolVersion $protocolVersion,
|
||||
private readonly ClientCapabilities $capabilities,
|
||||
private readonly Implementation $clientInfo,
|
||||
) {
|
||||
}
|
||||
|
||||
public function protocolVersion(): ProtocolVersion
|
||||
{
|
||||
return $this->protocolVersion;
|
||||
}
|
||||
|
||||
public function withProtocolVersion(ProtocolVersion $protocolVersion): self
|
||||
{
|
||||
return new self($protocolVersion, $this->capabilities, $this->clientInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges the envelope into an encoded message, preserving whatever `_meta`
|
||||
* the caller already put there — a `progressToken` most of all, which would
|
||||
* otherwise be dropped and take every progress notification with it.
|
||||
*
|
||||
* @param array<string, mixed> $payload a serialized JSON-RPC message
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function stamp(array $payload): array
|
||||
{
|
||||
$params = \is_array($payload['params'] ?? null) ? $payload['params'] : [];
|
||||
$meta = \is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
|
||||
|
||||
$params['_meta'] = [
|
||||
...$meta,
|
||||
RequestMeta::PROTOCOL_VERSION => $this->protocolVersion->value,
|
||||
// ClientCapabilities encodes an empty set as `{}` already; `[]`
|
||||
// would reach the server as a JSON array and fail its check.
|
||||
RequestMeta::CLIENT_CAPABILITIES => $this->capabilities,
|
||||
RequestMeta::CLIENT_INFO => $this->clientInfo,
|
||||
];
|
||||
|
||||
$payload['params'] = $params;
|
||||
|
||||
return $payload;
|
||||
}
|
||||
}
|
||||
206
src/Client/Stateless/ToolCatalog.php
Normal file
206
src/Client/Stateless/ToolCatalog.php
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
<?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\Client\Stateless;
|
||||
|
||||
use Mcp\Schema\Wire\McpHeader;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
/**
|
||||
* What the client remembers from `tools/list` in order to send correct headers.
|
||||
*
|
||||
* Mirroring an argument into `Mcp-Param-*` needs the tool's `x-mcp-header`
|
||||
* annotations, which only the listing carries — by the time `tools/call` is
|
||||
* built the schema is gone unless something kept it.
|
||||
*
|
||||
* Keeping it here is also where SEP-2243's client-side obligation lands: a tool
|
||||
* whose annotations are malformed is dropped rather than called, because the
|
||||
* client cannot produce the headers such a tool demands. One bad definition
|
||||
* removes that tool and nothing else — a server with ten good tools and one
|
||||
* broken one stays usable.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class ToolCatalog
|
||||
{
|
||||
/** @var array<string, array<string, mixed>> tool name to input schema */
|
||||
private array $schemas = [];
|
||||
|
||||
/** @var array<string, string> tool name to the reason it was refused */
|
||||
private array $rejected = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $logger = new NullLogger(),
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a listing page and returns the tools a caller may actually use.
|
||||
*
|
||||
* A tool whose annotations are malformed is dropped from the result, which
|
||||
* is how the client "rejects" it: it never reaches the caller, so it cannot
|
||||
* be called, and the tools listed beside it are untouched.
|
||||
*
|
||||
* @param list<array<string, mixed>> $tools raw `tools/list` entries
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function record(array $tools): array
|
||||
{
|
||||
$usable = [];
|
||||
|
||||
foreach ($tools as $tool) {
|
||||
$name = $tool['name'] ?? null;
|
||||
$schema = $tool['inputSchema'] ?? null;
|
||||
|
||||
if (!\is_string($name) || !\is_array($schema)) {
|
||||
$usable[] = $tool;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
unset($this->rejected[$name], $this->schemas[$name]);
|
||||
|
||||
if (null !== $reason = McpHeader::checkAnnotations($schema)) {
|
||||
$this->rejected[$name] = $reason;
|
||||
|
||||
$this->logger->warning('Excluding tool with an invalid "x-mcp-header" annotation', [
|
||||
'tool' => $name,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->schemas[$name] = $schema;
|
||||
$usable[] = $tool;
|
||||
}
|
||||
|
||||
return $usable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client refuses to call this tool.
|
||||
*
|
||||
* Only a tool that was listed and failed validation is refused; an unknown
|
||||
* name is not, since the client may legitimately call a tool it never
|
||||
* listed and the server is the authority on whether it exists.
|
||||
*/
|
||||
public function isRejected(string $name): bool
|
||||
{
|
||||
return isset($this->rejected[$name]);
|
||||
}
|
||||
|
||||
public function reasonFor(string $name): ?string
|
||||
{
|
||||
return $this->rejected[$name] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Mcp-Param-*` headers a call to $name must carry, given its arguments.
|
||||
*
|
||||
* An argument that is absent or null contributes no header — the
|
||||
* specification reads a missing header as a missing value, so sending an
|
||||
* empty one would assert something different.
|
||||
*
|
||||
* @param array<string, mixed> $arguments
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function headersFor(string $name, array $arguments): array
|
||||
{
|
||||
$schema = $this->schemas[$name] ?? null;
|
||||
|
||||
if (null === $schema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$headers = [];
|
||||
|
||||
foreach (self::annotations($schema) as $header => $path) {
|
||||
$value = self::valueAt($arguments, $path);
|
||||
|
||||
if (null === $value) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$encoded = McpHeader::encode($value);
|
||||
|
||||
if (null === $encoded) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$headers[$header] = $encoded;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `x-mcp-header` annotation in $schema, as header name to the property
|
||||
* path it mirrors.
|
||||
*
|
||||
* Only statically reachable properties count, matching the server's reader:
|
||||
* a chain through `items`, a composition keyword or a `$ref` cannot be
|
||||
* resolved without the instance, so an annotation there is out of bounds.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
* @param list<string> $path
|
||||
*
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private static function annotations(array $schema, array $path = []): array
|
||||
{
|
||||
$properties = $schema['properties'] ?? null;
|
||||
|
||||
if (!\is_array($properties)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$found = [];
|
||||
|
||||
foreach ($properties as $property => $definition) {
|
||||
if (!\is_array($definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$here = [...$path, (string) $property];
|
||||
|
||||
if (\is_string($definition['x-mcp-header'] ?? null)) {
|
||||
$found[$definition['x-mcp-header']] = $here;
|
||||
}
|
||||
|
||||
$found = [...$found, ...self::annotations($definition, $here)];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $arguments
|
||||
* @param list<string> $path
|
||||
*/
|
||||
private static function valueAt(array $arguments, array $path): mixed
|
||||
{
|
||||
$node = $arguments;
|
||||
|
||||
foreach ($path as $segment) {
|
||||
if (!\is_array($node) || !\array_key_exists($segment, $node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$node = $node[$segment];
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
}
|
||||
37
src/Client/Transport/HeaderAwareTransportInterface.php
Normal file
37
src/Client/Transport/HeaderAwareTransportInterface.php
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?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\Client\Transport;
|
||||
|
||||
/**
|
||||
* A transport whose messages carry headers the protocol gets to fill in.
|
||||
*
|
||||
* From 2026-07-28 an HTTP client mirrors parts of the body into request headers
|
||||
* so intermediaries can route without parsing it (SEP-2243), and declares the
|
||||
* revision it is speaking on every POST (SEP-2575). Deriving those needs the
|
||||
* message, which the protocol has and the transport only sees encoded — hence a
|
||||
* callback rather than a setter: the transport asks about the exact payload it
|
||||
* is about to write, so the two can never drift apart.
|
||||
*
|
||||
* Separate from {@see TransportInterface} because it is meaningless over stdio,
|
||||
* and because an existing transport must keep working without it.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
interface HeaderAwareTransportInterface extends TransportInterface
|
||||
{
|
||||
/**
|
||||
* Register the source of per-message headers.
|
||||
*
|
||||
* @param callable(string $payload): array<string, string> $callback receives the encoded message
|
||||
*/
|
||||
public function onHeaders(callable $callback): void;
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ use Psr\Log\LoggerInterface;
|
|||
*
|
||||
* @author Kyrian Obikwelu <koshnawaza@gmail.com>
|
||||
*/
|
||||
class HttpTransport extends BaseTransport
|
||||
class HttpTransport extends BaseTransport implements HeaderAwareTransportInterface
|
||||
{
|
||||
private ClientInterface $httpClient;
|
||||
private RequestFactoryInterface $requestFactory;
|
||||
|
|
@ -40,6 +40,9 @@ class HttpTransport extends BaseTransport
|
|||
|
||||
private ?string $sessionId = null;
|
||||
|
||||
/** @var (callable(string): array<string, string>)|null */
|
||||
private $headerCallback;
|
||||
|
||||
/** @var McpFiber|null */
|
||||
private ?\Fiber $activeFiber = null;
|
||||
|
||||
|
|
@ -113,6 +116,11 @@ class HttpTransport extends BaseTransport
|
|||
$this->logger->info('HTTP client connected and initialized', ['endpoint' => $this->endpoint]);
|
||||
}
|
||||
|
||||
public function onHeaders(callable $callback): void
|
||||
{
|
||||
$this->headerCallback = $callback;
|
||||
}
|
||||
|
||||
public function send(string $data): void
|
||||
{
|
||||
$request = $this->requestFactory->createRequest('POST', $this->endpoint)
|
||||
|
|
@ -124,6 +132,13 @@ class HttpTransport extends BaseTransport
|
|||
$request = $request->withHeader('Mcp-Session-Id', $this->sessionId);
|
||||
}
|
||||
|
||||
// Protocol-derived first, so an explicitly configured header still wins:
|
||||
// the caller passing one is making a deliberate choice about this
|
||||
// connection, and a proxy credential is the usual reason.
|
||||
foreach ($this->protocolHeaders($data) as $name => $value) {
|
||||
$request = $request->withHeader($name, $value);
|
||||
}
|
||||
|
||||
foreach ($this->headers as $name => $value) {
|
||||
$request = $request->withHeader($name, $value);
|
||||
}
|
||||
|
|
@ -199,6 +214,27 @@ class HttpTransport extends BaseTransport
|
|||
$this->handleClose('Transport closed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function protocolHeaders(string $payload): array
|
||||
{
|
||||
if (!\is_callable($this->headerCallback)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return ($this->headerCallback)($payload);
|
||||
} catch (\Throwable $e) {
|
||||
// Headers mirror the body; failing to derive them is a bug worth
|
||||
// reporting, but dropping the request would be a worse outcome than
|
||||
// sending it the way an earlier revision would have.
|
||||
$this->logger->error('Could not derive protocol headers', ['exception' => $e]);
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private function tick(): void
|
||||
{
|
||||
$this->processSSEStream();
|
||||
|
|
|
|||
43
src/Schema/Request/DiscoverRequest.php
Normal file
43
src/Schema/Request/DiscoverRequest.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?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\Schema\Request;
|
||||
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
|
||||
/**
|
||||
* Asks a server what it is and what it speaks (SEP-2575).
|
||||
*
|
||||
* The modern era's optional replacement for `initialize`: it reports rather
|
||||
* than negotiates, so a client MAY call it to learn the server's capabilities
|
||||
* up front, and MAY skip it and simply declare a revision on each request.
|
||||
*
|
||||
* @see \Mcp\Schema\Result\DiscoverResult
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class DiscoverRequest extends Request
|
||||
{
|
||||
public static function getMethod(): string
|
||||
{
|
||||
return 'server/discover';
|
||||
}
|
||||
|
||||
protected static function fromParams(?array $params): static
|
||||
{
|
||||
return new self();
|
||||
}
|
||||
|
||||
protected function getParams(): ?array
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
namespace Mcp\Schema;
|
||||
|
||||
use Mcp\Exception\InvalidArgumentException;
|
||||
use Mcp\Schema\Wire\McpHeader;
|
||||
|
||||
/**
|
||||
* Definition for a tool the client can call.
|
||||
|
|
@ -130,94 +131,11 @@ class Tool implements \JsonSerializable
|
|||
// the whole tool definition invalid, so it is refused where the tool
|
||||
// is defined rather than discovered when a header comparison
|
||||
// mysteriously fails.
|
||||
if (null !== $reason = $this->checkHeaderAnnotations()) {
|
||||
if (null !== $reason = McpHeader::checkAnnotations($this->inputSchema)) {
|
||||
throw new InvalidArgumentException(\sprintf('Tool "%s" has an invalid "x-mcp-header" annotation: %s', $this->name, $reason));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates every `x-mcp-header` annotation reachable through `properties`
|
||||
* in an input schema (SEP-2243).
|
||||
*
|
||||
* The value becomes an HTTP field name, so it has to be one; it has to be
|
||||
* unique case-insensitively, or two arguments would fight over one header;
|
||||
* and it may only sit on a primitive that is not `number`, because a float
|
||||
* has no single decimal spelling for a receiver to compare against.
|
||||
*
|
||||
* @return string|null the reason it is invalid, or null when every annotation is well-formed
|
||||
*/
|
||||
private function checkHeaderAnnotations(): ?string
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach ($this->headerAnnotations($this->inputSchema) as [$name, $type, $path]) {
|
||||
if ('' === $name) {
|
||||
return \sprintf('the annotation at "%s" is empty', $path);
|
||||
}
|
||||
|
||||
// RFC 9110 tchar; excludes CR, LF and every other control character.
|
||||
if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) {
|
||||
return \sprintf('"%s" is not a valid HTTP field name', $name);
|
||||
}
|
||||
|
||||
$folded = strtolower($name);
|
||||
if (isset($seen[$folded])) {
|
||||
return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path);
|
||||
}
|
||||
$seen[$folded] = $path;
|
||||
|
||||
if ('number' === $type) {
|
||||
return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path);
|
||||
}
|
||||
|
||||
if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) {
|
||||
return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every annotation reachable through `properties` alone, as name, declared
|
||||
* type and dotted path.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
*
|
||||
* @return list<array{string, ?string, string}>
|
||||
*/
|
||||
private function headerAnnotations(array $schema, string $prefix = ''): array
|
||||
{
|
||||
$properties = $schema['properties'] ?? null;
|
||||
|
||||
if (!\is_array($properties)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$found = [];
|
||||
|
||||
foreach ($properties as $property => $definition) {
|
||||
if (!\is_array($definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = '' === $prefix ? (string) $property : $prefix.'.'.$property;
|
||||
$annotation = $definition['x-mcp-header'] ?? null;
|
||||
|
||||
if (null !== $annotation) {
|
||||
if (!\is_string($annotation)) {
|
||||
$found[] = ['', null, $path];
|
||||
} else {
|
||||
$found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path];
|
||||
}
|
||||
}
|
||||
|
||||
$found = [...$found, ...$this->headerAnnotations($definition, $path)];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ToolData $data
|
||||
*/
|
||||
|
|
|
|||
217
src/Schema/Wire/McpHeader.php
Normal file
217
src/Schema/Wire/McpHeader.php
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<?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\Schema\Wire;
|
||||
|
||||
/**
|
||||
* The HTTP header vocabulary that mirrors a request's JSON-RPC body (SEP-2243).
|
||||
*
|
||||
* Both sides of the wire need the same answers — which header carries a
|
||||
* method's subject, and how a value that is not header-safe is wrapped — so
|
||||
* the rules live here rather than once in the client's emitter and again in
|
||||
* the server's validator, where they could quietly disagree.
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports#http-headers
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class McpHeader
|
||||
{
|
||||
public const METHOD = 'Mcp-Method';
|
||||
public const NAME = 'Mcp-Name';
|
||||
public const PARAM_PREFIX = 'Mcp-Param-';
|
||||
public const PROTOCOL_VERSION = 'MCP-Protocol-Version';
|
||||
|
||||
/** Wrapper marking a header value as Base64 of its UTF-8 representation. */
|
||||
private const BASE64_PREFIX = '=?base64?';
|
||||
private const BASE64_SUFFIX = '?=';
|
||||
|
||||
/**
|
||||
* The subject of a request, per method. Anything unlisted is exempt.
|
||||
*
|
||||
* @param array<string, mixed>|null $params
|
||||
*/
|
||||
public static function nameFor(string $method, ?array $params): ?string
|
||||
{
|
||||
$value = match ($method) {
|
||||
'tools/call', 'prompts/get' => $params['name'] ?? null,
|
||||
'resources/read' => $params['uri'] ?? null,
|
||||
'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return \is_string($value) ? $value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a mirrored argument as a header value, wrapping it when it is not
|
||||
* header-safe.
|
||||
*
|
||||
* Booleans travel as `true`/`false` and integers as decimal digits, both of
|
||||
* which are always safe. A string is wrapped when it carries a control
|
||||
* character, anything outside US-ASCII, or leading or trailing whitespace —
|
||||
* the last because a receiver is entitled to trim the value (RFC 9110
|
||||
* §5.5), which would otherwise silently change it.
|
||||
*
|
||||
* Returns null for a value that cannot be mirrored at all.
|
||||
*/
|
||||
public static function encode(mixed $value): ?string
|
||||
{
|
||||
$rendered = match (true) {
|
||||
\is_bool($value) => $value ? 'true' : 'false',
|
||||
\is_int($value) => (string) $value,
|
||||
\is_string($value) => $value,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (null === $rendered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return self::isSafe($rendered) ? $rendered : self::wrap($rendered);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps a `=?base64?…?=` value, or returns a plain value unchanged.
|
||||
*
|
||||
* Strict: PHP's decoder accepts mispadded input and returns plausible
|
||||
* bytes, which would turn a corrupted header into a silent mismatch.
|
||||
* Null when the wrapper is present but its contents are not valid Base64.
|
||||
*/
|
||||
public static function decode(string $value): ?string
|
||||
{
|
||||
if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX));
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
|
||||
if (false === $decoded || base64_encode($decoded) !== $encoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates every `x-mcp-header` annotation reachable through `properties`
|
||||
* in an input schema (SEP-2243).
|
||||
*
|
||||
* The value becomes an HTTP field name, so it has to be one; it has to be
|
||||
* unique case-insensitively, or two arguments would fight over one header;
|
||||
* and it may only sit on a primitive that is not `number`, because a float
|
||||
* has no single decimal spelling for a receiver to compare against.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
*
|
||||
* @return string|null the reason it is invalid, or null when every annotation is well-formed
|
||||
*/
|
||||
public static function checkAnnotations(array $schema): ?string
|
||||
{
|
||||
$seen = [];
|
||||
|
||||
foreach (self::annotations($schema) as [$name, $type, $path]) {
|
||||
if ('' === $name) {
|
||||
return \sprintf('the annotation at "%s" is empty', $path);
|
||||
}
|
||||
|
||||
// RFC 9110 tchar; excludes CR, LF and every other control character.
|
||||
if (1 !== preg_match('/^[!#$%&\'*+\-.^_`|~0-9A-Za-z]+$/', $name)) {
|
||||
return \sprintf('"%s" is not a valid HTTP field name', $name);
|
||||
}
|
||||
|
||||
$folded = strtolower($name);
|
||||
if (isset($seen[$folded])) {
|
||||
return \sprintf('"%s" is declared twice, at "%s" and "%s"', $name, $seen[$folded], $path);
|
||||
}
|
||||
$seen[$folded] = $path;
|
||||
|
||||
if ('number' === $type) {
|
||||
return \sprintf('"%s" is on a "number" property ("%s"), which cannot be mirrored', $name, $path);
|
||||
}
|
||||
|
||||
if (null !== $type && !\in_array($type, ['string', 'integer', 'boolean'], true)) {
|
||||
return \sprintf('"%s" is on a "%s" property ("%s"); only string, integer and boolean can be mirrored', $name, $type, $path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every annotation reachable through `properties` alone, as name, declared
|
||||
* type and dotted path.
|
||||
*
|
||||
* @param array<string, mixed> $schema
|
||||
*
|
||||
* @return list<array{string, ?string, string}>
|
||||
*/
|
||||
public static function annotations(array $schema, string $prefix = ''): array
|
||||
{
|
||||
$properties = $schema['properties'] ?? null;
|
||||
|
||||
if (!\is_array($properties)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$found = [];
|
||||
|
||||
foreach ($properties as $property => $definition) {
|
||||
if (!\is_array($definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = '' === $prefix ? (string) $property : $prefix.'.'.$property;
|
||||
$annotation = $definition['x-mcp-header'] ?? null;
|
||||
|
||||
if (null !== $annotation) {
|
||||
if (!\is_string($annotation)) {
|
||||
$found[] = ['', null, $path];
|
||||
} else {
|
||||
$found[] = [$annotation, \is_string($definition['type'] ?? null) ? $definition['type'] : null, $path];
|
||||
}
|
||||
}
|
||||
|
||||
$found = [...$found, ...self::annotations($definition, $path)];
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
private static function wrap(string $value): string
|
||||
{
|
||||
return self::BASE64_PREFIX.base64_encode($value).self::BASE64_SUFFIX;
|
||||
}
|
||||
|
||||
/**
|
||||
* Printable US-ASCII with no leading or trailing whitespace. Interior
|
||||
* spaces are fine; a tab is not, since it is a control character that
|
||||
* field parsers are allowed to fold.
|
||||
*
|
||||
* A literal that already has the wrapper's shape is not safe either: sent
|
||||
* unchanged, {@see self::decode()} would unwrap it as if it were Base64
|
||||
* and hand back something other than the literal.
|
||||
*/
|
||||
private static function isSafe(string $value): bool
|
||||
{
|
||||
if ($value !== trim($value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (str_starts_with($value, self::BASE64_PREFIX) && str_ends_with($value, self::BASE64_SUFFIX)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 1 === preg_match('/^[\x20-\x7E]*$/', $value);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Server\Stateless;
|
|||
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
use Mcp\Exception\ToolNotFoundException;
|
||||
use Mcp\Schema\Wire\McpHeader;
|
||||
|
||||
/**
|
||||
* Checks that a request's HTTP headers agree with its JSON-RPC body (SEP-2243).
|
||||
|
|
@ -25,13 +26,9 @@ use Mcp\Exception\ToolNotFoundException;
|
|||
*/
|
||||
final class StandardHeaderValidator
|
||||
{
|
||||
public const METHOD_HEADER = 'Mcp-Method';
|
||||
public const NAME_HEADER = 'Mcp-Name';
|
||||
public const PARAM_HEADER_PREFIX = 'Mcp-Param-';
|
||||
|
||||
/** Wrapper marking a header value as Base64 of its UTF-8 representation. */
|
||||
private const BASE64_PREFIX = '=?base64?';
|
||||
private const BASE64_SUFFIX = '?=';
|
||||
public const METHOD_HEADER = McpHeader::METHOD;
|
||||
public const NAME_HEADER = McpHeader::NAME;
|
||||
public const PARAM_HEADER_PREFIX = McpHeader::PARAM_PREFIX;
|
||||
|
||||
public function __construct(
|
||||
private readonly ?RegistryInterface $registry = null,
|
||||
|
|
@ -113,14 +110,7 @@ final class StandardHeaderValidator
|
|||
*/
|
||||
public static function nameFor(string $method, ?array $params): ?string
|
||||
{
|
||||
$value = match ($method) {
|
||||
'tools/call', 'prompts/get' => $params['name'] ?? null,
|
||||
'resources/read' => $params['uri'] ?? null,
|
||||
'tasks/get', 'tasks/update', 'tasks/cancel' => $params['taskId'] ?? null,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return \is_string($value) ? $value : null;
|
||||
return McpHeader::nameFor($method, $params);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -285,26 +275,10 @@ final class StandardHeaderValidator
|
|||
|
||||
/**
|
||||
* Unwraps a `=?base64?…?=` value, or returns a plain value unchanged.
|
||||
*
|
||||
* Strict: PHP's decoder accepts mispadded input and returns plausible
|
||||
* bytes, which would turn a corrupted header into a silent mismatch.
|
||||
* Null when the wrapper is present but its contents are not valid Base64.
|
||||
*/
|
||||
public static function decode(string $value): ?string
|
||||
{
|
||||
if (!str_starts_with($value, self::BASE64_PREFIX) || !str_ends_with($value, self::BASE64_SUFFIX)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$encoded = substr($value, \strlen(self::BASE64_PREFIX), -\strlen(self::BASE64_SUFFIX));
|
||||
|
||||
$decoded = base64_decode($encoded, true);
|
||||
|
||||
if (false === $decoded || base64_encode($decoded) !== $encoded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
return McpHeader::decode($value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
|||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
|
|
@ -30,35 +31,53 @@ if (!$url || !$scenario) {
|
|||
exit(1);
|
||||
}
|
||||
|
||||
// The runner names the revision it is testing; without honouring it the client
|
||||
// would open every scenario with `initialize` and never reach the modern wire.
|
||||
$version = ProtocolVersion::tryFrom(getenv('MCP_CONFORMANCE_PROTOCOL_VERSION') ?: '')
|
||||
?? ProtocolVersion::V2025_11_25;
|
||||
|
||||
// Scenario-specific data (tool arguments, credentials) the runner passes in.
|
||||
$context = json_decode(getenv('MCP_CONFORMANCE_CONTEXT') ?: '[]', true);
|
||||
$context = is_array($context) ? $context : [];
|
||||
|
||||
@mkdir(__DIR__.'/logs', 0777, true);
|
||||
$logger = new FileLogger(__DIR__.'/logs/client-conformance.log', true);
|
||||
$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s', $scenario, $url));
|
||||
$logger->info(sprintf('Starting client conformance test: scenario=%s, url=%s, version=%s', $scenario, $url, $version->value));
|
||||
|
||||
$builder = Client::builder()
|
||||
->setClientInfo('mcp-conformance-test-client', '1.0.0')
|
||||
->setProtocolVersion($version)
|
||||
->setInitTimeout(30)
|
||||
->setRequestTimeout(60)
|
||||
->setLogger($logger);
|
||||
|
||||
if ('elicitation-sep1034-client-defaults' === $scenario) {
|
||||
/**
|
||||
* Accepts every elicitation with an empty payload.
|
||||
*
|
||||
* Enough for the scenarios here, which check that the client asked and echoed
|
||||
* correctly rather than what a user would have typed.
|
||||
*/
|
||||
$acceptElicitation = new class($logger) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly Psr\Log\LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$this->logger->info('Received elicitation request, accepting with empty content');
|
||||
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
|
||||
}
|
||||
};
|
||||
|
||||
if (in_array($scenario, ['elicitation-sep1034-client-defaults', 'sep-2322-client-request-state'], true)) {
|
||||
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||
$builder->addRequestHandler(new class($logger) implements RequestHandlerInterface {
|
||||
public function __construct(private readonly Psr\Log\LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
$this->logger->info('Received elicitation request, accepting with empty content');
|
||||
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, []));
|
||||
}
|
||||
});
|
||||
$builder->addRequestHandler($acceptElicitation);
|
||||
}
|
||||
|
||||
$client = $builder->build();
|
||||
|
|
@ -76,9 +95,11 @@ try {
|
|||
break;
|
||||
|
||||
case 'tools_call':
|
||||
$toolName = $toolsResult->tools[0]->name ?? 'test-tool';
|
||||
$client->callTool($toolName, []);
|
||||
$logger->info(sprintf('Called tool: %s', $toolName));
|
||||
// The scenario asserts both arguments arrive as numbers, so the
|
||||
// call has to be made by name with real values rather than
|
||||
// whatever tool happens to be listed first.
|
||||
$client->callTool('add_numbers', ['a' => 2, 'b' => 3]);
|
||||
$logger->info('Called tool: add_numbers');
|
||||
break;
|
||||
|
||||
case 'elicitation-sep1034-client-defaults':
|
||||
|
|
@ -87,6 +108,74 @@ try {
|
|||
$logger->info(sprintf('Called tool: %s', $toolName));
|
||||
break;
|
||||
|
||||
case 'json-schema-2020-12-preservation':
|
||||
// Round-trips the focal tool's inputSchema back through the echo
|
||||
// tool so the harness can diff what survived the client's parsing
|
||||
// (SEP-1613 keywords, plus the SEP-2106 vocabulary).
|
||||
$focal = null;
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
if ('json_schema_2020_12_tool' === $tool->name) {
|
||||
$focal = $tool;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (null === $focal) {
|
||||
throw new RuntimeException('Mock server did not advertise json_schema_2020_12_tool.');
|
||||
}
|
||||
|
||||
$client->callTool('json_schema_echo', ['schema' => $focal->inputSchema]);
|
||||
$logger->info('Echoed the observed inputSchema back via json_schema_echo');
|
||||
break;
|
||||
|
||||
case 'http-standard-headers':
|
||||
// Exercises every method that carries an Mcp-Method or Mcp-Name
|
||||
// header, including the ones whose subject needs Base64 wrapping.
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
$client->callTool($tool->name, []);
|
||||
}
|
||||
|
||||
$resources = $client->listResources();
|
||||
foreach ($resources->resources as $resource) {
|
||||
$client->readResource($resource->uri);
|
||||
}
|
||||
|
||||
$prompts = $client->listPrompts();
|
||||
foreach ($prompts->prompts as $prompt) {
|
||||
$client->getPrompt($prompt->name, []);
|
||||
}
|
||||
|
||||
$logger->info('Exercised every header-carrying method');
|
||||
break;
|
||||
|
||||
case 'http-custom-headers':
|
||||
// The runner supplies the exact argument values, each chosen to hit
|
||||
// a different corner of the encoding rules.
|
||||
foreach ($context['toolCalls'] ?? [] as $call) {
|
||||
$client->callTool($call['name'], $call['arguments'] ?? []);
|
||||
$logger->info(sprintf('Called tool: %s', $call['name']));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'http-invalid-tool-headers':
|
||||
// Only the tools that survived the listing are callable; calling
|
||||
// any of the malformed ones is the failure this scenario looks for.
|
||||
foreach ($toolsResult->tools as $tool) {
|
||||
$client->callTool($tool->name, ['region' => 'us-west1']);
|
||||
$logger->info(sprintf('Called tool: %s', $tool->name));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'sep-2322-client-request-state':
|
||||
// Each tool drives one rule: echo the state back, omit it when none
|
||||
// was sent, keep an unrelated call clean, and treat a result with
|
||||
// no resultType as complete.
|
||||
foreach (['test_mrtr_echo_state', 'test_mrtr_unrelated', 'test_mrtr_no_state', 'test_mrtr_no_result_type'] as $tool) {
|
||||
$client->callTool($tool, []);
|
||||
$logger->info(sprintf('Called tool: %s', $tool));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
$logger->warning(sprintf('Unknown scenario: %s', $scenario));
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
client:
|
||||
- elicitation-sep1034-client-defaults
|
||||
- sse-retry
|
||||
- tools_call:tool-add-numbers
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed
|
||||
- auth/metadata-default
|
||||
- auth/metadata-var1
|
||||
- auth/metadata-var2
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
client:
|
||||
- request-metadata
|
||||
- http-standard-headers
|
||||
- http-custom-headers
|
||||
- http-invalid-tool-headers:sep-2243-invalid-tool-tools-list-gate
|
||||
- http-invalid-tool-headers:sep-2243-client-reject-invalid-tool
|
||||
- sep-2322-client-request-state
|
||||
- tools_call:tool-add-numbers
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-tool-found
|
||||
- json-schema-2020-12-preservation:json-schema-2020-12-client-echo-completed
|
||||
- auth/metadata-default
|
||||
- auth/metadata-var1
|
||||
- auth/metadata-var2
|
||||
|
|
|
|||
105
tests/Integration/DualEraElicitationTest.php
Normal file
105
tests/Integration/DualEraElicitationTest.php
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
<?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\Integration;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
|
||||
/**
|
||||
* The elicitation example, driven from both eras against one endpoint.
|
||||
*
|
||||
* This is the example where the two lifecycles genuinely part ways: the
|
||||
* handshake era can interrupt a call to ask the user something, and the modern
|
||||
* era cannot. Its handlers fork in one place — a private `ask()` — and these
|
||||
* tests are what say the fork works both ways round.
|
||||
*/
|
||||
final class DualEraElicitationTest extends DualEraExampleTestCase
|
||||
{
|
||||
protected const ANSWERS = [
|
||||
'confirm' => true,
|
||||
'party_size' => 4,
|
||||
'date' => '2026-09-01',
|
||||
'dietary' => 'vegan',
|
||||
'rating' => '5',
|
||||
'comments' => 'Excellent',
|
||||
];
|
||||
|
||||
protected static function server(): string
|
||||
{
|
||||
return __DIR__.'/../../examples/server/elicitation/server.php';
|
||||
}
|
||||
|
||||
protected static function portBase(): int
|
||||
{
|
||||
return 9500;
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a confirmation is collected on $_dataName')]
|
||||
public function testConfirmAction(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('confirm_action', ['actionDescription' => 'delete the staging database']);
|
||||
|
||||
$this->assertStringContainsString('Action confirmed', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a multi-field form is collected on $_dataName')]
|
||||
public function testBookRestaurant(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('book_restaurant', ['restaurantName' => 'Osteria']);
|
||||
|
||||
$this->assertStringContainsString('Reservation confirmed at Osteria for 4 guests', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('feedback with an optional field is collected on $_dataName')]
|
||||
public function testCollectFeedback(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era, elicitation: true);
|
||||
|
||||
$result = $client->callTool('collect_feedback', ['topic' => 'the new checkout flow']);
|
||||
|
||||
$this->assertStringContainsString('Thank you for your feedback', self::text($result));
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
|
||||
#[DataProvider('provideEras')]
|
||||
#[TestDox('a client that declares no elicitation is told what to do instead, on $_dataName')]
|
||||
public function testWithoutTheCapability(ProtocolVersion $era): void
|
||||
{
|
||||
$client = $this->connect($era);
|
||||
|
||||
// Both eras refuse an ask the client cannot answer. The handshake era
|
||||
// finds out from the capability the client declared at initialize, the
|
||||
// modern era from the envelope on this very request — and the example
|
||||
// says the same thing either way.
|
||||
try {
|
||||
$answer = self::text($client->callTool('confirm_action', ['actionDescription' => 'anything']));
|
||||
$this->assertStringContainsString('does not support elicitation', $answer);
|
||||
} catch (\Throwable $e) {
|
||||
$this->assertStringContainsString('did not declare it can provide', $e->getMessage());
|
||||
}
|
||||
|
||||
$client->disconnect();
|
||||
}
|
||||
}
|
||||
144
tests/Integration/DualEraExampleTestCase.php
Normal file
144
tests/Integration/DualEraExampleTestCase.php
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<?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\Integration;
|
||||
|
||||
use Mcp\Client;
|
||||
use Mcp\Client\Handler\Request\RequestHandlerInterface;
|
||||
use Mcp\Client\Transport\HttpTransport;
|
||||
use Mcp\Schema\ClientCapabilities;
|
||||
use Mcp\Schema\Content\TextContent;
|
||||
use Mcp\Schema\Enum\ElicitAction;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ElicitRequest;
|
||||
use Mcp\Schema\Result\CallToolResult;
|
||||
use Mcp\Schema\Result\ElicitResult;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
/**
|
||||
* Base for tests that drive one example server from both protocol eras.
|
||||
*
|
||||
* The example is started once, on one port, and every client in the test
|
||||
* reaches it at that one URL — which is the property being tested. Nothing here
|
||||
* mounts a second endpoint or restarts anything between eras.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
abstract class DualEraExampleTestCase extends TestCase
|
||||
{
|
||||
/** Answers whatever the server elicits, so a tool that asks can complete. */
|
||||
protected const ANSWERS = [];
|
||||
|
||||
private ?Process $server = null;
|
||||
private int $port;
|
||||
|
||||
/** Absolute path to the example's server script. */
|
||||
abstract protected static function server(): string;
|
||||
|
||||
/** Port range base, so concurrent test classes do not collide. */
|
||||
abstract protected static function portBase(): int;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
// PHP_CLI_SERVER_WORKERS does not reliably fork more than one worker on
|
||||
// PHP 8.1 (see php/php-src#9400), which reproduces the very deadlock the
|
||||
// extra workers exist to avoid: every request below hangs to the test
|
||||
// timeout instead of running. Skip until either PHP 8.1 is dropped or
|
||||
// this suite stops depending on `php -S` for multi-worker concurrency.
|
||||
if (\PHP_VERSION_ID < 80200) {
|
||||
$this->markTestSkipped('php -S does not reliably fork multiple workers on PHP 8.1 (PHP_CLI_SERVER_WORKERS); see php/php-src#9400.');
|
||||
}
|
||||
|
||||
$this->port = static::portBase() + (getmypid() % 200);
|
||||
|
||||
// More than one worker because the handshake era needs it: a tool that
|
||||
// asks the client mid-call holds its SSE response open while the client
|
||||
// POSTs the answer on a second connection. One worker deadlocks there —
|
||||
// a property of `php -S`, not of the server.
|
||||
$this->server = new Process(
|
||||
['php', '-S', \sprintf('127.0.0.1:%d', $this->port), static::server()],
|
||||
env: ['PHP_CLI_SERVER_WORKERS' => '4'],
|
||||
);
|
||||
$this->server->start();
|
||||
|
||||
$deadline = microtime(true) + 5;
|
||||
while (microtime(true) < $deadline) {
|
||||
if (@fsockopen('127.0.0.1', $this->port, $errno, $error, 0.1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
usleep(50_000);
|
||||
}
|
||||
|
||||
$this->fail(\sprintf('The example server did not start: %s', $this->server->getErrorOutput()));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->server?->stop();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{ProtocolVersion}>
|
||||
*/
|
||||
public static function provideEras(): iterable
|
||||
{
|
||||
yield 'the handshake era' => [ProtocolVersion::V2025_11_25];
|
||||
yield 'the modern era' => [ProtocolVersion::V2026_07_28];
|
||||
}
|
||||
|
||||
protected static function text(CallToolResult $result): string
|
||||
{
|
||||
$first = $result->content[0] ?? null;
|
||||
|
||||
self::assertInstanceOf(TextContent::class, $first);
|
||||
|
||||
return $first->text;
|
||||
}
|
||||
|
||||
protected function connect(ProtocolVersion $era, bool $elicitation = false): Client
|
||||
{
|
||||
$builder = Client::builder()
|
||||
->setClientInfo('dual-era-integration-client', '1.0.0')
|
||||
->setProtocolVersion($era)
|
||||
->setRequestTimeout(10);
|
||||
|
||||
if ($elicitation) {
|
||||
$answers = static::ANSWERS;
|
||||
|
||||
$builder->setCapabilities(new ClientCapabilities(elicitation: true));
|
||||
$builder->addRequestHandler(new class($answers) implements RequestHandlerInterface {
|
||||
/** @param array<string, mixed> $answers */
|
||||
public function __construct(private readonly array $answers)
|
||||
{
|
||||
}
|
||||
|
||||
public function supports(Request $request): bool
|
||||
{
|
||||
return $request instanceof ElicitRequest;
|
||||
}
|
||||
|
||||
public function handle(Request $request): Response
|
||||
{
|
||||
return new Response($request->getId(), new ElicitResult(ElicitAction::Accept, $this->answers));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$client = $builder->build();
|
||||
$client->connect(new HttpTransport(\sprintf('http://127.0.0.1:%d/', $this->port)));
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue