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: $envelope = $request->getAttribute(StreamableHttpServerTransport::ENVELOPE_ATTRIBUTE);
72:
73: if (! \is_array($envelope)) {
74: $body = (string) $request->getBody();
75: $request = $request->withBody($this->streamFactory->createStream($body));
76: $envelope = json_decode($body, associative: true);
77:
78: if (! \is_array($envelope)) {
79: return $handler->handle($request);
80: }
81:
82: $request = $request->withAttribute(StreamableHttpServerTransport::ENVELOPE_ATTRIBUTE, $envelope);
83: }
84:
85: if (CallToolRequest::getMethod() !== ($envelope['method'] ?? null)) {
86: return $handler->handle($request);
87: }
88:
89: $params = $envelope['params'] ?? null;
90:
91: if (! \is_array($params) || ! \is_string($params['name'] ?? null)) {
92: return $handler->handle($request);
93: }
94:
95: $name = $params['name'];
96: $arguments = $params['arguments'] ?? [];
97: $mismatch = $this->parameterHeaders->validate(
98: $this->resolveBindings($name),
99: \is_array($arguments) ? $arguments : [],
100: $this->readHeaders($request),
101: );
102:
103: if (null === $mismatch) {
104: return $handler->handle($request);
105: }
106:
107: return $this->reject($mismatch, EnvelopeRequestId::recover($envelope));
108: }
109:
110: /**
111: * @return list<ParameterHeaderBinding>
112: */
113: private function resolveBindings(string $tool): array
114: {
115: $this->bindings ??= $this->scan();
116:
117: return $this->bindings[$tool] ?? [];
118: }
119:
120: /**
121: * @return array<string, list<ParameterHeaderBinding>>
122: */
123: private function scan(): array
124: {
125: $bindings = [];
126: $cursor = null;
127:
128: do {
129: $page = $this->store->list($cursor);
130:
131: foreach ($page->tools as $tool) {
132: $result = ParameterHeaderScanner::scan($tool->inputSchema);
133:
134: if (! $result->valid) {
135: $this->logger->warning(
136: 'Tool {tool} declares an invalid "x-mcp-header", so only its {checked} valid declarations are checked.',
137: ['tool' => $tool->name, 'reason' => $result->reason, 'checked' => \count($result->bindings)],
138: );
139: }
140:
141: $bindings[$tool->name] = $result->bindings;
142: }
143:
144: $cursor = $page->nextCursor;
145: } while (null !== $cursor);
146:
147: return $bindings;
148: }
149:
150: /**
151: * @return array<string, string>
152: */
153: private function readHeaders(ServerRequestInterface $request): array
154: {
155: return array_map(
156: static fn(array $values): string => implode(', ', $values),
157: $request->getHeaders(),
158: );
159: }
160:
161: private function reject(HeaderMismatchError $error, ?RequestId $id): ResponseInterface
162: {
163: $envelope = new JsonRpcErrorResponse(id: $id, error: $error);
164:
165: return $this->responseFactory->createResponse(HttpStatus::BadRequest->value)
166: ->withHeader('Content-Type', 'application/json')
167: ->withBody($this->streamFactory->createStream(json_encode($envelope, \JSON_THROW_ON_ERROR)))
168: ;
169: }
170: }
171: