[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
11 changed files with 855 additions and 106 deletions

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