[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:
Christopher Hertel 2026-08-18 23:09:34 +02:00 • committed by GitHub
commit 5e73683079
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 855 additions and 106 deletions

View file

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

View file

@ -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.
*/

View file

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

View file

@ -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());
}

View file

@ -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]);

View file

@ -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]);

View file

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

View file

@ -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) {

View file

@ -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.');
}

View 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;
}
}

View file

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

View file

@ -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);
}
/**

View file

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

View file

@ -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());
}
}

View file

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

View file

@ -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);
}

View file

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

View 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']);
}
}

View 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;
}
}

View 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);
}
}
}