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