[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
12 changed files with 2443 additions and 248 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in a new issue