1: <?php
2:
3: declare(strict_types=1);
4:
5: /**
6: * This file is part of the Nexus MCP SDK package.
7: *
8: * (c) 2026 John Paul E. Balandan, CPA <paulbalandan@gmail.com>
9: *
10: * For the full copyright and license information, please view
11: * the LICENSE file that was distributed with this source code.
12: */
13:
14: namespace Nexus\Mcp\Server\Transport\Http\Middleware;
15:
16: use Nexus\Mcp\Core\Http\HttpStatus;
17: use Psr\Http\Message\ResponseFactoryInterface;
18: use Psr\Http\Message\ResponseInterface;
19: use Psr\Http\Message\ServerRequestInterface;
20: use Psr\Http\Server\MiddlewareInterface;
21: use Psr\Http\Server\RequestHandlerInterface;
22:
23: /**
24: * Grants browser clients cross-origin access to the MCP endpoint.
25: *
26: * The middleware is additive. An allowed `Origin` is reflected into `Access-Control-Allow-Origin`, a preflight
27: * `OPTIONS` is answered with `204` plus the negotiated `Access-Control-*` headers, and every other request is
28: * forwarded and its response decorated. A disallowed or absent `Origin` receives no grant, so rejection stays
29: * with the DNS-rebinding gate. Every response carries the `Vary` keys it turns on, grant or not, so a shared
30: * cache cannot serve one origin's answer to another.
31: */
32: final readonly class CorsMiddleware implements MiddlewareInterface
33: {
34: private const string WILDCARD = '*';
35:
36: /**
37: * @param list<non-empty-string> $allowedOrigins Origins granted cross-origin access, or `['*']` to allow any
38: * @param int $maxAge Seconds a browser may cache the preflight result
39: */
40: public function __construct(
41: private array $allowedOrigins,
42: private ResponseFactoryInterface $responseFactory,
43: private int $maxAge = 600,
44: ) {
45: }
46:
47: #[\Override]
48: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
49: {
50: if (self::isPreflight($request)) {
51: return $this->preflight($request);
52: }
53:
54: return $this->decorate($request, $handler->handle($request));
55: }
56:
57: private function preflight(ServerRequestInterface $request): ResponseInterface
58: {
59: // A preflight answer turns on both request headers, so it is keyed on both whether or not the origin
60: // is allowed. Without that, a cache can replay one origin's or one header set's answer to another.
61: $response = $this->responseFactory->createResponse(HttpStatus::NoContent->value)
62: ->withAddedHeader('Vary', 'Origin')
63: ->withAddedHeader('Vary', 'Access-Control-Request-Headers')
64: ;
65:
66: if (! $this->isAllowedOrigin($request)) {
67: return $response;
68: }
69:
70: $response = $response
71: ->withHeader('Access-Control-Allow-Origin', $request->getHeaderLine('Origin'))
72: ->withHeader('Access-Control-Allow-Methods', 'POST, OPTIONS')
73: ->withHeader('Access-Control-Max-Age', (string) $this->maxAge)
74: ;
75:
76: $requestedHeaders = $request->getHeaderLine('Access-Control-Request-Headers');
77:
78: return '' === $requestedHeaders
79: ? $response
80: : $response->withHeader('Access-Control-Allow-Headers', $requestedHeaders);
81: }
82:
83: private function decorate(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
84: {
85: // Whether the grant appears depends on `Origin`, so the response is keyed on it even when the origin
86: // is refused. A header-free response cached without it would be replayed to an allowed origin.
87: $response = $response->withAddedHeader('Vary', 'Origin');
88:
89: if (! $this->isAllowedOrigin($request)) {
90: return $response;
91: }
92:
93: return $response->withHeader('Access-Control-Allow-Origin', $request->getHeaderLine('Origin'));
94: }
95:
96: private function isAllowedOrigin(ServerRequestInterface $request): bool
97: {
98: if (! $request->hasHeader('Origin')) {
99: return false;
100: }
101:
102: $origin = $request->getHeaderLine('Origin');
103:
104: return \in_array(self::WILDCARD, $this->allowedOrigins, true)
105: || \in_array($origin, $this->allowedOrigins, true);
106: }
107:
108: private static function isPreflight(ServerRequestInterface $request): bool
109: {
110: return $request->getMethod() === 'OPTIONS'
111: && $request->hasHeader('Access-Control-Request-Method');
112: }
113: }
114: