1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server\Transport\Http\Middleware;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Http\HttpStatus;
18: use Nexus\Mcp\Core\Schema\Error\InvalidRequestError;
19: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcErrorResponse;
20: use Psr\Http\Message\ResponseFactoryInterface;
21: use Psr\Http\Message\ResponseInterface;
22: use Psr\Http\Message\ServerRequestInterface;
23: use Psr\Http\Message\StreamFactoryInterface;
24: use Psr\Http\Message\StreamInterface;
25: use Psr\Http\Server\MiddlewareInterface;
26: use Psr\Http\Server\RequestHandlerInterface;
27:
28: /**
29: * Rejects a request whose body exceeds a configured byte cap with an id-less JSON-RPC error on
30: * HTTP 413, reading a body of indeterminate size only up to one byte past the cap.
31: */
32: final readonly class RequestBodySizeLimitMiddleware implements MiddlewareInterface
33: {
34: /**
35: * @param int<0, max> $maxBytes Maximum permitted request body size in bytes
36: */
37: public function __construct(
38: private int $maxBytes,
39: private ResponseFactoryInterface $responseFactory,
40: private StreamFactoryInterface $streamFactory,
41: ) {
42: Assert::that($maxBytes)->isNaturalInt('The maximum request body size must be a non-negative integer, {value} given.');
43: }
44:
45: #[\Override]
46: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
47: {
48: $body = $request->getBody();
49: $size = $body->getSize();
50:
51: if (null !== $size) {
52: return $size > $this->maxBytes ? $this->reject() : $handler->handle($request);
53: }
54:
55: $buffered = $this->readPastCap($body, $this->maxBytes);
56:
57: if ($this->maxBytes < \strlen($buffered)) {
58: return $this->reject();
59: }
60:
61: return $handler->handle($request->withBody($this->streamFactory->createStream($buffered)));
62: }
63:
64: /**
65: * Reads at most one byte past `$maxBytes`, so an oversized stream is detected without buffering it.
66: *
67: * @param int<0, max> $maxBytes
68: */
69: private function readPastCap(StreamInterface $body, int $maxBytes): string
70: {
71: $limit = $maxBytes + 1;
72: $buffered = '';
73:
74: while ($limit > \strlen($buffered)) {
75: $chunk = $body->read($limit - \strlen($buffered));
76:
77: if ('' === $chunk) {
78: break;
79: }
80:
81: $buffered .= $chunk;
82: }
83:
84: return $buffered;
85: }
86:
87: private function reject(): ResponseInterface
88: {
89: $envelope = new JsonRpcErrorResponse(
90: id: null,
91: error: new InvalidRequestError(message: 'The request body exceeds the permitted size.'),
92: );
93:
94: return $this->responseFactory->createResponse(HttpStatus::ContentTooLarge->value)
95: ->withHeader('Content-Type', 'application/json')
96: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
97: ;
98: }
99: }
100: