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 Nexus\Mcp\Server\Transport\StreamableHttpServerTransport;
28: use Psr\Http\Message\ResponseFactoryInterface;
29: use Psr\Http\Message\ResponseInterface;
30: use Psr\Http\Message\ServerRequestInterface;
31: use Psr\Http\Message\StreamFactoryInterface;
32: use Psr\Http\Server\MiddlewareInterface;
33: use Psr\Http\Server\RequestHandlerInterface;
34: use Psr\Log\LoggerInterface;
35: use Psr\Log\NullLogger;
36:
37: /**
38: * Rejects a `tools/call` whose `Mcp-Param-{Name}` headers disagree with the arguments in its body.
39: *
40: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http#server-behavior-for-custom-headers
41: */
42: final class ParameterHeaderValidationMiddleware implements MiddlewareInterface
43: {
44: /**
45: * Bindings keyed by tool name, or `null` until the store has been scanned.
46: *
47: * @var null|array<string, list<ParameterHeaderBinding>>
48: */
49: private ?array $bindings = null;
50:
51: private readonly ParameterHeaders $parameterHeaders;
52:
53: public function __construct(
54: private readonly ToolStoreInterface $store,
55: private readonly ResponseFactoryInterface $responseFactory,
56: private readonly StreamFactoryInterface $streamFactory,
57: private readonly LoggerInterface $logger = new NullLogger(),
58: ) {
59: $this->parameterHeaders = new ParameterHeaders();
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: $body = (string) $request->getBody();
72: $request = $request->withBody($this->streamFactory->createStream($body));
73: $envelope = json_decode($body, associative: true);
74:
75: if (! \is_array($envelope)) {
76: return $handler->handle($request);
77: }
78:
79: $request = $request->withAttribute(StreamableHttpServerTransport::ENVELOPE_ATTRIBUTE, $envelope);
80:
81: if (CallToolRequest::getMethod() !== ($envelope['method'] ?? null)) {
82: return $handler->handle($request);
83: }
84:
85: $params = $envelope['params'] ?? null;
86:
87: if (! \is_array($params)) {
88: return $handler->handle($request);
89: }
90:
91: $name = $params['name'] ?? null;
92:
93: if (! \is_string($name)) {
94: return $handler->handle($request);
95: }
96:
97: $arguments = $params['arguments'] ?? [];
98: $mismatch = $this->parameterHeaders->validate(
99: $this->resolveBindings($name),
100: \is_array($arguments) ? $arguments : [],
101: $this->readHeaders($request),
102: );
103:
104: if (null === $mismatch) {
105: return $handler->handle($request);
106: }
107:
108: return $this->reject($mismatch, EnvelopeRequestId::recover($envelope));
109: }
110:
111: /**
112: * @return list<ParameterHeaderBinding>
113: */
114: private function resolveBindings(string $tool): array
115: {
116: $this->bindings ??= $this->scan();
117:
118: return $this->bindings[$tool] ?? [];
119: }
120:
121: /**
122: * @return array<string, list<ParameterHeaderBinding>>
123: */
124: private function scan(): array
125: {
126: $bindings = [];
127: $cursor = null;
128:
129: do {
130: $page = $this->store->list($cursor);
131:
132: foreach ($page->tools as $tool) {
133: $result = ParameterHeaderScanner::scan($tool->inputSchema);
134:
135: if (! $result->valid) {
136: $this->logger->warning(
137: 'Skipping {tool} header validation: its "x-mcp-header" declarations are invalid.',
138: ['tool' => $tool->name, 'reason' => $result->reason],
139: );
140: }
141:
142: $bindings[$tool->name] = $result->bindings;
143: }
144:
145: $cursor = $page->nextCursor;
146: } while (null !== $cursor);
147:
148: return $bindings;
149: }
150:
151: /**
152: * @return array<string, string>
153: */
154: private function readHeaders(ServerRequestInterface $request): array
155: {
156: return array_map(
157: static fn(array $values): string => implode(', ', $values),
158: $request->getHeaders(),
159: );
160: }
161:
162: private function reject(HeaderMismatchError $error, ?RequestId $id): ResponseInterface
163: {
164: $envelope = new JsonRpcErrorResponse(id: $id, error: $error);
165:
166: return $this->responseFactory->createResponse(HttpStatus::BadRequest->value)
167: ->withHeader('Content-Type', 'application/json')
168: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
169: ;
170: }
171: }
172: