Skip to content

Commit 32ed494

Browse files
committed
[Server] Add CORS, DNS rebinding and protocol version middleware
Introduce three PSR-15 middleware for `StreamableHttpTransport` exposed through a public `StreamableHttpTransport::defaultMiddleware()` factory composed automatically when no middleware is passed. - `CorsMiddleware`: secure-by-default (no `Access-Control-Allow-Origin`), configurable allowlist, reflects matching origin with `Vary: Origin` to protect shared caches. - `DnsRebindingProtectionMiddleware`: validates `Origin`/`Host` against a hostname allowlist (localhost-only by default). - `ProtocolVersionMiddleware`: rejects requests carrying an unsupported `Mcp-Protocol-Version` header with `400 Bad Request`. The transport no longer applies CORS via an `instanceof + array_unshift` post-hook; the middleware parameter is nullable — `null` installs the secure defaults, `[]` disables them, and users compose by spreading `StreamableHttpTransport::defaultMiddleware()`. `SESSION_HEADER` and `PROTOCOL_VERSION_HEADER` are promoted to public constants so middleware can reuse them. BC breaks: - The `corsHeaders` constructor parameter is removed; the `middleware` parameter shifts one position. Positional callers passing the old `corsHeaders` argument must switch to named arguments or drop it. - Default `Access-Control-Allow-Origin` is no longer `*`. Addresses #260 (DNS rebinding), #277 (CORS extraction) and #306 (protocol version validation).
1 parent 73414d6 commit 32ed494

15 files changed

Lines changed: 1168 additions & 145 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ All notable changes to `mcp/sdk` will be documented in this file.
66
-----
77

88
* Allow overriding the default name pattern for Discovery
9+
* Add `CorsMiddleware`, `DnsRebindingProtectionMiddleware`, and `ProtocolVersionMiddleware` for `StreamableHttpTransport`, composed automatically as the default stack via `StreamableHttpTransport::defaultMiddleware()`
10+
* **[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 `*`).
911

1012
0.5.0
1113
-----

docs/transports.md

Lines changed: 115 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ $transport = new StreamableHttpTransport(
110110
- **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request
111111
- **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided.
112112
- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided.
113-
- **`corsHeaders`** (optional): `array` - Custom CORS headers to override defaults. Merges with secure defaults. Defaults to `[]`.
114113
- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`.
114+
- **`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.
115115

116116
### PSR-17 Auto-Discovery
117117

@@ -137,56 +137,109 @@ $psr17Factory = new Psr17Factory();
137137
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
138138
```
139139

140-
### CORS Configuration
140+
### Default Middleware
141+
142+
When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack:
141143

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

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

148-
// Restrict to specific origin
149-
$transport = new StreamableHttpTransport(
150-
$request,
151-
$responseFactory,
152-
$streamFactory,
153-
['Access-Control-Allow-Origin' => 'https://myapp.com']
154-
);
155+
The default stack can be inspected and recomposed via the public factory:
156+
157+
```php
158+
$middleware = StreamableHttpTransport::defaultMiddleware();
159+
```
160+
161+
### CORS Configuration
162+
163+
CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it
164+
in place of (or alongside) the defaults:
155165

156-
// Disable CORS for proxy scenarios
166+
```php
167+
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
168+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
169+
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
170+
use Mcp\Server\Transport\StreamableHttpTransport;
171+
172+
// Reflect a specific origin
157173
$transport = new StreamableHttpTransport(
158174
$request,
159-
$responseFactory,
160-
$streamFactory,
161-
['Access-Control-Allow-Origin' => '']
175+
middleware: [
176+
new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
177+
new DnsRebindingProtectionMiddleware(),
178+
new ProtocolVersionMiddleware(),
179+
],
162180
);
163181

164-
// Custom headers with logger
182+
// Allow all origins (development only)
165183
$transport = new StreamableHttpTransport(
166184
$request,
167-
$responseFactory,
168-
$streamFactory,
169-
[
170-
'Access-Control-Allow-Origin' => 'https://api.example.com',
171-
'Access-Control-Max-Age' => '86400'
185+
middleware: [
186+
new CorsMiddleware(allowedOrigins: ['*']),
187+
new DnsRebindingProtectionMiddleware(),
188+
new ProtocolVersionMiddleware(),
172189
],
173-
$logger
174190
);
175191
```
176192

177-
Default CORS headers:
178-
- `Access-Control-Allow-Origin: *`
179-
- `Access-Control-Allow-Methods: GET, POST, DELETE, OPTIONS`
180-
- `Access-Control-Allow-Headers: Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID, Authorization, Accept`
193+
When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin`
194+
so shared caches/CDNs do not serve a response generated for one origin to a request from another.
195+
196+
Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds
197+
defaults when they are absent.
198+
199+
> [!IMPORTANT]
200+
> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying
201+
> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves
202+
> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`.
203+
> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials.
181204
182-
### PSR-15 Middleware
205+
### DNS Rebinding Protection
183206

184-
`StreamableHttpTransport` can run a PSR-15 middleware chain before it processes the request. Middleware can log,
185-
enforce auth, or short-circuit with a response for any HTTP method.
207+
`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host`
208+
when `Origin` is absent). The default allowlist is localhost-only:
209+
210+
```php
211+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
212+
213+
new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']);
214+
```
215+
216+
If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain
217+
or supply a permissive allowlist.
218+
219+
### Protocol Version Validation
220+
221+
`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported
222+
set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some
223+
legacy clients do not send it.
224+
225+
```php
226+
use Mcp\Schema\Enum\ProtocolVersion;
227+
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
228+
229+
// Only accept the latest spec version
230+
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
231+
```
232+
233+
### Custom PSR-15 Middleware
234+
235+
`StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append
236+
your own middleware — the defaults stay outermost so CORS headers are applied to every response, including
237+
short-circuited ones:
186238

187239
```php
188240
use Mcp\Server\Transport\StreamableHttpTransport;
189241
use Psr\Http\Message\ResponseFactoryInterface;
242+
use Psr\Http\Message\ResponseInterface;
190243
use Psr\Http\Message\ServerRequestInterface;
191244
use Psr\Http\Server\MiddlewareInterface;
192245
use Psr\Http\Server\RequestHandlerInterface;
@@ -197,7 +250,7 @@ final class AuthMiddleware implements MiddlewareInterface
197250
{
198251
}
199252

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

210263
$transport = new StreamableHttpTransport(
211264
$request,
212-
$responseFactory,
213-
$streamFactory,
214-
[],
215-
$logger,
216-
[new AuthMiddleware($responseFactory)],
265+
logger: $logger,
266+
middleware: [
267+
...StreamableHttpTransport::defaultMiddleware(),
268+
new AuthMiddleware($responseFactory),
269+
],
217270
);
218271
```
219272

220-
If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.
273+
To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:
274+
275+
```php
276+
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
277+
use Mcp\Server\Transport\StreamableHttpTransport;
278+
279+
$transport = new StreamableHttpTransport(
280+
$request,
281+
middleware: [
282+
...array_filter(
283+
StreamableHttpTransport::defaultMiddleware(),
284+
fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
285+
),
286+
new AuthMiddleware($responseFactory),
287+
],
288+
);
289+
```
290+
291+
Pass `middleware: []` to disable every default and run only your own chain:
292+
293+
```php
294+
$transport = new StreamableHttpTransport(
295+
$request,
296+
middleware: [new AuthMiddleware($responseFactory)],
297+
);
298+
```
221299

222300
### Architecture
223301

examples/server/oauth-keycloak/server.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@
5858
$transport = new StreamableHttpTransport(
5959
(new Psr17Factory())->createServerRequestFromGlobals(),
6060
logger: logger(),
61-
middleware: [$metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
61+
middleware: [
62+
...StreamableHttpTransport::defaultMiddleware(),
63+
$metadataMiddleware,
64+
$authMiddleware,
65+
new OAuthRequestMetaMiddleware(),
66+
],
6267
);
6368

6469
$response = $server->run($transport);

examples/server/oauth-microsoft/server.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,13 @@
8181
$transport = new StreamableHttpTransport(
8282
(new Psr17Factory())->createServerRequestFromGlobals(),
8383
logger: logger(),
84-
middleware: [$oauthProxyMiddleware, $metadataMiddleware, $authMiddleware, new OAuthRequestMetaMiddleware()],
84+
middleware: [
85+
...StreamableHttpTransport::defaultMiddleware(),
86+
$oauthProxyMiddleware,
87+
$metadataMiddleware,
88+
$authMiddleware,
89+
new OAuthRequestMetaMiddleware(),
90+
],
8591
);
8692

8793
$response = $server->run($transport);
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the official PHP MCP SDK.
5+
*
6+
* A collaboration between Symfony and the PHP Foundation.
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Mcp\Server\Transport\Http;
13+
14+
use Mcp\Schema\JsonRpc\Error;
15+
use Psr\Http\Message\ResponseFactoryInterface;
16+
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\StreamFactoryInterface;
18+
19+
/**
20+
* Builds the canonical JSON-RPC error response used by the HTTP transport
21+
* and its middleware: a PSR-7 response with the given HTTP status, a
22+
* `Content-Type: application/json` header, and a body containing a single
23+
* `Error::forInvalidRequest($message)` payload.
24+
*
25+
* @internal
26+
*/
27+
final class JsonRpcErrorResponse
28+
{
29+
public static function create(
30+
ResponseFactoryInterface $responseFactory,
31+
StreamFactoryInterface $streamFactory,
32+
int $statusCode,
33+
string $message,
34+
): ResponseInterface {
35+
$body = json_encode(Error::forInvalidRequest($message), \JSON_THROW_ON_ERROR);
36+
37+
return $responseFactory
38+
->createResponse($statusCode)
39+
->withHeader('Content-Type', 'application/json')
40+
->withBody($streamFactory->createStream($body));
41+
}
42+
}

0 commit comments

Comments
 (0)