Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
-----
Expand Down
23 changes: 21 additions & 2 deletions docs/run/http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<MiddlewareInterface>|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<MiddlewareInterface>|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
Expand Down Expand Up @@ -250,11 +250,30 @@ $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(
$request,
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.
33 changes: 33 additions & 0 deletions src/Server/Transport/Http/Middleware/PassthroughMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?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\Http\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
* Hands every request to the next handler unchanged.
*
* Pass `[new PassthroughMiddleware()]` as the transport's middleware when the
* host application already handles CORS and host validation. It replaces
* {@see \Mcp\Server\Transport\StreamableHttpTransport::defaultMiddleware()}
* without the warning an empty list logs.
*/
final class PassthroughMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
return $handler->handle($request);
}
}
9 changes: 7 additions & 2 deletions src/Server/Transport/StreamableHttpTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,12 @@ class StreamableHttpTransport extends BaseTransport implements StatelessAwareTra
private ?array $middleware;

/**
* @param iterable<MiddlewareInterface>|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<MiddlewareInterface>|null $middleware `null` installs {@see self::defaultMiddleware()}; `[]` disables them and logs a warning
*/
public function __construct(
private readonly ServerRequestInterface $request,
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/Unit/Server/Transport/StreamableHttpTransportTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
{
Expand Down
Loading