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 Nexus\Mcp\Core\Http\ParameterHeaderBinding;
18: use Nexus\Mcp\Core\Http\ParameterHeaders;
19: use Nexus\Mcp\Core\Http\ParameterHeaderScanner;
20: use Nexus\Mcp\Core\JsonRpc\EnvelopeRequestId;
21: use Nexus\Mcp\Core\Schema\Error\HeaderMismatchError;
22: use Nexus\Mcp\Core\Schema\JsonRpc\JsonRpcErrorResponse;
23: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
24: use Nexus\Mcp\Core\Schema\RequestId;
25: use Nexus\Mcp\Server\ListChangeSourceInterface;
26: use Nexus\Mcp\Server\Tool\ToolStoreInterface;
27: use Psr\Http\Message\ResponseFactoryInterface;
28: use Psr\Http\Message\ResponseInterface;
29: use Psr\Http\Message\ServerRequestInterface;
30: use Psr\Http\Message\StreamFactoryInterface;
31: use Psr\Http\Server\MiddlewareInterface;
32: use Psr\Http\Server\RequestHandlerInterface;
33: use Psr\Log\LoggerInterface;
34: use Psr\Log\NullLogger;
35:
36: /**
37: * Rejects a `tools/call` whose `Mcp-Param-{Name}` headers disagree with the arguments in its body.
38: *
39: * The spec requires any server that processes the body to validate the mirrored headers against it, so an
40: * intermediary routing on a header value cannot disagree with what the server executes. Bindings are read
41: * from the tool `inputSchema` declarations once and cached, and the cache is dropped whenever a
42: * `ListChangeSourceInterface` store reports that its listing changed.
43: *
44: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-behavior-for-custom-headers
45: */
46: final class ParameterHeaderValidationMiddleware implements MiddlewareInterface
47: {
48: /**
49: * Bindings keyed by tool name, or `null` until the store has been scanned.
50: *
51: * @var null|array<string, list<ParameterHeaderBinding>>
52: */
53: private ?array $bindings = null;
54:
55: public function __construct(
56: private readonly ToolStoreInterface $store,
57: private readonly ResponseFactoryInterface $responseFactory,
58: private readonly StreamFactoryInterface $streamFactory,
59: private readonly LoggerInterface $logger = new NullLogger(),
60: ) {
61: if ($store instanceof ListChangeSourceInterface) {
62: $store->onListChanged(function (): void {
63: $this->bindings = null;
64: });
65: }
66: }
67:
68: #[\Override]
69: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
70: {
71: $envelope = self::readEnvelope($request);
72:
73: if (CallToolRequest::getMethod() !== ($envelope['method'] ?? null)) {
74: // Only a tool call mirrors arguments into headers. Every other method, malformed bodies included,
75: // is the transport's to answer, even when it carries a `params.name` of its own.
76: return $handler->handle($request);
77: }
78:
79: $params = $envelope['params'] ?? null;
80: $name = \is_array($params) ? $params['name'] ?? null : null;
81:
82: if (! \is_string($name)) {
83: return $handler->handle($request);
84: }
85:
86: $arguments = \is_array($params) ? $params['arguments'] ?? [] : [];
87: $mismatch = ParameterHeaders::validate(
88: $this->resolveBindings($name),
89: \is_array($arguments) ? array_filter($arguments, is_string(...), \ARRAY_FILTER_USE_KEY) : [],
90: self::readHeaders($request),
91: );
92:
93: if (null === $mismatch) {
94: return $handler->handle($request);
95: }
96:
97: return $this->reject($mismatch, EnvelopeRequestId::recover($envelope));
98: }
99:
100: /**
101: * @return list<ParameterHeaderBinding>
102: */
103: private function resolveBindings(string $tool): array
104: {
105: $this->bindings ??= $this->scan();
106:
107: return $this->bindings[$tool] ?? [];
108: }
109:
110: /**
111: * @return array<string, list<ParameterHeaderBinding>>
112: */
113: private function scan(): array
114: {
115: $bindings = [];
116: $cursor = null;
117:
118: do {
119: $page = $this->store->list($cursor);
120:
121: foreach ($page->tools as $tool) {
122: $result = ParameterHeaderScanner::scan($tool->inputSchema);
123:
124: if (! $result->valid) {
125: // An invalid scan yields no bindings, so the tool goes unvalidated. A conforming client
126: // already excluded it from its own listing and will never call it.
127: $this->logger->warning(
128: 'Skipping {tool} header validation: its "x-mcp-header" declarations are invalid.',
129: ['tool' => $tool->name, 'reason' => $result->reason],
130: );
131: }
132:
133: $bindings[$tool->name] = $result->bindings;
134: }
135:
136: $cursor = $page->nextCursor;
137: } while (null !== $cursor);
138:
139: return $bindings;
140: }
141:
142: /**
143: * Peeks at the request body. PSR-7 has `__toString()` seek to the start, so the transport downstream
144: * still reads a whole body. An int-keyed decode yields no envelope keys and so governs nothing.
145: *
146: * @return array<string, mixed>
147: */
148: private static function readEnvelope(ServerRequestInterface $request): array
149: {
150: $decoded = json_decode((string) $request->getBody(), associative: true);
151:
152: return \is_array($decoded) ? array_filter($decoded, is_string(...), \ARRAY_FILTER_USE_KEY) : [];
153: }
154:
155: /**
156: * @return array<string, string>
157: */
158: private static function readHeaders(ServerRequestInterface $request): array
159: {
160: return array_map(
161: static fn(array $values): string => implode(', ', $values),
162: $request->getHeaders(),
163: );
164: }
165:
166: private function reject(HeaderMismatchError $error, ?RequestId $id): ResponseInterface
167: {
168: $envelope = new JsonRpcErrorResponse(id: $id, error: $error);
169:
170: return $this->responseFactory->createResponse(HttpStatus::BadRequest->value)
171: ->withHeader('Content-Type', 'application/json')
172: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
173: ;
174: }
175: }
176: