Skip to content

Commit 2ed3d9c

Browse files
chr-hertelclaude
andcommitted
[Server] refactor: extract CORS handling into CorsMiddleware
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85fddf5 commit 2ed3d9c

File tree

6 files changed

+298
-82
lines changed

6 files changed

+298
-82
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ All notable changes to `mcp/sdk` will be documented in this file.
55
0.5.0
66
-----
77

8+
* **[BC BREAK]** Extract CORS handling from `StreamableHttpTransport` into `CorsMiddleware`. The `$corsHeaders` constructor parameter is replaced by `$corsMiddleware`. Default `Access-Control-Allow-Origin` is no longer set (was `*`).
89
* Add built-in authentication middleware for HTTP transport using OAuth
910
* Add client component for building MCP clients
1011
* Add `Builder::setReferenceHandler()` to allow custom `ReferenceHandlerInterface` implementations (e.g. authorization decorators)

docs/transports.md

Lines changed: 31 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -139,45 +139,50 @@ $transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory)
139139

140140
### CORS Configuration
141141

142-
The transport sets secure CORS defaults that can be customized or disabled:
142+
CORS is handled by the `CorsMiddleware`, which is automatically prepended to the middleware chain. By default,
143+
no `Access-Control-Allow-Origin` header is set, which effectively blocks cross-origin browser requests.
143144

144145
```php
145-
// Default CORS headers (backward compatible)
146-
$transport = new StreamableHttpTransport($request, $responseFactory, $streamFactory);
146+
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
147+
use Mcp\Server\Transport\StreamableHttpTransport;
148+
149+
// Default: cross-origin requests are blocked (no Access-Control-Allow-Origin header)
150+
$transport = new StreamableHttpTransport($request);
147151

148-
// Restrict to specific origin
152+
// Allow specific origins
149153
$transport = new StreamableHttpTransport(
150154
$request,
151-
$responseFactory,
152-
$streamFactory,
153-
['Access-Control-Allow-Origin' => 'https://myapp.com']
155+
corsMiddleware: new CorsMiddleware(
156+
allowedOrigins: ['https://myapp.com', 'https://staging.myapp.com'],
157+
),
154158
);
155159

156-
// Disable CORS for proxy scenarios
160+
// Allow all origins (e.g. for development)
157161
$transport = new StreamableHttpTransport(
158162
$request,
159-
$responseFactory,
160-
$streamFactory,
161-
['Access-Control-Allow-Origin' => '']
163+
corsMiddleware: new CorsMiddleware(allowedOrigins: ['*']),
162164
);
163165

164-
// Custom headers with logger
166+
// Full configuration
165167
$transport = new StreamableHttpTransport(
166168
$request,
167-
$responseFactory,
168-
$streamFactory,
169-
[
170-
'Access-Control-Allow-Origin' => 'https://api.example.com',
171-
'Access-Control-Max-Age' => '86400'
172-
],
173-
$logger
169+
corsMiddleware: new CorsMiddleware(
170+
allowedOrigins: ['https://myapp.com'],
171+
allowedMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
172+
allowedHeaders: ['Accept', 'Authorization', 'Content-Type', 'Last-Event-ID', 'Mcp-Protocol-Version', 'Mcp-Session-Id'],
173+
exposedHeaders: ['Mcp-Session-Id'],
174+
),
174175
);
175176
```
176177

177-
Default CORS headers:
178-
- `Access-Control-Allow-Origin: *`
178+
Default CORS headers (always set unless overridden by middleware):
179179
- `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`
180+
- `Access-Control-Allow-Headers: Accept, Authorization, Content-Type, Last-Event-ID, Mcp-Protocol-Version, Mcp-Session-Id`
181+
- `Access-Control-Expose-Headers: Mcp-Session-Id`
182+
183+
The `CorsMiddleware` is always the first middleware in the chain, ensuring CORS headers are applied to all responses —
184+
including those from other middleware that short-circuit (e.g. an auth middleware returning `401`). The transport itself
185+
handles `OPTIONS` preflight requests by returning a `204` response.
181186

182187
### PSR-15 Middleware
183188

@@ -209,15 +214,13 @@ final class AuthMiddleware implements MiddlewareInterface
209214

210215
$transport = new StreamableHttpTransport(
211216
$request,
212-
$responseFactory,
213-
$streamFactory,
214-
[],
215-
$logger,
216-
[new AuthMiddleware($responseFactory)],
217+
logger: $logger,
218+
middleware: [new AuthMiddleware($responseFactory)],
217219
);
218220
```
219221

220-
If middleware returns a response, the transport will still ensure CORS headers are present unless you set them yourself.
222+
The `CorsMiddleware` is always prepended before user middleware, so CORS headers are applied to all responses
223+
even when middleware short-circuits.
221224

222225
### Architecture
223226

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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\Middleware;
13+
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\ServerRequestInterface;
16+
use Psr\Http\Server\MiddlewareInterface;
17+
use Psr\Http\Server\RequestHandlerInterface;
18+
19+
/**
20+
* Applies CORS headers to all responses.
21+
*
22+
* By default, no Access-Control-Allow-Origin header is set, which effectively
23+
* blocks cross-origin browser requests. Configure $allowedOrigins to allow
24+
* specific origins or use ['*'] to allow all.
25+
*
26+
* @author Christopher Hertel <mail@christopher-hertel.de>
27+
*/
28+
final class CorsMiddleware implements MiddlewareInterface
29+
{
30+
/**
31+
* @param list<string> $allowedOrigins Origins to allow (empty = no Access-Control-Allow-Origin header). Use ['*'] to allow all origins.
32+
* @param list<string> $allowedMethods HTTP methods for Access-Control-Allow-Methods
33+
* @param list<string> $allowedHeaders Request headers for Access-Control-Allow-Headers
34+
* @param list<string> $exposedHeaders Response headers for Access-Control-Expose-Headers
35+
*/
36+
public function __construct(
37+
private readonly array $allowedOrigins = [],
38+
private readonly array $allowedMethods = ['GET', 'POST', 'DELETE', 'OPTIONS'],
39+
private readonly array $allowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'Last-Event-ID', 'Mcp-Protocol-Version', 'Mcp-Session-Id'],
40+
private readonly array $exposedHeaders = ['Mcp-Session-Id'],
41+
) {
42+
}
43+
44+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
45+
{
46+
$response = $handler->handle($request);
47+
48+
$origin = $request->getHeaderLine('Origin');
49+
$allowedOrigin = $this->resolveAllowedOrigin($origin);
50+
51+
if (null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Origin')) {
52+
$response = $response->withHeader('Access-Control-Allow-Origin', $allowedOrigin);
53+
}
54+
55+
if (!$response->hasHeader('Access-Control-Allow-Methods')) {
56+
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods));
57+
}
58+
59+
if (!$response->hasHeader('Access-Control-Allow-Headers')) {
60+
$response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders));
61+
}
62+
63+
if ([] !== $this->exposedHeaders && !$response->hasHeader('Access-Control-Expose-Headers')) {
64+
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders));
65+
}
66+
67+
return $response;
68+
}
69+
70+
private function resolveAllowedOrigin(string $origin): ?string
71+
{
72+
if ([] === $this->allowedOrigins) {
73+
return null;
74+
}
75+
76+
if (\in_array('*', $this->allowedOrigins, true)) {
77+
return '*';
78+
}
79+
80+
if ('' !== $origin && \in_array($origin, $this->allowedOrigins, true)) {
81+
return $origin;
82+
}
83+
84+
return null;
85+
}
86+
}

src/Server/Transport/StreamableHttpTransport.php

Lines changed: 4 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Http\Discovery\Psr17FactoryDiscovery;
1515
use Mcp\Exception\InvalidArgumentException;
1616
use Mcp\Schema\JsonRpc\Error;
17+
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
1718
use Mcp\Server\Transport\Http\MiddlewareRequestHandler;
1819
use Psr\Http\Message\ResponseFactoryInterface;
1920
use Psr\Http\Message\ResponseInterface;
@@ -32,50 +33,32 @@ class StreamableHttpTransport extends BaseTransport
3233
{
3334
private const SESSION_HEADER = 'Mcp-Session-Id';
3435

35-
private const ALLOWED_HEADER = [
36-
'Accept',
37-
'Authorization',
38-
'Content-Type',
39-
'Last-Event-ID',
40-
'Mcp-Protocol-Version',
41-
self::SESSION_HEADER,
42-
];
43-
4436
private ResponseFactoryInterface $responseFactory;
4537
private StreamFactoryInterface $streamFactory;
4638

4739
private ?string $immediateResponse = null;
4840
private ?int $immediateStatusCode = null;
4941

50-
/** @var array<string, string> */
51-
private array $corsHeaders;
52-
5342
/** @var list<MiddlewareInterface> */
5443
private array $middleware = [];
5544

5645
/**
57-
* @param array<string, string> $corsHeaders
5846
* @param iterable<MiddlewareInterface> $middleware
5947
*/
6048
public function __construct(
6149
private ServerRequestInterface $request,
6250
?ResponseFactoryInterface $responseFactory = null,
6351
?StreamFactoryInterface $streamFactory = null,
64-
array $corsHeaders = [],
6552
?LoggerInterface $logger = null,
6653
iterable $middleware = [],
54+
?CorsMiddleware $corsMiddleware = null,
6755
) {
6856
parent::__construct($logger);
6957

7058
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
7159
$this->streamFactory = $streamFactory ?? Psr17FactoryDiscovery::findStreamFactory();
7260

73-
$this->corsHeaders = array_merge([
74-
'Access-Control-Allow-Origin' => '*',
75-
'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
76-
'Access-Control-Allow-Headers' => implode(',', self::ALLOWED_HEADER),
77-
'Access-Control-Expose-Headers' => self::SESSION_HEADER,
78-
], $corsHeaders);
61+
$this->middleware[] = $corsMiddleware ?? new CorsMiddleware();
7962

8063
foreach ($middleware as $m) {
8164
if (!$m instanceof MiddlewareInterface) {
@@ -98,7 +81,7 @@ public function listen(): ResponseInterface
9881
\Closure::fromCallable([$this, 'handleRequest']),
9982
);
10083

101-
return $this->withCorsHeaders($handler->handle($this->request));
84+
return $handler->handle($this->request);
10285
}
10386

10487
protected function handleOptionsRequest(): ResponseInterface
@@ -273,17 +256,6 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
273256
return $response;
274257
}
275258

276-
protected function withCorsHeaders(ResponseInterface $response): ResponseInterface
277-
{
278-
foreach ($this->corsHeaders as $name => $value) {
279-
if (!$response->hasHeader($name)) {
280-
$response = $response->withHeader($name, $value);
281-
}
282-
}
283-
284-
return $response;
285-
}
286-
287259
private function handleRequest(ServerRequestInterface $request): ResponseInterface
288260
{
289261
$this->request = $request;

0 commit comments

Comments
 (0)