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;
15:
16: use Nexus\Mcp\Core\Auth\ProtectedResourceMetadata;
17: use Nexus\Mcp\Core\Auth\ResourceIdentifier;
18: use Nexus\Mcp\Core\Auth\ScopeSet;
19: use Nexus\Mcp\Core\Http\HttpStatus;
20: use Psr\Http\Message\ResponseFactoryInterface;
21: use Psr\Http\Message\ResponseInterface;
22: use Psr\Http\Message\ServerRequestInterface;
23: use Psr\Http\Message\StreamFactoryInterface;
24: use Psr\Http\Server\RequestHandlerInterface;
25:
26: /**
27: * Serves this MCP server's Protected Resource Metadata document, the record a client reads to learn which
28: * authorization servers issue tokens for it.
29: *
30: * Route it at both `/.well-known/oauth-protected-resource{/path}` and `/.well-known/oauth-protected-resource`,
31: * and name the same URL in `BearerAuthenticationMiddleware`'s challenges. Any other path is answered `404`.
32: *
33: * @see https://datatracker.ietf.org/doc/html/rfc9728#section-3
34: */
35: final readonly class ProtectedResourceMetadataHandler implements RequestHandlerInterface
36: {
37: /**
38: * MCP requires the token in the `Authorization` header and forbids it in the query string.
39: */
40: private const string BEARER_METHOD_HEADER = 'header';
41:
42: private const string WELL_KNOWN_PATH = '/.well-known/oauth-protected-resource';
43:
44: private ProtectedResourceMetadata $document;
45:
46: /**
47: * The request paths this document belongs at, path-scoped before root.
48: *
49: * @var list<string>
50: */
51: private array $paths;
52:
53: /**
54: * @param string $resource Canonical URI of this MCP server
55: * @param list<non-empty-string> $authorizationServers Issuers that mint tokens for it, at least one
56: * @param list<non-empty-string> $scopesSupported Scopes basic use of this server calls for
57: * @param null|non-empty-string $resourceName Human-readable name for a consent screen
58: */
59: public function __construct(
60: string $resource,
61: array $authorizationServers,
62: private ResponseFactoryInterface $responseFactory,
63: private StreamFactoryInterface $streamFactory,
64: array $scopesSupported = [],
65: ?string $resourceName = null,
66: ) {
67: $identifier = new ResourceIdentifier($resource);
68: $this->document = new ProtectedResourceMetadata(
69: $identifier,
70: $authorizationServers,
71: [] === $scopesSupported ? null : new ScopeSet($scopesSupported),
72: [self::BEARER_METHOD_HEADER],
73: $resourceName,
74: );
75:
76: $path = rtrim((string) parse_url($identifier->value, \PHP_URL_PATH), '/');
77: $this->paths = '' === $path
78: ? [self::WELL_KNOWN_PATH]
79: : [self::WELL_KNOWN_PATH.$path, self::WELL_KNOWN_PATH];
80: }
81:
82: #[\Override]
83: public function handle(ServerRequestInterface $request): ResponseInterface
84: {
85: // The document describes one MCP server, so it belongs only at the well-known paths RFC 9728 derives
86: // from that server's URL, however many routes the handler is mounted on.
87: if (! \in_array($request->getUri()->getPath(), $this->paths, true)) {
88: return $this->responseFactory->createResponse(HttpStatus::NotFound->value);
89: }
90:
91: if ($request->getMethod() !== 'GET') {
92: return $this->responseFactory->createResponse(HttpStatus::MethodNotAllowed->value)->withHeader('Allow', 'GET');
93: }
94:
95: return $this->responseFactory->createResponse(HttpStatus::Ok->value)
96: ->withHeader('Content-Type', 'application/json')
97: ->withBody($this->streamFactory->createStream(
98: json_encode($this->document->toArray(), \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
99: ))
100: ;
101: }
102: }
103: