[Server] Close the conformance gaps in the modern lifecycle (#447)
* [Server] Let the client gateway see modern-era capabilities The six supports*() probes read session state only InitializeHandler wrote, so every request served statelessly reported no client capabilities at all — a tool guarding on them took the unsupported branch without a signal. StatelessProtocol now writes the request's declaration under the same keys. * [Server] Reject the RPCs 2026-07-28 removed resources/subscribe and resources/unsubscribe were still dispatched and answered 200 OK, recording subscriptions the modern era never reads. They join initialize, ping and logging/setLevel in the era guard; the handlers stay registered because the handshake era still serves them. * [Server] Decode a wrapped Mcp-Name before comparing it The Base64 sentinel was only unwrapped for Mcp-Param-*, so any resource URI or tool name outside the header-safe ASCII set — which the spec explicitly tells clients to wrap — was compared encoded and refused with -32020. * [Server] Require the MCP-Protocol-Version header It was only checked for contradicting the body, so omitting it entirely passed — leaving the value intermediaries route on unenforced. Tied to the header validator's presence, since that is what a header-bearing transport installs and stdio carries its metadata inline. * [Server] Read the modern-era request body whole A single read() takes whatever the stream cares to give — 64 bytes from a chunked transfer is legal — so an oversized-but-valid POST came back as a parse error. Shares the legacy transport's incremental read as a trait. * [Schema][Server] Answer a notification with a status, not a response A body with no id was dispatched as a request and came back as a JSON-RPC error carrying "id": "" — an id nobody issued, and on a path a client uses to tell a modern server from a legacy one. Notifications now get 202 and no body, and Error omits an id it could not read instead of emptying it. * [Server] Stop emitting the error codes 2026-07-28 reserved resources/read answered -32002, which this revision forbids; prompts/get and completion/complete answered it for an unknown *name*, which it never meant; tools/call answered -32601, which belongs to an unknown method. Only the resources/read code is revision-gated — older peers expect -32002 there, and nowhere else. Also routes the -32021 capability exception out of the prompt and resource handlers, which were swallowing it into -32603.
This commit is contained in:
parent
ef0c1c2808
commit
5e73683079
20 changed files with 855 additions and 106 deletions
|
|
@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
|
|||
0.8.0
|
||||
-----
|
||||
|
||||
* [BC Break] Answer a not-found subject with `-32602` (Invalid params) instead of `-32002`, which the 2026-07-28 revision reserves and forbids emitting (SEP-2164). `resources/read` picks the code from the revision serving the request — `-32602` with the uri in `error.data` from `2026-07-28` on, `-32002` below. `prompts/get` for an unknown prompt, `completion/complete` for an unknown reference and `tools/call` for an unknown tool switch to `-32602` in *every* revision: `-32002` was never the code for those. Adds `ProtocolVersion::usesInvalidParamsForResourceNotFound()`.
|
||||
* Add the multi round-trip requests pattern for the 2026-07-28 lifecycle (SEP-2322): a `tools/call` or `prompts/get` handler returning `Mcp\Schema\Result\InputRequiredResult` comes back as `resultType: "input_required"` carrying the `inputRequests` it needs answered and an opaque `requestState`; the client retries the same request with `inputResponses`, which the handler reads through `RequestContext::getInputContext()`. `Mcp\Server\Stateless\RequestStateCodec` signs and time-bounds the state — set the key with `Builder::setRequestStateKey()`.
|
||||
* Validate the standard MCP request headers under the 2026-07-28 lifecycle (SEP-2243): `Mcp\Server\Stateless\StandardHeaderValidator`, set with `Builder::setHeaderValidator()`, checks that `Mcp-Method` and `Mcp-Name` agree with the body they travel with and that a `Mcp-Param-*` mirrors the argument its tool marked `x-mcp-header`, answering `-32020` when they disagree. Intermediaries route on these headers, so a value contradicting the body has to be refused rather than ignored.
|
||||
* [BC Break] `Mcp\Schema\JsonRpc\Error` accepts `null` as its `$id`, and `getId()` may return it. An error response whose id could not be read now omits the member instead of sending `"id": ""` — which claimed the peer had issued a request with an empty-string id. All the `for*()` factories default to `null`, `fromArray()` accepts a missing or explicitly-null id, and `MessageFactory` decodes both as an id-less error rather than rejecting them.
|
||||
|
|
|
|||
|
|
@ -115,6 +115,21 @@ enum ProtocolVersion: string
|
|||
return !$this->isAtLeast(self::V2026_07_28);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this revision answers a missing resource with `-32602`.
|
||||
*
|
||||
* SEP-2164, part of {@see self::V2026_07_28}, retired the bespoke `-32002`
|
||||
* in favour of the JSON-RPC code that already meant this, and reserved
|
||||
* `-32002` so it is never reused. Earlier revisions still expect it, and
|
||||
* clients are told to keep accepting it from them.
|
||||
*
|
||||
* @see https://modelcontextprotocol.io/specification/2026-07-28/basic/index#error-codes
|
||||
*/
|
||||
public function usesInvalidParamsForResourceNotFound(): bool
|
||||
{
|
||||
return $this->isAtLeast(self::V2026_07_28);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this revision is at least as new as $minimum.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ final class CallToolHandler implements RequestHandlerInterface
|
|||
} catch (ToolNotFoundException $e) {
|
||||
$this->logger->error('Tool not found', ['name' => $toolName, 'exception' => $e]);
|
||||
|
||||
return new Error($request->getId(), Error::METHOD_NOT_FOUND, $e->getMessage());
|
||||
// -32601 answers an unknown *method*; tools/call exists, it is the
|
||||
// name in its params that does not.
|
||||
return Error::forInvalidParams($e->getMessage(), $request->getId());
|
||||
}
|
||||
|
||||
$inputSchema = $reference->tool->inputSchema;
|
||||
|
|
|
|||
|
|
@ -85,7 +85,9 @@ final class CompletionCompleteHandler implements RequestHandlerInterface
|
|||
|
||||
return new Response($request->getId(), new CompletionCompleteResult($paged, $total, $hasMore));
|
||||
} catch (PromptNotFoundException|ResourceNotFoundException $e) {
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
// The reference names something the server does not have, which is
|
||||
// a bad parameter rather than a missing resource.
|
||||
return Error::forInvalidParams($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
return Error::forInternalError('Error while handling completion request', $request->getId());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ namespace Mcp\Server\Handler\Request;
|
|||
|
||||
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||
use Mcp\Exception\PromptGetException;
|
||||
use Mcp\Exception\PromptNotFoundException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
|
|
@ -70,6 +71,10 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
$formatted = $reference->formatResult($result);
|
||||
|
||||
return new Response($request->getId(), new GetPromptResult($formatted));
|
||||
} catch (MissingRequiredClientCapabilityException $e) {
|
||||
// Not a handler failure — the request was unservable, and the client
|
||||
// needs to retry declaring the capability. Rendered as -32021.
|
||||
throw $e;
|
||||
} catch (PromptGetException $e) {
|
||||
$this->logger->error(\sprintf('Error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]);
|
||||
|
||||
|
|
@ -77,7 +82,9 @@ final class GetPromptHandler implements RequestHandlerInterface
|
|||
} catch (PromptNotFoundException $e) {
|
||||
$this->logger->error('Prompt not found', ['prompt_name' => $promptName, 'exception' => $e]);
|
||||
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
// An unknown prompt name is a bad parameter, not a missing
|
||||
// resource: -32002 was never the code for this.
|
||||
return Error::forInvalidParams($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(\sprintf('Unexpected error while handling prompt "%s": "%s".', $promptName, $e->getMessage()), ['exception' => $e]);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ namespace Mcp\Server\Handler\Request;
|
|||
use Mcp\Capability\Registry\ReferenceHandlerInterface;
|
||||
use Mcp\Capability\Registry\ResourceTemplateReference;
|
||||
use Mcp\Capability\RegistryInterface;
|
||||
use Mcp\Exception\MissingRequiredClientCapabilityException;
|
||||
use Mcp\Exception\ResourceNotFoundException;
|
||||
use Mcp\Exception\ResourceReadException;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
|
|
@ -21,6 +22,7 @@ use Mcp\Schema\JsonRpc\Request;
|
|||
use Mcp\Schema\JsonRpc\Response;
|
||||
use Mcp\Schema\Request\ReadResourceRequest;
|
||||
use Mcp\Schema\Result\ReadResourceResult;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Session\SessionInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
|
@ -76,6 +78,10 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
}
|
||||
|
||||
return new Response($request->getId(), new ReadResourceResult($formatted));
|
||||
} catch (MissingRequiredClientCapabilityException $e) {
|
||||
// Not a handler failure — the request was unservable, and the client
|
||||
// needs to retry declaring the capability. Rendered as -32021.
|
||||
throw $e;
|
||||
} catch (ResourceReadException $e) {
|
||||
$this->logger->error(\sprintf('Error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]);
|
||||
|
||||
|
|
@ -83,7 +89,12 @@ final class ReadResourceHandler implements RequestHandlerInterface
|
|||
} catch (ResourceNotFoundException $e) {
|
||||
$this->logger->error('Resource not found', ['uri' => $uri, 'exception' => $e]);
|
||||
|
||||
return Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
// SEP-2164 retired -32002 in favour of the JSON-RPC code that
|
||||
// already meant this. Older peers still expect the old one, so the
|
||||
// revision answering the request decides.
|
||||
return (new RequestContext($session, $request))->getProtocolVersion()->usesInvalidParamsForResourceNotFound()
|
||||
? Error::forInvalidParams($e->getMessage(), $request->getId(), ['uri' => $uri])
|
||||
: Error::forResourceNotFound($e->getMessage(), $request->getId());
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(\sprintf('Unexpected error while reading resource "%s": "%s".', $uri, $e->getMessage()), ['exception' => $e]);
|
||||
|
||||
|
|
|
|||
|
|
@ -89,8 +89,18 @@ final class StandardHeaderValidator
|
|||
return \sprintf('Missing required %s header (body carries "%s").', self::NAME_HEADER, $expected);
|
||||
}
|
||||
|
||||
if ($declared !== $expected) {
|
||||
return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $declared, $expected);
|
||||
// Tool and prompt names are only SHOULD-constrained to header-safe
|
||||
// characters and a resource URI is not constrained at all, so the
|
||||
// client wraps anything unsafe — decode before comparing or every
|
||||
// conformant client carrying a non-ASCII subject is refused.
|
||||
$decoded = self::decode($declared);
|
||||
|
||||
if (null === $decoded) {
|
||||
return \sprintf('%s header is not a well-formed Base64 wrapper.', self::NAME_HEADER);
|
||||
}
|
||||
|
||||
if ($decoded !== $expected) {
|
||||
return \sprintf('%s header "%s" does not match the body value "%s".', self::NAME_HEADER, $decoded, $expected);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ use Mcp\Exception\RequestStateException;
|
|||
use Mcp\JsonRpc\MessageFactory;
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Schema\JsonRpc\Notification;
|
||||
use Mcp\Schema\JsonRpc\Request;
|
||||
use Mcp\Schema\JsonRpc\ResultInterface;
|
||||
use Mcp\Schema\Result\DiscoverResult;
|
||||
|
|
@ -47,12 +46,22 @@ final class StatelessProtocol
|
|||
/**
|
||||
* Methods the modern era deleted. Answered as unknown methods, which is
|
||||
* what they are to a modern server.
|
||||
*
|
||||
* A deny-list rather than an allow-list on purpose: extensions add methods
|
||||
* this class has never heard of, so an unlisted method has to reach
|
||||
* dispatch. Every removal named in the 2026-07-28 changelog belongs here —
|
||||
* the handlers behind them stay registered for the handshake era, which is
|
||||
* why the era guard, and not the registration, is what turns them off.
|
||||
*/
|
||||
public const REMOVED_METHODS = [
|
||||
'initialize',
|
||||
'notifications/initialized',
|
||||
'ping',
|
||||
'logging/setLevel',
|
||||
// Replaced by the `resourceSubscriptions` filter of subscriptions/listen.
|
||||
'resources/subscribe',
|
||||
'resources/unsubscribe',
|
||||
'notifications/roots/list_changed',
|
||||
];
|
||||
|
||||
public const DISCOVER_METHOD = 'server/discover';
|
||||
|
|
@ -85,6 +94,27 @@ final class StatelessProtocol
|
|||
private readonly ?RequestStateCodec $requestStateCodec = null,
|
||||
) {
|
||||
$this->codec = $codec ?? new Rev2026Codec($configuration->serverInfo);
|
||||
|
||||
if (null === $this->headerValidator) {
|
||||
// Not fatal: a transport without a header layer — stdio — has
|
||||
// nothing to validate. But on HTTP the headers are REQUIRED for
|
||||
// compliance, so an absent validator there is a silently
|
||||
// non-conformant server and worth saying out loud once.
|
||||
$this->logger->warning('No StandardHeaderValidator configured; the SEP-2243 request headers will not be enforced. This is correct only for a transport without a header layer.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the transport carrying this dispatcher has a header layer whose
|
||||
* required members must be present.
|
||||
*
|
||||
* The validator's presence is the signal: it is what a header-bearing
|
||||
* transport installs, and stdio carries its metadata inline instead
|
||||
* (see the stdio binding's "Request Metadata").
|
||||
*/
|
||||
private function requiresTransportHeaders(): bool
|
||||
{
|
||||
return null !== $this->headerValidator;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -111,7 +141,13 @@ final class StatelessProtocol
|
|||
// already knows to omit a null id instead of fabricating one, and
|
||||
// "id": "" would falsely claim the sender issued a request with an
|
||||
// empty-string id.
|
||||
//
|
||||
// An absent id and an unreadable one are different messages: the first
|
||||
// is a notification, the second a malformed request. JSON-RPC 2.0
|
||||
// writes "no id" as an explicit null, so that counts as absent too.
|
||||
$isNotification = !\array_key_exists('id', $decoded) || null === $decoded['id'];
|
||||
$id = $decoded['id'] ?? null;
|
||||
|
||||
if (!\is_string($id) && !\is_int($id)) {
|
||||
$id = null;
|
||||
}
|
||||
|
|
@ -123,6 +159,19 @@ final class StatelessProtocol
|
|||
|
||||
$params = \is_array($decoded['params'] ?? null) ? $decoded['params'] : null;
|
||||
|
||||
// No id is a notification. It gets an acknowledgment, never a response
|
||||
// — answering one with a JSON-RPC message would invent a correlation
|
||||
// the client has no request to match it against. Checked before the
|
||||
// `_meta` parse: notification params carry `NotificationMetaObject`,
|
||||
// which has none of a request's required members.
|
||||
if ($isNotification) {
|
||||
return $this->acknowledge($method);
|
||||
}
|
||||
|
||||
if (null === $id) {
|
||||
return StatelessResult::error(Error::forInvalidRequest('A JSON-RPC request id must be a string or a number.'), 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$meta = RequestMeta::fromParams($params);
|
||||
} catch (MissingRequestMetaException $e) {
|
||||
|
|
@ -140,14 +189,6 @@ final class StatelessProtocol
|
|||
}
|
||||
|
||||
if (self::DISCOVER_METHOD === $method || self::LISTEN_METHOD === $method) {
|
||||
// Both answer with a single response tied to this request's id,
|
||||
// unlike a genuine notification — so unlike the general dispatch
|
||||
// path below, a missing id here is this request being invalid
|
||||
// rather than this request needing no answer at all.
|
||||
if (null === $id) {
|
||||
return StatelessResult::error(Error::forInvalidRequest(\sprintf('Method "%s" requires an "id".', $method)), 400);
|
||||
}
|
||||
|
||||
if (self::DISCOVER_METHOD === $method) {
|
||||
return $this->encode($method, $id, $this->discover());
|
||||
}
|
||||
|
|
@ -165,6 +206,29 @@ final class StatelessProtocol
|
|||
return $this->dispatch($method, $decoded, $meta, $id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a notification.
|
||||
*
|
||||
* This revision's core defines no client-to-server notification over HTTP —
|
||||
* `notifications/cancelled` is stdio-only, since closing the response
|
||||
* stream is the cancellation signal here — so anything arriving is either
|
||||
* an extension's or a client still speaking an older revision. Accepting
|
||||
* the former and refusing the latter both come out as a status with no
|
||||
* body; what must not happen is a JSON-RPC response.
|
||||
*/
|
||||
private function acknowledge(string $method): StatelessResult
|
||||
{
|
||||
if (\in_array($method, self::REMOVED_METHODS, true)) {
|
||||
$this->logger->debug('Refused a notification this revision removed.', ['method' => $method]);
|
||||
|
||||
return StatelessResult::empty(400);
|
||||
}
|
||||
|
||||
$this->logger->debug('Accepted a notification with no handler to run.', ['method' => $method]);
|
||||
|
||||
return StatelessResult::empty(202);
|
||||
}
|
||||
|
||||
/**
|
||||
* Header and `_meta` must agree before the version can be judged supported:
|
||||
* when they disagree the server cannot know which the client meant, so a
|
||||
|
|
@ -176,6 +240,19 @@ final class StatelessProtocol
|
|||
{
|
||||
$headerVersion = $this->header($headers, 'MCP-Protocol-Version');
|
||||
|
||||
// REQUIRED on every POST. The 2025-03-26 fallback for a header-less
|
||||
// request exists only for servers choosing to serve pre-2025-06-18
|
||||
// clients, which a modern-only endpoint is not.
|
||||
if (null === $headerVersion && $this->requiresTransportHeaders()) {
|
||||
return StatelessResult::error(
|
||||
Error::forHeaderMismatch(
|
||||
\sprintf('Missing required MCP-Protocol-Version header (_meta declares "%s").', $meta->protocolVersion),
|
||||
$id,
|
||||
),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (null !== $headerVersion && $headerVersion !== $meta->protocolVersion) {
|
||||
return StatelessResult::error(
|
||||
Error::forHeaderMismatch(
|
||||
|
|
@ -273,12 +350,6 @@ final class StatelessProtocol
|
|||
|
||||
$request = $messages[0] ?? null;
|
||||
|
||||
// A notification (no id) is never answered, successful or not — this is
|
||||
// one of the SDK's own registered message classes, just not a Request.
|
||||
if ($request instanceof Notification) {
|
||||
return StatelessResult::accepted();
|
||||
}
|
||||
|
||||
// The factory hands back an exception object rather than throwing one,
|
||||
// distinguishing a genuinely unknown method (-32601, the client should
|
||||
// stop asking) from a known method the message could not otherwise be
|
||||
|
|
@ -310,6 +381,12 @@ final class StatelessProtocol
|
|||
$session = new Session(new InMemorySessionStore());
|
||||
$session->set(RequestMeta::class, $meta);
|
||||
|
||||
// Under the same keys the handshake era writes, so everything reading
|
||||
// connection state — ClientGateway's capability probes above all — sees
|
||||
// this request's declaration instead of an empty session.
|
||||
$session->set('client_capabilities', $meta->clientCapabilities->jsonSerialize());
|
||||
$session->set('protocol_version', $meta->protocolVersion);
|
||||
|
||||
try {
|
||||
$input = $this->liftInputContext($decoded['params'] ?? null);
|
||||
} catch (RequestStateException $e) {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ final class StatelessResult
|
|||
public readonly ?\Closure $frames = null,
|
||||
private readonly ?array $body = null,
|
||||
private readonly string|int|null $id = null,
|
||||
private readonly bool $bodyless = false,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -54,13 +55,12 @@ final class StatelessResult
|
|||
}
|
||||
|
||||
/**
|
||||
* A notification never gets an answer, successful or not — per JSON-RPC, a
|
||||
* message with no id must never receive a response. This is the "I read
|
||||
* it, there is nothing to say back" answer: HTTP 202 with no body.
|
||||
* A status with no body — what a notification gets, since it has no id to
|
||||
* correlate a JSON-RPC message against.
|
||||
*/
|
||||
public static function accepted(): self
|
||||
public static function empty(int $httpStatus): self
|
||||
{
|
||||
return new self(null, 202);
|
||||
return new self(null, $httpStatus, bodyless: true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,13 +80,9 @@ final class StatelessResult
|
|||
return null !== $this->frames;
|
||||
}
|
||||
|
||||
/**
|
||||
* An accepted notification: no message, no body, no frames — just the
|
||||
* status code.
|
||||
*/
|
||||
public function isEmpty(): bool
|
||||
{
|
||||
return null === $this->message && null === $this->body && null === $this->frames;
|
||||
return $this->bodyless;
|
||||
}
|
||||
|
||||
public function isError(): bool
|
||||
|
|
@ -104,6 +100,10 @@ final class StatelessResult
|
|||
], \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
if ($this->bodyless) {
|
||||
throw new \LogicException('This result carries no body; send its status alone.');
|
||||
}
|
||||
|
||||
if (null === $this->message) {
|
||||
throw new \LogicException('A streaming or empty result has no single JSON body; check isStream()/isEmpty() first.');
|
||||
}
|
||||
|
|
|
|||
61
src/Server/Transport/ReadsBoundedBody.php
Normal file
61
src/Server/Transport/ReadsBoundedBody.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?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\Server\Transport;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* Reads a request body whole, bounded by a byte cap.
|
||||
*
|
||||
* Shared because a single `read($cap)` is not enough: PSR-7 promises only *up
|
||||
* to* the requested length, and a stream over `php://input` or a chunked
|
||||
* transfer routinely returns less — which truncates the payload into a parse
|
||||
* error the caller cannot act on.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
trait ReadsBoundedBody
|
||||
{
|
||||
/**
|
||||
* Returns the body contents, or `null` when the payload exceeds $maxBytes.
|
||||
*
|
||||
* A stream advertising its size is rejected up front; otherwise the read is
|
||||
* incremental and stops at the cap, so an unbounded stream cannot exhaust
|
||||
* memory.
|
||||
*/
|
||||
private function readBoundedBody(StreamInterface $body, int $maxBytes): ?string
|
||||
{
|
||||
if ($body->isSeekable()) {
|
||||
$body->rewind();
|
||||
}
|
||||
|
||||
$size = $body->getSize();
|
||||
if (null !== $size && $size > $maxBytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = '';
|
||||
while (!$body->eof()) {
|
||||
$chunk = $body->read(8192);
|
||||
if ('' === $chunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
$contents .= $chunk;
|
||||
if (\strlen($contents) > $maxBytes) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
}
|
||||
|
|
@ -21,7 +21,6 @@ use Psr\Http\Message\ResponseFactoryInterface;
|
|||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Message\StreamFactoryInterface;
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
|
@ -36,6 +35,8 @@ use Psr\Log\NullLogger;
|
|||
*/
|
||||
final class StatelessHttpTransport
|
||||
{
|
||||
use ReadsBoundedBody;
|
||||
|
||||
/**
|
||||
* Upper bound on the request body read for a POST, guarding against memory
|
||||
* exhaustion from an oversized (or unbounded chunked) payload.
|
||||
|
|
@ -106,11 +107,7 @@ final class StatelessHttpTransport
|
|||
);
|
||||
}
|
||||
|
||||
$body = $request->getBody();
|
||||
if ($body->isSeekable()) {
|
||||
$body->rewind();
|
||||
}
|
||||
$payload = $this->readBody($body);
|
||||
$payload = $this->readBoundedBody($request->getBody(), $this->maxBodyBytes);
|
||||
|
||||
if (null === $payload) {
|
||||
$this->logger->warning('Rejected POST body exceeding the maximum allowed size.', ['limit' => $this->maxBodyBytes]);
|
||||
|
|
@ -171,39 +168,6 @@ final class StatelessHttpTransport
|
|||
->withBody(new CallbackStream($callback, $this->logger));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the request body, bounded by {@see self::$maxBodyBytes}.
|
||||
*
|
||||
* Returns the body contents, or `null` when the payload exceeds the cap.
|
||||
* A single `read()` call is not enough: PSR-7 allows it to return fewer
|
||||
* bytes than requested, so a body arriving across more than one physical
|
||||
* read would otherwise be silently truncated. Reading incrementally to
|
||||
* EOF is what {@see StreamableHttpTransport::readBody()} already does for
|
||||
* the same reason.
|
||||
*/
|
||||
private function readBody(StreamInterface $body): ?string
|
||||
{
|
||||
$size = $body->getSize();
|
||||
if (null !== $size && $size > $this->maxBodyBytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = '';
|
||||
while (!$body->eof()) {
|
||||
$chunk = $body->read(8192);
|
||||
if ('' === $chunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
$contents .= $chunk;
|
||||
if (\strlen($contents) > $this->maxBodyBytes) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
private function json(string $payload, int $status): ResponseInterface
|
||||
{
|
||||
return $this->responseFactory->createResponse($status)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ use Symfony\Component\Uid\Uuid;
|
|||
*/
|
||||
class StreamableHttpTransport extends BaseTransport
|
||||
{
|
||||
use ReadsBoundedBody;
|
||||
|
||||
public const SESSION_HEADER = 'Mcp-Session-Id';
|
||||
public const PROTOCOL_VERSION_HEADER = 'Mcp-Protocol-Version';
|
||||
|
||||
|
|
@ -293,33 +295,10 @@ class StreamableHttpTransport extends BaseTransport
|
|||
|
||||
/**
|
||||
* Reads the request body, bounded by {@see self::$maxBodyBytes}.
|
||||
*
|
||||
* Returns the body contents, or `null` when the payload exceeds the cap. When
|
||||
* the stream advertises a size we reject up-front; otherwise (e.g. chunked
|
||||
* transfer with unknown size) we read incrementally and stop at the cap so an
|
||||
* unbounded stream cannot exhaust memory.
|
||||
*/
|
||||
private function readBody(StreamInterface $body): ?string
|
||||
{
|
||||
$size = $body->getSize();
|
||||
if (null !== $size && $size > $this->maxBodyBytes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$contents = '';
|
||||
while (!$body->eof()) {
|
||||
$chunk = $body->read(8192);
|
||||
if ('' === $chunk) {
|
||||
break;
|
||||
}
|
||||
|
||||
$contents .= $chunk;
|
||||
if (\strlen($contents) > $this->maxBodyBytes) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return $contents;
|
||||
return $this->readBoundedBody($body, $this->maxBodyBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -22,16 +22,6 @@ server:
|
|||
# chunks will need — rather than anything specific to progress.
|
||||
- tools-call-with-progress:tools-call-with-progress
|
||||
|
||||
# --- SHOULD-level warnings -------------------------------------------------
|
||||
# These report WARNING rather than FAILURE (the severity follows the spec
|
||||
# keyword), so the run summary counts them as passed — but the baseline
|
||||
# evaluator still wants them declared, and declaring them is right: each is a
|
||||
# SHOULD the SDK does not yet honour. Both concern the resources/read
|
||||
# not-found shape: SEP-2164 moved it to -32602 with the uri in error.data,
|
||||
# and the SDK still answers with the older -32002 and no data.
|
||||
- sep-2164-resource-not-found:sep-2164-data-uri
|
||||
- sep-2164-resource-not-found:sep-2164-error-code
|
||||
|
||||
# --- Pre-existing, not lifecycle-specific ----------------------------------
|
||||
# Neither is caused by (or fixable in) the modern lifecycle; both reproduce
|
||||
# on the handshake endpoint. Listed so the draft run is honest.
|
||||
|
|
|
|||
|
|
@ -141,4 +141,17 @@ class ProtocolVersionTest extends TestCase
|
|||
|
||||
$this->assertFalse(ProtocolVersion::V2026_07_28->requiresObjectStructuredContent());
|
||||
}
|
||||
|
||||
#[TestDox('SEP-2164 moves resource-not-found from -32002 to -32602')]
|
||||
public function testUsesInvalidParamsForResourceNotFound(): void
|
||||
{
|
||||
foreach (ProtocolVersion::handshakeVersions() as $version) {
|
||||
$this->assertFalse(
|
||||
$version->usesInvalidParamsForResourceNotFound(),
|
||||
\sprintf('%s predates SEP-2164 and still expects -32002.', $version->value),
|
||||
);
|
||||
}
|
||||
|
||||
$this->assertTrue(ProtocolVersion::V2026_07_28->usesInvalidParamsForResourceNotFound());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ class CallToolHandlerTest extends TestCase
|
|||
|
||||
$this->assertInstanceOf(Error::class, $response);
|
||||
$this->assertEquals($request->getId(), $response->id);
|
||||
$this->assertEquals(Error::METHOD_NOT_FOUND, $response->code);
|
||||
$this->assertEquals(Error::INVALID_PARAMS, $response->code);
|
||||
}
|
||||
|
||||
public function testHandleToolCallExceptionReturnsResponseWithErrorResult(): void
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ class GetPromptHandlerTest extends TestCase
|
|||
|
||||
$this->assertInstanceOf(Error::class, $response);
|
||||
$this->assertEquals($request->getId(), $response->id);
|
||||
$this->assertEquals(Error::RESOURCE_NOT_FOUND, $response->code);
|
||||
$this->assertEquals(Error::INVALID_PARAMS, $response->code);
|
||||
$this->assertEquals('Prompt not found: "nonexistent_prompt".', $response->message);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -101,6 +101,52 @@ class StandardHeaderValidatorTest extends TestCase
|
|||
));
|
||||
}
|
||||
|
||||
#[TestDox('a Base64-wrapped Mcp-Name is decoded before it is compared')]
|
||||
#[DataProvider('wrappedNames')]
|
||||
public function testWrappedNameHeaderIsDecoded(string $method, string $member, string $subject): void
|
||||
{
|
||||
$this->assertNull($this->validator->validate(
|
||||
$method,
|
||||
[$member => $subject],
|
||||
[
|
||||
'Mcp-Method' => $method,
|
||||
'Mcp-Name' => '=?base64?'.base64_encode($subject).'?=',
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, string, string}>
|
||||
*/
|
||||
public static function wrappedNames(): iterable
|
||||
{
|
||||
yield 'non-ASCII tool name' => ['tools/call', 'name', 'grüße_welt'];
|
||||
yield 'CJK prompt name' => ['prompts/get', 'name', '天気予報'];
|
||||
yield 'resource URI with a non-ASCII path' => ['resources/read', 'uri', 'file:///projects/münchen/config.json'];
|
||||
yield 'value padded with spaces' => ['tools/call', 'name', ' padded '];
|
||||
yield 'value matching the sentinel pattern' => ['tools/call', 'name', '=?base64?literal?='];
|
||||
}
|
||||
|
||||
#[TestDox('a wrapped Mcp-Name that disagrees with the body is still rejected')]
|
||||
public function testWrappedNameHeaderStillHasToMatch(): void
|
||||
{
|
||||
$this->assertStringContainsString('does not match', (string) $this->validator->validate(
|
||||
'tools/call',
|
||||
['name' => 'grüße_welt'],
|
||||
['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?'.base64_encode('etwas_anderes').'?='],
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('a malformed Base64 wrapper on Mcp-Name is refused, not compared raw')]
|
||||
public function testMalformedWrappedNameHeaderIsRefused(): void
|
||||
{
|
||||
$this->assertStringContainsString('well-formed Base64', (string) $this->validator->validate(
|
||||
'tools/call',
|
||||
['name' => 'do_thing'],
|
||||
['Mcp-Method' => 'tools/call', 'Mcp-Name' => '=?base64?SGVsbG8!?='],
|
||||
));
|
||||
}
|
||||
|
||||
#[TestDox('a method that carries no name does not require the header')]
|
||||
public function testNamelessMethodNeedsNoNameHeader(): void
|
||||
{
|
||||
|
|
|
|||
321
tests/Unit/Server/Stateless/StatelessProtocolTest.php
Normal file
321
tests/Unit/Server/Stateless/StatelessProtocolTest.php
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
<?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\Unit\Server\Stateless;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Schema\JsonRpc\Error;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\RequestContext;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StatelessProtocolTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $capabilities
|
||||
*/
|
||||
private static function protocol(array $capabilities = []): StatelessProtocol
|
||||
{
|
||||
return Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->addTool(static fn (): string => 'ok', name: 'plain_tool', description: 'Returns a fixed string')
|
||||
->addTool(
|
||||
static function (RequestContext $context): string {
|
||||
$gateway = $context->getClientGateway();
|
||||
|
||||
return implode(',', array_keys(array_filter([
|
||||
'roots' => $gateway->supportsRoots(),
|
||||
'sampling' => $gateway->supportsSampling(),
|
||||
'sampling.tools' => $gateway->supportsSamplingTools(),
|
||||
'elicitation' => $gateway->supportsElicitation(),
|
||||
'elicitation.url' => $gateway->supportsElicitationUrl(),
|
||||
]))) ?: 'none';
|
||||
},
|
||||
name: 'probe_capabilities',
|
||||
description: 'Reports which client capabilities the gateway can see',
|
||||
)
|
||||
->addResource(static fn (): string => 'body', 'test://static', 'static', 'A static resource')
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, string> $extraHeaders
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function call(StatelessProtocol $protocol, string $method, array $params = [], array $extraHeaders = [], array $capabilities = []): array
|
||||
{
|
||||
$params['_meta'] = [
|
||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => (object) $capabilities,
|
||||
];
|
||||
|
||||
return self::callWithHeaders($protocol, $method, $params, [
|
||||
'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value,
|
||||
'Mcp-Method' => $method,
|
||||
...$extraHeaders,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header-exact variant: nothing is filled in, so a test can leave a
|
||||
* required header out.
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, string> $headers
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function callWithHeaders(
|
||||
StatelessProtocol $protocol,
|
||||
string $method,
|
||||
array $params = [],
|
||||
array $headers = [],
|
||||
?string $metaVersion = null,
|
||||
): array {
|
||||
$params['_meta'] ??= [
|
||||
RequestMeta::PROTOCOL_VERSION => $metaVersion ?? ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
];
|
||||
|
||||
$result = $protocol->handle(
|
||||
json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR),
|
||||
$headers,
|
||||
);
|
||||
|
||||
return [
|
||||
'status' => $result->httpStatus,
|
||||
'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR),
|
||||
];
|
||||
}
|
||||
|
||||
#[TestDox('the gateway sees the capabilities this request declared, not an empty session')]
|
||||
public function testClientCapabilitiesReachTheGateway(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'probe_capabilities', 'arguments' => []],
|
||||
['Mcp-Name' => 'probe_capabilities'],
|
||||
['roots' => new \stdClass(), 'elicitation' => ['url' => new \stdClass()], 'sampling' => ['tools' => new \stdClass()]],
|
||||
);
|
||||
|
||||
$this->assertSame(200, $answer['status']);
|
||||
$this->assertSame(
|
||||
'roots,sampling,sampling.tools,elicitation,elicitation.url',
|
||||
$answer['body']['result']['content'][0]['text'],
|
||||
);
|
||||
}
|
||||
|
||||
#[TestDox('a client declaring nothing is reported as declaring nothing')]
|
||||
public function testEmptyCapabilitiesReportNone(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'probe_capabilities', 'arguments' => []],
|
||||
['Mcp-Name' => 'probe_capabilities'],
|
||||
);
|
||||
|
||||
$this->assertSame('none', $answer['body']['result']['content'][0]['text']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, array<string, mixed>}>
|
||||
*/
|
||||
public static function removedMethods(): iterable
|
||||
{
|
||||
yield 'initialize' => ['initialize', []];
|
||||
yield 'ping' => ['ping', []];
|
||||
yield 'logging/setLevel' => ['logging/setLevel', ['level' => 'info']];
|
||||
yield 'resources/subscribe' => ['resources/subscribe', ['uri' => 'test://static']];
|
||||
yield 'resources/unsubscribe' => ['resources/unsubscribe', ['uri' => 'test://static']];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
*/
|
||||
#[DataProvider('removedMethods')]
|
||||
#[TestDox('a method this revision removed is answered as unknown')]
|
||||
public function testRemovedMethodsAreUnknown(string $method, array $params): void
|
||||
{
|
||||
$answer = self::call(self::protocol(), $method, $params);
|
||||
|
||||
$this->assertSame(404, $answer['status']);
|
||||
$this->assertSame(Error::METHOD_NOT_FOUND, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('a missing resource answers -32602 with the uri, not the retired -32002')]
|
||||
public function testResourceNotFoundUsesInvalidParams(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'resources/read',
|
||||
['uri' => 'test://absent'],
|
||||
['Mcp-Name' => 'test://absent'],
|
||||
);
|
||||
|
||||
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
|
||||
$this->assertSame('test://absent', $answer['body']['error']['data']['uri']);
|
||||
}
|
||||
|
||||
#[TestDox('an unknown tool is a bad parameter, not an unknown method')]
|
||||
public function testUnknownToolUsesInvalidParams(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'no_such_tool', 'arguments' => []],
|
||||
['Mcp-Name' => 'no_such_tool'],
|
||||
);
|
||||
|
||||
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('an unknown prompt is a bad parameter too')]
|
||||
public function testUnknownPromptUsesInvalidParams(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'prompts/get',
|
||||
['name' => 'no_such_prompt', 'arguments' => []],
|
||||
['Mcp-Name' => 'no_such_prompt'],
|
||||
);
|
||||
|
||||
$this->assertSame(Error::INVALID_PARAMS, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('this revision never emits the codes it reserved')]
|
||||
public function testReservedCodesAreNeverEmitted(): void
|
||||
{
|
||||
$protocol = self::protocol();
|
||||
|
||||
$answers = [
|
||||
self::call($protocol, 'resources/read', ['uri' => 'test://absent'], ['Mcp-Name' => 'test://absent']),
|
||||
self::call($protocol, 'tools/call', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']),
|
||||
self::call($protocol, 'prompts/get', ['name' => 'nope', 'arguments' => []], ['Mcp-Name' => 'nope']),
|
||||
];
|
||||
|
||||
foreach ($answers as $answer) {
|
||||
// -32002 (resource not found) and -32042 (url elicitation required)
|
||||
// are reserved by earlier revisions and never reused.
|
||||
$this->assertNotContains($answer['body']['error']['code'], [-32002, -32042]);
|
||||
}
|
||||
}
|
||||
|
||||
#[TestDox('a notification is acknowledged with no body, never answered')]
|
||||
public function testNotificationIsAcknowledged(): void
|
||||
{
|
||||
$result = self::protocol()->handle(
|
||||
json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/something', 'params' => []], \JSON_THROW_ON_ERROR),
|
||||
['Mcp-Method' => 'notifications/something'],
|
||||
);
|
||||
|
||||
$this->assertTrue($result->isEmpty());
|
||||
$this->assertSame(202, $result->httpStatus);
|
||||
}
|
||||
|
||||
#[TestDox('a notification for a removed method is refused with no body')]
|
||||
public function testRemovedNotificationIsRefused(): void
|
||||
{
|
||||
$result = self::protocol()->handle(
|
||||
json_encode(['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], \JSON_THROW_ON_ERROR),
|
||||
['Mcp-Method' => 'notifications/initialized'],
|
||||
);
|
||||
|
||||
$this->assertTrue($result->isEmpty());
|
||||
$this->assertSame(400, $result->httpStatus);
|
||||
}
|
||||
|
||||
#[TestDox('an error for a request whose id could not be read omits the id')]
|
||||
public function testUnreadableIdIsOmittedRatherThanEmptied(): void
|
||||
{
|
||||
$result = self::protocol()->handle('{"jsonrpc":"2.0","id":{"bad":1},"method":"tools/list"}', []);
|
||||
|
||||
$body = json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR);
|
||||
|
||||
$this->assertArrayNotHasKey('id', $body);
|
||||
$this->assertSame(Error::INVALID_REQUEST, $body['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('an error for a readable id still echoes it')]
|
||||
public function testReadableIdIsEchoed(): void
|
||||
{
|
||||
$answer = self::callWithHeaders(self::protocol(), 'tools/list', [], ['Mcp-Method' => 'tools/list']);
|
||||
|
||||
$this->assertSame(1, $answer['body']['id']);
|
||||
}
|
||||
|
||||
#[TestDox('a POST without the MCP-Protocol-Version header is refused')]
|
||||
public function testMissingProtocolVersionHeaderIsRefused(): void
|
||||
{
|
||||
$answer = self::callWithHeaders(
|
||||
self::protocol(),
|
||||
'tools/list',
|
||||
[],
|
||||
['Mcp-Method' => 'tools/list'],
|
||||
);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']);
|
||||
$this->assertStringContainsString('MCP-Protocol-Version', $answer['body']['error']['message']);
|
||||
}
|
||||
|
||||
#[TestDox('a header contradicting the _meta version outranks an unsupported version')]
|
||||
public function testContradictingProtocolVersionHeaderIsRefused(): void
|
||||
{
|
||||
$answer = self::callWithHeaders(
|
||||
self::protocol(),
|
||||
'tools/list',
|
||||
[],
|
||||
['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '2025-11-25'],
|
||||
);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(Error::HEADER_MISMATCH, $answer['body']['error']['code']);
|
||||
}
|
||||
|
||||
#[TestDox('an unsupported version carries the supported set the client can retry from')]
|
||||
public function testUnsupportedProtocolVersionCarriesSupportedSet(): void
|
||||
{
|
||||
$answer = self::callWithHeaders(
|
||||
self::protocol(),
|
||||
'tools/list',
|
||||
[],
|
||||
['Mcp-Method' => 'tools/list', 'MCP-Protocol-Version' => '1900-01-01'],
|
||||
'1900-01-01',
|
||||
);
|
||||
|
||||
$this->assertSame(400, $answer['status']);
|
||||
$this->assertSame(Error::UNSUPPORTED_PROTOCOL_VERSION, $answer['body']['error']['code']);
|
||||
$this->assertSame('1900-01-01', $answer['body']['error']['data']['requested']);
|
||||
$this->assertSame([ProtocolVersion::V2026_07_28->value], $answer['body']['error']['data']['supported']);
|
||||
}
|
||||
|
||||
#[TestDox('elicitation without a named mode reports form, not url')]
|
||||
public function testElicitationDefaultsToFormMode(): void
|
||||
{
|
||||
$answer = self::call(
|
||||
self::protocol(),
|
||||
'tools/call',
|
||||
['name' => 'probe_capabilities', 'arguments' => []],
|
||||
['Mcp-Name' => 'probe_capabilities'],
|
||||
['elicitation' => new \stdClass()],
|
||||
);
|
||||
|
||||
$this->assertSame('elicitation', $answer['body']['result']['content'][0]['text']);
|
||||
}
|
||||
}
|
||||
110
tests/Unit/Server/Transport/Fixture/ShortReadStream.php
Normal file
110
tests/Unit/Server/Transport/Fixture/ShortReadStream.php
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
<?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\Unit\Server\Transport\Fixture;
|
||||
|
||||
use Psr\Http\Message\StreamInterface;
|
||||
|
||||
/**
|
||||
* A non-seekable stream that hands back at most $chunkSize bytes per read.
|
||||
*
|
||||
* Models what PSR-7 actually promises — *up to* the requested length — which a
|
||||
* stream over `php://input` or a chunked transfer delivers routinely, and which
|
||||
* a single `read($cap)` therefore truncates.
|
||||
*/
|
||||
final class ShortReadStream implements StreamInterface
|
||||
{
|
||||
private int $offset = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $contents,
|
||||
private readonly int $chunkSize = 64,
|
||||
private readonly bool $advertiseSize = true,
|
||||
) {
|
||||
}
|
||||
|
||||
public function read(int $length): string
|
||||
{
|
||||
$chunk = substr($this->contents, $this->offset, min($length, $this->chunkSize));
|
||||
$this->offset += \strlen($chunk);
|
||||
|
||||
return $chunk;
|
||||
}
|
||||
|
||||
public function eof(): bool
|
||||
{
|
||||
return $this->offset >= \strlen($this->contents);
|
||||
}
|
||||
|
||||
public function getSize(): ?int
|
||||
{
|
||||
return $this->advertiseSize ? \strlen($this->contents) : null;
|
||||
}
|
||||
|
||||
public function isSeekable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return $this->contents;
|
||||
}
|
||||
|
||||
public function getContents(): string
|
||||
{
|
||||
return substr($this->contents, $this->offset);
|
||||
}
|
||||
|
||||
public function close(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function detach()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function tell(): int
|
||||
{
|
||||
return $this->offset;
|
||||
}
|
||||
|
||||
public function seek(int $offset, int $whence = \SEEK_SET): void
|
||||
{
|
||||
throw new \RuntimeException('Not seekable.');
|
||||
}
|
||||
|
||||
public function rewind(): void
|
||||
{
|
||||
throw new \RuntimeException('Not seekable.');
|
||||
}
|
||||
|
||||
public function isWritable(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function write(string $string): int
|
||||
{
|
||||
throw new \RuntimeException('Not writable.');
|
||||
}
|
||||
|
||||
public function isReadable(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getMetadata(?string $key = null)
|
||||
{
|
||||
return null === $key ? [] : null;
|
||||
}
|
||||
}
|
||||
140
tests/Unit/Server/Transport/StatelessHttpTransportTest.php
Normal file
140
tests/Unit/Server/Transport/StatelessHttpTransportTest.php
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
<?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\Unit\Server\Transport;
|
||||
|
||||
use Mcp\Schema\Enum\ProtocolVersion;
|
||||
use Mcp\Server;
|
||||
use Mcp\Server\Stateless\RequestMeta;
|
||||
use Mcp\Server\Stateless\StatelessProtocol;
|
||||
use Mcp\Server\Transport\StatelessHttpTransport;
|
||||
use Mcp\Tests\Unit\Server\Transport\Fixture\ShortReadStream;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use PHPUnit\Framework\Attributes\TestDox;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class StatelessHttpTransportTest extends TestCase
|
||||
{
|
||||
private Psr17Factory $factory;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->factory = new Psr17Factory();
|
||||
}
|
||||
|
||||
private function protocol(): StatelessProtocol
|
||||
{
|
||||
return Server::builder()
|
||||
->setServerInfo('test-server', '1.0.0')
|
||||
->addTool(static fn (string $text = ''): string => 'echo:'.$text, name: 'echo_tool', description: 'Echoes its argument')
|
||||
->buildStateless([ProtocolVersion::V2026_07_28]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `tools/call` body padded so it comfortably exceeds one 8 KiB read.
|
||||
*/
|
||||
private function callBody(int $padding = 0): string
|
||||
{
|
||||
return json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'id' => 1,
|
||||
'method' => 'tools/call',
|
||||
'params' => [
|
||||
'name' => 'echo_tool',
|
||||
'arguments' => ['text' => str_repeat('x', $padding)],
|
||||
'_meta' => [
|
||||
RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value,
|
||||
RequestMeta::CLIENT_CAPABILITIES => new \stdClass(),
|
||||
],
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
}
|
||||
|
||||
private function post(string $body): ServerRequestInterface
|
||||
{
|
||||
return $this->factory
|
||||
->createServerRequest('POST', 'http://localhost/mcp')
|
||||
->withHeader('Host', 'localhost')
|
||||
->withHeader('MCP-Protocol-Version', ProtocolVersion::V2026_07_28->value)
|
||||
->withHeader('Mcp-Method', 'tools/call')
|
||||
->withHeader('Mcp-Name', 'echo_tool')
|
||||
->withBody($this->factory->createStream($body));
|
||||
}
|
||||
|
||||
#[TestDox('a body arriving in short reads is assembled whole, not truncated')]
|
||||
public function testShortReadsDoNotTruncateTheBody(): void
|
||||
{
|
||||
$body = $this->callBody(20_000);
|
||||
|
||||
// PSR-7 promises only *up to* the requested length; a stream over
|
||||
// php://input or a chunked transfer routinely returns less.
|
||||
$request = $this->post('')->withBody(new ShortReadStream($body, 64));
|
||||
|
||||
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request);
|
||||
|
||||
$this->assertSame(200, $response->getStatusCode());
|
||||
|
||||
$decoded = json_decode((string) $response->getBody(), true, flags: \JSON_THROW_ON_ERROR);
|
||||
$this->assertSame('echo:'.str_repeat('x', 20_000), $decoded['result']['content'][0]['text']);
|
||||
}
|
||||
|
||||
#[TestDox('a body over the cap is refused with 413')]
|
||||
public function testOversizedBodyIsRefused(): void
|
||||
{
|
||||
$request = $this->post($this->callBody(2048));
|
||||
|
||||
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request);
|
||||
|
||||
$this->assertSame(413, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[TestDox('an oversized body is refused even when its size is not advertised')]
|
||||
public function testOversizedUnsizedBodyIsRefused(): void
|
||||
{
|
||||
$request = $this->post('')->withBody(new ShortReadStream($this->callBody(2048), 64, advertiseSize: false));
|
||||
|
||||
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory, maxBodyBytes: 256))->handle($request);
|
||||
|
||||
$this->assertSame(413, $response->getStatusCode());
|
||||
}
|
||||
|
||||
#[TestDox('a notification POST is acknowledged with 202 and no body')]
|
||||
public function testNotificationPostIsAccepted(): void
|
||||
{
|
||||
$body = json_encode([
|
||||
'jsonrpc' => '2.0',
|
||||
'method' => 'notifications/something',
|
||||
'params' => [],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
|
||||
$request = $this->post($body)->withHeader('Mcp-Method', 'notifications/something');
|
||||
|
||||
$response = (new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory))->handle($request);
|
||||
|
||||
$this->assertSame(202, $response->getStatusCode());
|
||||
$this->assertSame('', (string) $response->getBody());
|
||||
}
|
||||
|
||||
#[TestDox('GET and DELETE are refused: there is no session to address')]
|
||||
public function testOnlyPostIsAccepted(): void
|
||||
{
|
||||
$transport = new StatelessHttpTransport($this->protocol(), $this->factory, $this->factory);
|
||||
|
||||
foreach (['GET', 'DELETE', 'PUT'] as $method) {
|
||||
$request = $this->factory
|
||||
->createServerRequest($method, 'http://localhost/mcp')
|
||||
->withHeader('Host', 'localhost');
|
||||
|
||||
$this->assertSame(405, $transport->handle($request)->getStatusCode(), $method);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue