Skip to content

Commit dba22a8

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 dba22a8

6 files changed

Lines changed: 387 additions & 89 deletions

File tree

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: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -139,45 +139,52 @@ $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+
maxAge: 86400,
175+
allowCredentials: true,
176+
),
174177
);
175178
```
176179

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

182189
### PSR-15 Middleware
183190

@@ -209,15 +216,13 @@ final class AuthMiddleware implements MiddlewareInterface
209216

210217
$transport = new StreamableHttpTransport(
211218
$request,
212-
$responseFactory,
213-
$streamFactory,
214-
[],
215-
$logger,
216-
[new AuthMiddleware($responseFactory)],
219+
logger: $logger,
220+
middleware: [new AuthMiddleware($responseFactory)],
217221
);
218222
```
219223

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

222227
### Architecture
223228

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
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 Http\Discovery\Psr17FactoryDiscovery;
15+
use Psr\Http\Message\ResponseFactoryInterface;
16+
use Psr\Http\Message\ResponseInterface;
17+
use Psr\Http\Message\ServerRequestInterface;
18+
use Psr\Http\Server\MiddlewareInterface;
19+
use Psr\Http\Server\RequestHandlerInterface;
20+
21+
/**
22+
* Handles CORS preflight requests and applies CORS headers to all responses.
23+
*
24+
* By default, no Access-Control-Allow-Origin header is set, which effectively
25+
* blocks cross-origin browser requests. Configure $allowedOrigins to allow
26+
* specific origins or use ['*'] to allow all.
27+
*
28+
* @author Christopher Hertel <mail@christopher-hertel.de>
29+
*/
30+
final class CorsMiddleware implements MiddlewareInterface
31+
{
32+
private readonly ResponseFactoryInterface $responseFactory;
33+
34+
/**
35+
* @param list<string> $allowedOrigins Origins to allow (empty = no Access-Control-Allow-Origin header). Use ['*'] to allow all origins.
36+
* @param list<string> $allowedMethods HTTP methods for Access-Control-Allow-Methods
37+
* @param list<string> $allowedHeaders Request headers for Access-Control-Allow-Headers
38+
* @param list<string> $exposedHeaders Response headers for Access-Control-Expose-Headers
39+
* @param int|null $maxAge Preflight cache duration in seconds (Access-Control-Max-Age)
40+
* @param bool $allowCredentials Whether to send Access-Control-Allow-Credentials: true
41+
*/
42+
public function __construct(
43+
private readonly array $allowedOrigins = [],
44+
private readonly array $allowedMethods = ['GET', 'POST', 'DELETE', 'OPTIONS'],
45+
private readonly array $allowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'Last-Event-ID', 'Mcp-Protocol-Version', 'Mcp-Session-Id'],
46+
private readonly array $exposedHeaders = ['Mcp-Session-Id'],
47+
private readonly ?int $maxAge = null,
48+
private readonly bool $allowCredentials = false,
49+
?ResponseFactoryInterface $responseFactory = null,
50+
) {
51+
$this->responseFactory = $responseFactory ?? Psr17FactoryDiscovery::findResponseFactory();
52+
}
53+
54+
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
55+
{
56+
if ('OPTIONS' === $request->getMethod()) {
57+
return $this->addCorsHeaders($request, $this->responseFactory->createResponse(204));
58+
}
59+
60+
return $this->addCorsHeaders($request, $handler->handle($request));
61+
}
62+
63+
private function addCorsHeaders(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
64+
{
65+
$origin = $request->getHeaderLine('Origin');
66+
$allowedOrigin = $this->resolveAllowedOrigin($origin);
67+
68+
if (null !== $allowedOrigin && !$response->hasHeader('Access-Control-Allow-Origin')) {
69+
$response = $response->withHeader('Access-Control-Allow-Origin', $allowedOrigin);
70+
}
71+
72+
if (!$response->hasHeader('Access-Control-Allow-Methods')) {
73+
$response = $response->withHeader('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods));
74+
}
75+
76+
if (!$response->hasHeader('Access-Control-Allow-Headers')) {
77+
$response = $response->withHeader('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders));
78+
}
79+
80+
if ([] !== $this->exposedHeaders && !$response->hasHeader('Access-Control-Expose-Headers')) {
81+
$response = $response->withHeader('Access-Control-Expose-Headers', implode(', ', $this->exposedHeaders));
82+
}
83+
84+
if (null !== $this->maxAge && !$response->hasHeader('Access-Control-Max-Age')) {
85+
$response = $response->withHeader('Access-Control-Max-Age', (string) $this->maxAge);
86+
}
87+
88+
if ($this->allowCredentials && !$response->hasHeader('Access-Control-Allow-Credentials')) {
89+
$response = $response->withHeader('Access-Control-Allow-Credentials', 'true');
90+
}
91+
92+
return $response;
93+
}
94+
95+
private function resolveAllowedOrigin(string $origin): ?string
96+
{
97+
if ([] === $this->allowedOrigins) {
98+
return null;
99+
}
100+
101+
if (\in_array('*', $this->allowedOrigins, true)) {
102+
return '*';
103+
}
104+
105+
if ('' !== $origin && \in_array($origin, $this->allowedOrigins, true)) {
106+
return $origin;
107+
}
108+
109+
return null;
110+
}
111+
}

src/Server/Transport/StreamableHttpTransport.php

Lines changed: 4 additions & 38 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,12 +81,7 @@ public function listen(): ResponseInterface
9881
\Closure::fromCallable([$this, 'handleRequest']),
9982
);
10083

101-
return $this->withCorsHeaders($handler->handle($this->request));
102-
}
103-
104-
protected function handleOptionsRequest(): ResponseInterface
105-
{
106-
return $this->responseFactory->createResponse(204);
84+
return $handler->handle($this->request);
10785
}
10886

10987
protected function handlePostRequest(): ResponseInterface
@@ -273,25 +251,13 @@ protected function createErrorResponse(Error $jsonRpcError, int $statusCode): Re
273251
return $response;
274252
}
275253

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-
287254
private function handleRequest(ServerRequestInterface $request): ResponseInterface
288255
{
289256
$this->request = $request;
290257
$sessionIdString = $request->getHeaderLine(self::SESSION_HEADER);
291258
$this->sessionId = $sessionIdString ? Uuid::fromString($sessionIdString) : null;
292259

293260
return match ($request->getMethod()) {
294-
'OPTIONS' => $this->handleOptionsRequest(),
295261
'POST' => $this->handlePostRequest(),
296262
'DELETE' => $this->handleDeleteRequest(),
297263
default => $this->createErrorResponse(Error::forInvalidRequest('Method Not Allowed'), 405),

0 commit comments

Comments
 (0)