diff --git a/CHANGELOG.md b/CHANGELOG.md index 03f80ef9..02077c6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix `RequestEvent`, `ResponseEvent` and `ErrorEvent` not being dispatched for `2026-07-28` requests. * [BC Break] Validate a tool result's `structuredContent` against the tool's `outputSchema`, which the specification requires the server to honour. A mismatch is answered with a `CallToolResult` carrying `isError: true` instead of the non-conforming value, matching the TypeScript, Python and Java SDKs. Skipped when the tool declares no `outputSchema`, when the result carries no `structuredContent`, and when the result is already an error. * Stop the server `Protocol` from logging full JSON-RPC payloads (tool arguments, client replies) at info level: info records now carry only the method and id, the raw message is logged at debug level. +* Add `PassthroughMiddleware` to opt `StreamableHttpTransport` out of its default middleware without the warning an empty `$middleware` list logs. 0.8.0 ----- diff --git a/docs/run/http.md b/docs/run/http.md index a57a8dbe..aa98e10a 100644 --- a/docs/run/http.md +++ b/docs/run/http.md @@ -21,7 +21,7 @@ $transport = new StreamableHttpTransport( - **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided. - **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided. - **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`. -- **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc. +- **`middleware`** (optional): `iterable|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). A list replaces the defaults. When the surrounding application already handles CORS, host validation, etc., see [Opting Out of All Middleware](#opting-out-of-all-middleware). - **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit). ## PSR-17 Auto-Discovery @@ -250,7 +250,7 @@ $transport = new StreamableHttpTransport( ); ``` -Pass `middleware: []` to disable every default and run only your own chain: +Leave the defaults out of the list to run only your own chain: ```php $transport = new StreamableHttpTransport( @@ -258,3 +258,22 @@ $transport = new StreamableHttpTransport( middleware: [new AuthMiddleware($responseFactory)], ); ``` + +### Opting Out of All Middleware + +When the surrounding application already handles CORS and host validation, pass `PassthroughMiddleware`. It hands +every request to the next handler unchanged: + +```php +use Mcp\Server\Transport\Http\Middleware\PassthroughMiddleware; +use Mcp\Server\Transport\StreamableHttpTransport; + +$transport = new StreamableHttpTransport( + $request, + middleware: [new PassthroughMiddleware()], +); +``` + +Do not pass `middleware: []`. The transport treats an empty list as a likely mistake and logs a warning on every +request. The pass-through makes the opt-out explicit. The transport still applies +`StreamableHttpTransport::handshakeMiddleware()` to handshake-era requests. diff --git a/src/Server/Transport/Http/Middleware/PassthroughMiddleware.php b/src/Server/Transport/Http/Middleware/PassthroughMiddleware.php new file mode 100644 index 00000000..00234e94 --- /dev/null +++ b/src/Server/Transport/Http/Middleware/PassthroughMiddleware.php @@ -0,0 +1,33 @@ +handle($request); + } +} diff --git a/src/Server/Transport/StreamableHttpTransport.php b/src/Server/Transport/StreamableHttpTransport.php index c01503eb..42cb3b19 100644 --- a/src/Server/Transport/StreamableHttpTransport.php +++ b/src/Server/Transport/StreamableHttpTransport.php @@ -78,7 +78,12 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra private ?array $middleware; /** - * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables all middleware + * A non-null `$middleware` list replaces {@see self::defaultMiddleware()}. + * An empty list is treated as a likely mistake and logs a warning on every + * request. When the host application already handles CORS and host + * validation, opt out explicitly with `[new PassthroughMiddleware()]`. + * + * @param iterable|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables them and logs a warning */ public function __construct( private readonly ServerRequestInterface $request, @@ -108,7 +113,7 @@ public function __construct( } else { $this->middleware = self::normalizeMiddleware($middleware); if ([] === $this->middleware) { - $this->logger->warning('Streamable HTTP transport started with an empty middleware list. Default security protections (CORS, DNS rebinding, protocol version validation) are disabled. Pass null (or omit the argument) to use the secure defaults, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware].'); + $this->logger->warning('Streamable HTTP transport started with an empty middleware list, so the default CORS and DNS rebinding protections are disabled. Pass null (or omit the argument) to use them, or include them via [...StreamableHttpTransport::defaultMiddleware(), $yourMiddleware]. If the host application already provides them, pass [new PassthroughMiddleware()] to opt out without this warning.'); } // Custom middleware runs before the request's era is classified, so a diff --git a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php index 186432fc..245dde77 100644 --- a/tests/Unit/Server/Transport/StreamableHttpTransportTest.php +++ b/tests/Unit/Server/Transport/StreamableHttpTransportTest.php @@ -15,6 +15,7 @@ use Mcp\Schema\JsonRpc\Error; use Mcp\Server\Transport\Http\Middleware\CorsMiddleware; use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware; +use Mcp\Server\Transport\Http\Middleware\PassthroughMiddleware; use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware; use Mcp\Server\Transport\StreamableHttpTransport; use Mcp\Server\Transport\TransportInterface; @@ -122,6 +123,31 @@ public function testDuplicateSessionIdHeadersReturnBadRequest(): void $this->assertStringContainsString('must not be repeated', (string) $response->getBody()); } + #[TestDox('pass-through middleware disables defaults without a warning log')] + public function testPassthroughMiddlewareDisablesDefaultsWithoutWarning(): 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->never())->method('warning'); + + $transport = new StreamableHttpTransport( + $request, + $this->factory, + $this->factory, + $logger, + [new PassthroughMiddleware()], + ); + + $response = $transport->listen(); + + $this->assertNotSame(403, $response->getStatusCode()); + $this->assertFalse($response->hasHeader('Access-Control-Allow-Origin')); + } + #[TestDox('explicit empty middleware list disables defaults and emits a warning log')] public function testEmptyMiddlewareListDisablesDefaultsAndWarns(): void {