Skip to content
Open
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
-----

* Allow overriding the default name pattern for Discovery
* Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()`
* **[BC BREAK]** `StreamableHttpTransport` constructor: `$corsHeaders` parameter removed; CORS is now configured via `CorsMiddleware`. The `$middleware` parameter is nullable — `null` (or omitted) installs the default stack; `[]` disables all defaults. Default `Access-Control-Allow-Origin` is no longer set (was `*`).

0.5.0
-----
Expand Down
152 changes: 115 additions & 37 deletions docs/transports.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,8 @@ $transport = new StreamableHttpTransport(
- **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request
- **`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.
- **`corsHeaders`** (optional): `array` - Custom CORS headers to override defaults. Merges with secure defaults. Defaults to `[]`.
- **`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.

### PSR-17 Auto-Discovery

Expand All @@ -137,56 +137,109 @@ $psr17Factory = new Psr17Factory();
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
```

### CORS Configuration
### Default Middleware

When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack:

The transport sets secure CORS defaults that can be customized or disabled:
| Order | Middleware | Purpose |
|-------|------------|---------|
| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). |
| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. |
| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. |

```php
// Default CORS headers (backward compatible)
$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory);
// Zero-config, secure-by-default — local servers get full protection automatically.
$transport = new StreamableHttpTransport($request);
```

// Restrict to specific origin
$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
['Access-Control-Allow-Origin' => 'https://myapp.com']
);
The default stack can be inspected and recomposed via the public factory:

```php
$middleware = StreamableHttpTransport::defaultMiddleware();
```

### CORS Configuration

CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it
in place of (or alongside) the defaults:

// Disable CORS for proxy scenarios
```php
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
use Mcp\Server\Transport\StreamableHttpTransport;

// Reflect a specific origin
$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
['Access-Control-Allow-Origin' => '']
middleware: [
new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
new DnsRebindingProtectionMiddleware(),
new ProtocolVersionMiddleware(),
],
);

// Custom headers with logger
// Allow all origins (development only)
$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
[
'Access-Control-Allow-Origin' => 'https://api.example.com',
'Access-Control-Max-Age' => '86400'
middleware: [
new CorsMiddleware(allowedOrigins: ['*']),
new DnsRebindingProtectionMiddleware(),
new ProtocolVersionMiddleware(),
],
$logger
);
```

Default CORS headers:
- `Access-Control-Allow-Origin: *`
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept`
When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin`
so shared caches/CDNs do not serve a response generated for one origin to a request from another.

Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds
defaults when they are absent.

> [!IMPORTANT]
> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying
> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves
> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`.
> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials.

### PSR-15 Middleware
### DNS Rebinding Protection

`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log,
enforce auth, or short-circuit with a response for any HTTP method.
`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host`
when `Origin` is absent). The default allowlist is localhost-only:

```php
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;

new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']);
```

If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain
or supply a permissive allowlist.

### Protocol Version Validation

`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported
set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some
legacy clients do not send it.

```php
use Mcp\Schema\Enum\ProtocolVersion;
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;

// Only accept the latest spec version
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
```

### Custom PSR-15 Middleware

`StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append
your own middleware — the defaults stay outermost so CORS headers are applied to every response, including
short-circuited ones:

```php
use Mcp\Server\Transport\StreamableHttpTransport;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
Expand All @@ -197,7 +250,7 @@ final class AuthMiddleware implements MiddlewareInterface
{
}

public function process(ServerRequestInterface $request, RequestHandlerInterface $handler)
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
if (!$request->hasHeader('Authorization')) {
return $this->responses->createResponse(401);
Expand All @@ -209,15 +262,40 @@ final class AuthMiddleware implements MiddlewareInterface

$transport = new StreamableHttpTransport(
$request,
$responseFactory,
$streamFactory,
[],
$logger,
[new AuthMiddleware($responseFactory)],
logger: $logger,
middleware: [
...StreamableHttpTransport::defaultMiddleware(),
new AuthMiddleware($responseFactory),
],
);
```

If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.
To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:

```php
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
use Mcp\Server\Transport\StreamableHttpTransport;

$transport = new StreamableHttpTransport(
$request,
middleware: [
...array_filter(
StreamableHttpTransport::defaultMiddleware(),
fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
),
new AuthMiddleware($responseFactory),
],
);
```

Pass `middleware: []` to disable every default and run only your own chain:

```php
$transport = new StreamableHttpTransport(
$request,
middleware: [new AuthMiddleware($responseFactory)],
);
```

### Architecture

Expand Down
7 changes: 6 additions & 1 deletion examples/server/oauth-keycloak/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,12 @@
$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
logger: logger(),
middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
middleware: [
...StreamableHttpTransport::defaultMiddleware(),
$metadataMiddleware,
$authMiddleware,
new OAuthRequestMetaMiddleware(),
],
);

$response = $server->run($transport);
Expand Down
8 changes: 7 additions & 1 deletion examples/server/oauth-microsoft/server.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,13 @@
$transport = new StreamableHttpTransport(
(new Psr17Factory())->createServerRequestFromGlobals(),
logger: logger(),
middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
middleware: [
...StreamableHttpTransport::defaultMiddleware(),
$oauthProxyMiddleware,
$metadataMiddleware,
$authMiddleware,
new OAuthRequestMetaMiddleware(),
],
);

$response = $server->run($transport);
Expand Down
42 changes: 42 additions & 0 deletions src/Server/Transport/Http/JsonRpcErrorResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?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;

use Mcp\Schema\JsonRpc\Error;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\StreamFactoryInterface;

/**
* Builds the canonical JSON-RPC error response used by the HTTP transport
* and its middleware: a PSR-7 response with the given HTTP status, a
* `Content-Type: application/json` header, and a body containing a single
* `Error::forInvalidRequest($message)` payload.
*
* @internal
*/
final class JsonRpcErrorResponse
{
public static function create(
ResponseFactoryInterface $responseFactory,
StreamFactoryInterface $streamFactory,
int $statusCode,
string $message,
): ResponseInterface {
$body = json_encode(Error::forInvalidRequest($message), \JSON_THROW_ON_ERROR);

return $responseFactory
->createResponse($statusCode)
->withHeader('Content-Type', 'application/json')
->withBody($streamFactory->createStream($body));
}
}
Loading