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\Assert\Assert;
17: use Nexus\Mcp\Core\Auth\ScopeSet;
18: use Nexus\Mcp\Core\Auth\VerifiedAccessToken;
19: use Nexus\Mcp\Core\Auth\WwwAuthenticateChallenge;
20: use Nexus\Mcp\Core\Exception\LogicException;
21: use Nexus\Mcp\Core\Http\HttpStatus;
22: use Nexus\Mcp\Core\Schema\Prompt\PromptReference;
23: use Nexus\Mcp\Core\Schema\Request\CallToolRequest;
24: use Nexus\Mcp\Core\Schema\Request\CompleteRequest;
25: use Nexus\Mcp\Core\Schema\Request\GetPromptRequest;
26: use Nexus\Mcp\Core\Schema\Request\ReadResourceRequest;
27: use Nexus\Mcp\Core\Schema\Request\SubscriptionsListenRequest;
28: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplateReference;
29: use Nexus\Mcp\Core\UriTemplate\Matcher;
30: use Nexus\Mcp\Core\UriTemplate\Validator;
31: use Nexus\Mcp\Server\Transport\StreamableHttpServerTransport;
32: use Psr\Http\Message\ResponseFactoryInterface;
33: use Psr\Http\Message\ResponseInterface;
34: use Psr\Http\Message\ServerRequestInterface;
35: use Psr\Http\Message\StreamFactoryInterface;
36: use Psr\Http\Server\MiddlewareInterface;
37: use Psr\Http\Server\RequestHandlerInterface;
38:
39: /**
40: * Challenges a request for a tool, prompt, or resource whose token lacks the scopes declared for it.
41: *
42: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#runtime-insufficient-scope-errors
43: */
44: final readonly class OperationScopeMiddleware implements MiddlewareInterface
45: {
46: private ScopeSet $endpointScopes;
47:
48: /**
49: * @var array<int|string, ScopeSet>
50: */
51: private array $tools;
52:
53: /**
54: * @var array<int|string, ScopeSet>
55: */
56: private array $prompts;
57:
58: /**
59: * @var array<non-empty-string, ScopeSet>
60: */
61: private array $resourcesByPattern;
62:
63: /**
64: * @param array<int|non-empty-string, list<non-empty-string>> $tools Scopes keyed by tool name
65: * @param array<int|non-empty-string, list<non-empty-string>> $prompts Scopes keyed by prompt name
66: * @param array<int|non-empty-string, list<non-empty-string>> $resources Scopes keyed by resource URI or URI template
67: * @param list<non-empty-string> $endpointScopes Scopes that the authentication middleware asks of every request, named in each challenge beside the operation's own
68: */
69: public function __construct(
70: private string $resourceMetadataUrl,
71: private ResponseFactoryInterface $responseFactory,
72: private StreamFactoryInterface $streamFactory,
73: array $tools = [],
74: array $prompts = [],
75: array $resources = [],
76: array $endpointScopes = [],
77: ) {
78: $this->endpointScopes = $this->buildScopeSet($endpointScopes);
79: $this->tools = $this->buildScopeSets($tools, 'tool');
80: $this->prompts = $this->buildScopeSets($prompts, 'prompt');
81:
82: $resourcesByPattern = [];
83:
84: foreach ($this->buildScopeSets($resources, 'resource') as $template => $scopes) {
85: $template = (string) $template;
86: Validator::validate($template, 'Operation scope resource');
87: $resourcesByPattern[Matcher::compile($template)] = $scopes;
88: }
89:
90: $this->resourcesByPattern = $resourcesByPattern;
91: }
92:
93: #[\Override]
94: public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
95: {
96: $body = (string) $request->getBody();
97: $request = $request->withBody($this->streamFactory->createStream($body));
98: $envelope = json_decode($body, associative: true);
99:
100: if (\is_array($envelope)) {
101: $request = $request->withAttribute(StreamableHttpServerTransport::ENVELOPE_ATTRIBUTE, $envelope);
102: }
103:
104: $operationScopes = $this->resolveOperationScopes($envelope);
105:
106: if ([] === $operationScopes->values) {
107: return $handler->handle($request);
108: }
109:
110: $token = $request->getAttribute(VerifiedAccessToken::REQUEST_ATTRIBUTE);
111:
112: if (! $token instanceof VerifiedAccessToken) {
113: throw new LogicException(\sprintf(
114: 'Operation scopes need the validated token an authentication middleware stores on the "%s" request attribute, and none was found.',
115: VerifiedAccessToken::REQUEST_ATTRIBUTE,
116: ));
117: }
118:
119: $required = $this->endpointScopes->mergeWith($operationScopes);
120:
121: if ((new ScopeSet($token->scopes))->containsAll($required)) {
122: return $handler->handle($request);
123: }
124:
125: return $this->responseFactory->createResponse(HttpStatus::Forbidden->value)->withHeader(
126: 'WWW-Authenticate',
127: WwwAuthenticateChallenge::buildForResource($this->resourceMetadataUrl, 'insufficient_scope', $required)->toHeaderValue(),
128: );
129: }
130:
131: /**
132: * @param array<int|non-empty-string, list<non-empty-string>> $scopesByName
133: *
134: * @return array<int|non-empty-string, ScopeSet>
135: */
136: private function buildScopeSets(array $scopesByName, string $kind): array
137: {
138: Assert::that($scopesByName)->keys()->isIntOrNonEmptyString(\sprintf(
139: 'each operation scope %s key must be an int or non-empty string, {value} given.',
140: $kind,
141: ));
142: Assert::that($scopesByName)->values()->isList(\sprintf(
143: 'each operation scope %s entry must be a list of scopes, {type} given.',
144: $kind,
145: ));
146:
147: return array_map($this->buildScopeSet(...), $scopesByName);
148: }
149:
150: /**
151: * @param list<non-empty-string> $scopes
152: */
153: private function buildScopeSet(array $scopes): ScopeSet
154: {
155: Assert::that($scopes)->values()->matchesRegularExpression(
156: ScopeSet::SCOPE_TOKEN_PATTERN,
157: 'each operation scope must be an RFC 6749 scope-token, {value} given.',
158: );
159:
160: return new ScopeSet($scopes);
161: }
162:
163: private function resolveOperationScopes(mixed $envelope): ScopeSet
164: {
165: $params = $this->readMember($envelope, 'params');
166:
167: return match ($this->readMember($envelope, 'method')) {
168: CallToolRequest::getMethod() => $this->lookUp($this->tools, $this->readMember($params, 'name')),
169: GetPromptRequest::getMethod() => $this->lookUp($this->prompts, $this->readMember($params, 'name')),
170: ReadResourceRequest::getMethod() => $this->resolveResourceScopes([$this->readMember($params, 'uri')]),
171: CompleteRequest::getMethod() => $this->resolveReferenceScopes($this->readMember($params, 'ref')),
172: SubscriptionsListenRequest::getMethod() => $this->resolveResourceScopes(
173: $this->readMember($this->readMember($params, 'notifications'), 'resourceSubscriptions'),
174: ),
175: default => new ScopeSet(),
176: };
177: }
178:
179: private function resolveReferenceScopes(mixed $reference): ScopeSet
180: {
181: return match ($this->readMember($reference, 'type')) {
182: PromptReference::TYPE => $this->lookUp($this->prompts, $this->readMember($reference, 'name')),
183: ResourceTemplateReference::TYPE => $this->resolveResourceScopes([$this->readMember($reference, 'uri')]),
184: default => new ScopeSet(),
185: };
186: }
187:
188: private function resolveResourceScopes(mixed $uris): ScopeSet
189: {
190: $required = new ScopeSet();
191:
192: foreach (\is_array($uris) ? $uris : [] as $uri) {
193: if (! \is_string($uri)) {
194: continue;
195: }
196:
197: foreach ($this->resourcesByPattern as $pattern => $scopes) {
198: // A server template binds a percent-encoded URI to its decoded value, so both forms are matched.
199: if (Matcher::matchCompiled($pattern, $uri) !== null || Matcher::matchCompiled($pattern, rawurldecode($uri)) !== null) {
200: $required = $required->mergeWith($scopes);
201: }
202: }
203: }
204:
205: return $required;
206: }
207:
208: /**
209: * @param array<int|string, ScopeSet> $scopesByName
210: */
211: private function lookUp(array $scopesByName, mixed $name): ScopeSet
212: {
213: return \is_string($name) ? $scopesByName[$name] ?? new ScopeSet() : new ScopeSet();
214: }
215:
216: private function readMember(mixed $container, string $key): mixed
217: {
218: return \is_array($container) ? $container[$key] ?? null : null;
219: }
220: }
221: