diff --git a/src/Server/Builder.php b/src/Server/Builder.php index c53fa620..7ce78179 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -223,6 +223,8 @@ final class Builder private bool $lazyLoading = true; + private bool $stateless = false; + /** * Sets the server's identity. Required. * @@ -725,7 +727,6 @@ public function build(): Server new Handler\Request\CallToolHandler($registry, $referenceHandler, $logger), new Handler\Request\CompletionCompleteHandler($registry, $container), new Handler\Request\GetPromptHandler($registry, $referenceHandler, $logger), - new Handler\Request\InitializeHandler($configuration), new Handler\Request\ListPromptsHandler($registry, $this->paginationLimit), new Handler\Request\ListResourcesHandler($registry, $this->paginationLimit), new Handler\Request\ListResourceTemplatesHandler($registry, $this->paginationLimit), @@ -737,9 +738,14 @@ public function build(): Server new Handler\Request\SetLogLevelHandler(), ]); - $notificationHandlers = array_merge($this->notificationHandlers, [ - new Handler\Notification\InitializedHandler(), - ]); + $notificationHandlers = array_merge($this->notificationHandlers, []); + + if ($this->stateless) { + $requestHandlers[] = new Handler\Request\ServerDiscoverHandler($configuration); + } else { + array_unshift($requestHandlers, new Handler\Request\InitializeHandler($configuration)); + $notificationHandlers[] = new Handler\Notification\InitializedHandler(); + } $protocol = new Protocol( requestHandlers: $requestHandlers, @@ -748,6 +754,7 @@ public function build(): Server sessionManager: $sessionManager, logger: $logger, eventDispatcher: $this->eventDispatcher, + stateless: $this->stateless, ); return new Server($protocol, $logger); @@ -806,4 +813,11 @@ private function createDiscoverer(LoggerInterface $logger): DiscovererInterface return $discoverer; } + + public function setStateless(bool $stateless = true): self + { + $this->stateless = $stateless; + + return $this; + } } diff --git a/src/Server/Handler/Request/ServerDiscoverHandler.php b/src/Server/Handler/Request/ServerDiscoverHandler.php new file mode 100644 index 00000000..380256c5 --- /dev/null +++ b/src/Server/Handler/Request/ServerDiscoverHandler.php @@ -0,0 +1,58 @@ + + */ + public function handle(Request $request, SessionInterface $session): Response + { + return new Response( + $request->getId(), + new InitializeResult( + $this->configuration->capabilities ?? new ServerCapabilities(), + $this->configuration->serverInfo ?? new Implementation(), + $this->configuration?->instructions, + null, + $this->configuration?->protocolVersion, + ), + ); + } +} diff --git a/src/Server/Protocol.php b/src/Server/Protocol.php index d9af4e4c..820a766b 100644 --- a/src/Server/Protocol.php +++ b/src/Server/Protocol.php @@ -72,6 +72,7 @@ public function __construct( private readonly SessionManagerInterface $sessionManager, private readonly LoggerInterface $logger = new NullLogger(), private readonly ?EventDispatcherInterface $eventDispatcher = null, + private readonly bool $stateless = false, ) { } @@ -122,9 +123,17 @@ public function processInput(TransportInterface $transport, string $input, ?Uuid return; } - $session = $this->resolveSession($transport, $sessionId, $messages); - if (null === $session) { - return; + if ($this->stateless) { + $session = $this->sessionManager->create(); + $this->logger->debug('Created new ephemeral session for stateless request', [ + 'session_id' => $session->getId()->toRfc4122(), + ]); + $transport->setSessionId($session->getId()); + } else { + $session = $this->resolveSession($transport, $sessionId, $messages); + if (null === $session) { + return; + } } foreach ($messages as $message) { diff --git a/src/Server/Transport/StatelessStreamableHttpTransport.php b/src/Server/Transport/StatelessStreamableHttpTransport.php new file mode 100644 index 00000000..bde00df4 --- /dev/null +++ b/src/Server/Transport/StatelessStreamableHttpTransport.php @@ -0,0 +1,322 @@ + */ + private array $middleware; + + /** + * @param iterable|null $middleware `null` installs default middleware; `[]` disables all middleware + */ + public function __construct( + private ServerRequestInterface $request, + ?ResponseFactoryInterface $responseFactory = null, + ?StreamFactoryInterface $streamFactory = null, + ?LoggerInterface $logger = null, + ?iterable $middleware = null, + private readonly int $maxBodyBytes = self::DEFAULT_MAX_BODY_BYTES, + ) { + parent::__construct($logger); + + if ($this->maxBodyBytes < 1) { + throw new InvalidArgumentException('maxBodyBytes must be at least 1.'); + } + + $this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory(); + $this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory(); + + if (null === $middleware) { + $this->middleware = self::defaultMiddleware(); + } else { + $this->middleware = self::normalizeMiddleware($middleware); + if ([] === $this->middleware) { + $this->logger->warning('Stateless HTTP transport started with an empty middleware list. Default security protections are disabled.'); + } + } + } + + /** + * @return list + */ + public static function defaultMiddleware(): array + { + return [ + new CorsMiddleware(), + new DnsRebindingProtectionMiddleware(), + new ProtocolVersionMiddleware(), + ]; + } + + public function send(string $data, array $context): void + { + $this->immediateResponse = $data; + $this->immediateStatusCode = $context['status_code'] ?? 200; + } + + public function listen(): ResponseInterface + { + $handler = new MiddlewareRequestHandler( + $this->middleware, + \Closure::fromCallable([$this, 'handleRequest']), + ); + + return $handler->handle($this->request); + } + + protected function handleRequest(ServerRequestInterface $request): ResponseInterface + { + $this->request = $request; + + return match ($request->getMethod()) { + 'OPTIONS' => $this->handleOptionsRequest(), + 'POST' => $this->handlePostRequest(), + default => $this->createErrorResponse( + Error::forInvalidRequest('Method Not Allowed'), + 405 + ), + }; + } + + protected function handleOptionsRequest(): ResponseInterface + { + return $this->responseFactory->createResponse(204); + } + + protected function handlePostRequest(): ResponseInterface + { + $body = $this->readBody($this->request->getBody()); + if (null === $body) { + return $this->createErrorResponse( + Error::forInvalidRequest(\sprintf('Request body exceeds the maximum allowed size of %d bytes.', $this->maxBodyBytes)), + 413 + ); + } + + // Stateless: no session ID, each request is independent + $this->handleMessage($body, null); + + if (null !== $this->immediateResponse) { + return $this->responseFactory + ->createResponse($this->immediateStatusCode ?? 200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($this->immediateResponse)); + } + + if (null !== $this->sessionFiber) { + return $this->createStreamedResponse(); + } + + return $this->createJsonResponse(); + } + + protected function createJsonResponse(): ResponseInterface + { + $outgoingMessages = $this->getOutgoingMessages($this->sessionId); + + if (empty($outgoingMessages)) { + return $this->responseFactory + ->createResponse(202) + ->withHeader('Content-Type', 'application/json'); + } + + $messages = array_column($outgoingMessages, 'message'); + $responseBody = 1 === \count($messages) ? $messages[0] : '['.implode(',', $messages).']'; + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($responseBody)); + } + + protected function createStreamedResponse(): ResponseInterface + { + $callback = function (): void { + try { + $this->logger->info('SSE: Starting stateless request processing loop'); + + while ($this->sessionFiber->isSuspended()) { + $this->flushOutgoingMessages($this->sessionId); + + $pendingRequests = $this->getPendingRequests($this->sessionId); + + if (empty($pendingRequests)) { + $yielded = $this->sessionFiber->resume(); + $this->handleFiberYield($yielded, $this->sessionId); + continue; + } + + $resumed = false; + foreach ($pendingRequests as $pending) { + $requestId = $pending['request_id']; + $timestamp = $pending['timestamp']; + $timeout = $pending['timeout'] ?? 120; + + $response = $this->checkForResponse($requestId, $this->sessionId); + + if (null !== $response) { + $yielded = $this->sessionFiber->resume($response); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + + if (time() - $timestamp >= $timeout) { + $error = Error::forInternalError('Request timed out', $requestId); + $yielded = $this->sessionFiber->resume($error); + $this->handleFiberYield($yielded, $this->sessionId); + $resumed = true; + break; + } + } + + if (!$resumed) { + usleep(100000); + } + } + + $this->handleFiberTermination(); + } finally { + $this->sessionFiber = null; + } + }; + + $stream = new CallbackStream($callback, $this->logger); + + return $this->responseFactory + ->createResponse(200) + ->withHeader('Content-Type', 'text/event-stream') + ->withHeader('Cache-Control', 'no-cache') + ->withHeader('Connection', 'keep-alive') + ->withHeader('X-Accel-Buffering', 'no') + ->withBody($stream); + } + + protected function handleFiberTermination(): void + { + $finalResult = $this->sessionFiber?->getReturn(); + + if (null !== $finalResult) { + try { + $encoded = json_encode($finalResult, \JSON_THROW_ON_ERROR); + echo "event: message\n"; + echo "data: {$encoded}\n\n"; + @ob_flush(); + flush(); + } catch (\JsonException $e) { + $this->logger->error('SSE: Failed to encode final Fiber result.', ['exception' => $e]); + } + } + + $this->sessionFiber = null; + } + + protected function flushOutgoingMessages(?\Symfony\Component\Uid\Uuid $sessionId): void + { + $messages = $this->getOutgoingMessages($sessionId); + + foreach ($messages as $message) { + echo "event: message\n"; + echo "data: {$message['message']}\n\n"; + @ob_flush(); + flush(); + } + } + + protected function createErrorResponse(Error $jsonRpcError, int $statusCode): ResponseInterface + { + $payload = json_encode($jsonRpcError, \JSON_THROW_ON_ERROR); + + $response = $this->responseFactory + ->createResponse($statusCode) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($payload)); + + if (405 === $statusCode) { + $response = $response->withHeader('Allow', 'POST, OPTIONS'); + } + + return $response; + } + + private function readBody(\Psr\Http\Message\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; + } + + /** + * @param iterable $middleware + * + * @return list + */ + private static function normalizeMiddleware(iterable $middleware): array + { + $normalized = []; + foreach ($middleware as $m) { + if (!$m instanceof MiddlewareInterface) { + throw new InvalidArgumentException('Streamable HTTP middleware must implement Psr\Http\Server\MiddlewareInterface.'); + } + $normalized[] = $m; + } + + return $normalized; + } +} diff --git a/tests/Unit/Server/BuilderTest.php b/tests/Unit/Server/BuilderTest.php index 255a142e..60efcde9 100644 --- a/tests/Unit/Server/BuilderTest.php +++ b/tests/Unit/Server/BuilderTest.php @@ -206,6 +206,18 @@ public function testEagerLoadingAdvertisesFromLoadedRegistry(): void $this->assertFalse($capabilities->tools); } + public function testSetStatelessEnablesStatelessMode(): void + { + $server = Server::builder() + ->setServerInfo('test', '1.0.0') + ->setStateless(true) + ->build(); + + // Reflection check that Protocol has stateless=true + $protocol = (new \ReflectionProperty($server, 'protocol'))->getValue($server); + $this->assertTrue((new \ReflectionProperty($protocol, 'stateless'))->getValue($protocol)); + } + private function extractServerCapabilities(Server $server): ServerCapabilities { $protocol = (new \ReflectionClass($server))->getProperty('protocol')->getValue($server); diff --git a/tests/Unit/Server/Handler/Request/ServerDiscoverHandlerTest.php b/tests/Unit/Server/Handler/Request/ServerDiscoverHandlerTest.php new file mode 100644 index 00000000..206e17cc --- /dev/null +++ b/tests/Unit/Server/Handler/Request/ServerDiscoverHandlerTest.php @@ -0,0 +1,172 @@ + MessageInterface::JSONRPC_VERSION, + 'id' => 1, + 'method' => 'server/discover', + 'params' => [], + ]); + + $this->assertTrue($handler->supports($request)); + } + + #[TestDox('does not support other methods')] + public function testDoesNotSupportOtherMethods(): void + { + $handler = new ServerDiscoverHandler(); + + $request = TestOtherRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 1, + 'method' => 'initialize', + 'params' => [], + ]); + + $this->assertFalse($handler->supports($request)); + } + + #[TestDox('handle returns InitializeResult with configuration data')] + public function testHandleReturnsInitializeResult(): void + { + $serverInfo = new Implementation('test-server', '1.0.0', 'Test description'); + $capabilities = new ServerCapabilities(tools: true, resources: true); + $configuration = new Configuration( + serverInfo: $serverInfo, + capabilities: $capabilities, + paginationLimit: 25, + instructions: 'Be helpful', + protocolVersion: ProtocolVersion::V2025_11_25, + ); + + $handler = new ServerDiscoverHandler($configuration); + + $request = TestServerDiscoverRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 42, + 'method' => 'server/discover', + 'params' => [], + ]); + + $session = $this->createMock(SessionInterface::class); + + $response = $handler->handle($request, $session); + + $this->assertSame(42, $response->getId()); + $this->assertInstanceOf(InitializeResult::class, $response->result); + + /** @var InitializeResult $result */ + $result = $response->result; + + $this->assertSame('test-server', $result->serverInfo->name); + $this->assertSame('1.0.0', $result->serverInfo->version); + $this->assertSame('Test description', $result->serverInfo->description); + $this->assertTrue($result->capabilities->tools); + $this->assertTrue($result->capabilities->resources); + $this->assertSame('Be helpful', $result->instructions); + $this->assertSame(ProtocolVersion::V2025_11_25, $result->protocolVersion); + } + + #[TestDox('handle falls back to defaults when configuration is minimal')] + public function testHandleFallsBackToDefaults(): void + { + $configuration = new Configuration( + serverInfo: new Implementation('test', '1.0.0'), + capabilities: new ServerCapabilities(), + ); + + $handler = new ServerDiscoverHandler($configuration); + + $request = TestServerDiscoverRequest::fromArray([ + 'jsonrpc' => MessageInterface::JSONRPC_VERSION, + 'id' => 1, + 'method' => 'server/discover', + 'params' => [], + ]); + + $session = $this->createMock(SessionInterface::class); + + $response = $handler->handle($request, $session); + + $this->assertSame(1, $response->getId()); + $this->assertInstanceOf(InitializeResult::class, $response->result); + + /** @var InitializeResult $result */ + $result = $response->result; + + $this->assertSame('test', $result->serverInfo->name); + $this->assertSame('1.0.0', $result->serverInfo->version); + $this->assertNull($result->instructions); + } +} diff --git a/tests/Unit/Server/Transport/StatelessStreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StatelessStreamableHttpTransportTest.php new file mode 100644 index 00000000..35aafc7d --- /dev/null +++ b/tests/Unit/Server/Transport/StatelessStreamableHttpTransportTest.php @@ -0,0 +1,351 @@ +factory = new Psr17Factory(); + } + + #[TestDox('default middleware is applied when none is passed')] + public function testDefaultMiddlewareIsAppliedWhenOmitted(): void + { + $request = $this->factory + ->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Access-Control-Request-Method', 'POST'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertSame('GET, POST, DELETE', $response->getHeaderLine('Access-Control-Allow-Methods')); + $this->assertNotSame('', $response->getHeaderLine('Access-Control-Allow-Headers')); + $this->assertNotSame('', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('POST without session ID is accepted')] + public function testPostWithoutSessionIdIsAccepted(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withBody($this->factory->createStream('{"jsonrpc":"2.0","method":"server/discover","id":1}')); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertNotSame(400, $response->getStatusCode()); + $this->assertNotSame(404, $response->getStatusCode()); + } + + #[TestDox('DELETE method returns 405')] + public function testDeleteMethodReturns405(): void + { + $request = $this->factory + ->createServerRequest('DELETE', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame('POST, OPTIONS', $response->getHeaderLine('Allow')); + } + + #[TestDox('GET method returns 405')] + public function testGetMethodReturns405(): void + { + $request = $this->factory + ->createServerRequest('GET', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(405, $response->getStatusCode()); + $this->assertSame('POST, OPTIONS', $response->getHeaderLine('Allow')); + } + + #[TestDox('default middleware blocks non-localhost Origin')] + public function testDefaultMiddlewareBlocksRebindingAttempt(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Origin', 'http://evil.example.com'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(403, $response->getStatusCode()); + } + + #[TestDox('default middleware rejects unsupported MCP-Protocol-Version')] + public function testDefaultMiddlewareRejectsUnsupportedProtocolVersion(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader(StatelessStreamableHttpTransport::PROTOCOL_VERSION_HEADER, '1900-01-01'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory); + + $response = $transport->listen(); + + $this->assertSame(400, $response->getStatusCode()); + } + + #[TestDox('explicit empty middleware list disables defaults and emits a warning log')] + public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'evil.example.com') + ->withHeader('Origin', 'http://evil.example.com'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once()) + ->method('warning') + ->with($this->stringContains('empty middleware list')); + + $transport = new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + $logger, + [], + ); + + $response = $transport->listen(); + + $this->assertNotSame(403, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Methods')); + } + + #[TestDox('null middleware does not trigger the empty-list warning')] + public function testNullMiddlewareDoesNotWarn(): void + { + $request = $this->factory + ->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('warning'); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory, $logger); + $transport->listen(); + } + + #[TestDox('custom middleware composes with default stack via spread')] + public function testDefaultsCanBeSpreadAndExtended(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $transport = new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [ + ...StatelessStreamableHttpTransport::defaultMiddleware(), + $this->stubAuth401(), + ], + ); + + $response = $transport->listen(); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('defaults can be filtered to drop DNS rebinding for proxy deployments')] + public function testDefaultsCanBeFilteredToDropDnsRebinding(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://api.myapp.com/') + ->withHeader('Host', 'api.myapp.com') + ->withHeader('Origin', 'https://myapp.com'); + + $transport = new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [ + ...array_filter( + StatelessStreamableHttpTransport::defaultMiddleware(), + static fn (MiddlewareInterface $m): bool => !$m instanceof DnsRebindingProtectionMiddleware, + ), + $this->stubAuth401(), + ], + ); + + $response = $transport->listen(); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame('Mcp-Session-Id', $response->getHeaderLine('Access-Control-Expose-Headers')); + } + + #[TestDox('configured CorsMiddleware reflects matching Origin')] + public function testConfiguredCorsReflectsMatchingOrigin(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withHeader('Host', 'localhost') + ->withHeader('Origin', 'https://myapp.example.com'); + + $transport = new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [ + new CorsMiddleware(allowedOrigins: ['https://myapp.example.com']), + new DnsRebindingProtectionMiddleware(allowedHosts: ['localhost']), + new ProtocolVersionMiddleware(), + ], + ); + + $response = $transport->listen(); + + $this->assertSame('https://myapp.example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); + } + + #[TestDox('middleware runs before transport handles the request')] + public function testMiddlewareRunsBeforeTransportHandlesRequest(): void + { + $request = $this->factory->createServerRequest('OPTIONS', 'http://localhost/') + ->withHeader('Host', 'localhost'); + + $state = new \stdClass(); + $state->called = false; + $spy = new class($state) implements MiddlewareInterface { + public function __construct(private \stdClass $state) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + $this->state->called = true; + + return $handler->handle($request); + } + }; + + $transport = new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [$spy], + ); + + $response = $transport->listen(); + + $this->assertTrue($state->called); + $this->assertSame(204, $response->getStatusCode()); + } + + #[TestDox('non-middleware entries are rejected')] + public function testInvalidMiddlewareEntryThrows(): void + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $this->expectException(InvalidArgumentException::class); + + new StatelessStreamableHttpTransport( + $request, + $this->factory, + $this->factory, + null, + [new \stdClass()], // @phpstan-ignore-line argument.type + ); + } + + public function testPostBodyExceedingMaxBytesReturns413(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream(str_repeat('a', 64))); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 16); + + $response = $transport->listen(); + + $this->assertSame(413, $response->getStatusCode()); + } + + public function testPostBodyWithinMaxBytesIsNotRejected(): void + { + $request = $this->factory + ->createServerRequest('POST', 'http://localhost/') + ->withBody($this->factory->createStream('{}')); + + $transport = new StatelessStreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 1024); + + $response = $transport->listen(); + + $this->assertNotSame(413, $response->getStatusCode()); + } + + public function testNonPositiveMaxBodyBytesThrows(): void + { + $request = $this->factory->createServerRequest('POST', 'http://localhost/'); + + $this->expectException(InvalidArgumentException::class); + + new StatelessStreamableHttpTransport($request, $this->factory, $this->factory, null, [], maxBodyBytes: 0); + } + + private function stubAuth401(): MiddlewareInterface + { + return new class($this->factory) implements MiddlewareInterface { + public function __construct(private ResponseFactoryInterface $factory) + { + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + return $this->factory->createResponse(401); + } + }; + } +}