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 at `/.well-known/oauth-protected-resource{/path}`
28: * and its root form, answering `404` on any other path.
29: *
30: * @see https://datatracker.ietf.org/doc/html/rfc9728#section-3
31: */
32: final readonly class ProtectedResourceMetadataHandler implements RequestHandlerInterface
33: {
34: private ProtectedResourceMetadata $document;
35:
36: /**
37: * The request paths this document belongs at, path-scoped before root.
38: *
39: * @var list<string>
40: */
41: private array $paths;
42:
43: /**
44: * @param string $resource Canonical URI of this MCP server
45: * @param list<non-empty-string> $authorizationServers Issuers that mint tokens for it, at least one
46: * @param list<non-empty-string> $scopesSupported
47: * @param null|non-empty-string $resourceName
48: */
49: public function __construct(
50: string $resource,
51: array $authorizationServers,
52: private ResponseFactoryInterface $responseFactory,
53: private StreamFactoryInterface $streamFactory,
54: array $scopesSupported = [],
55: ?string $resourceName = null,
56: ) {
57: $identifier = new ResourceIdentifier($resource);
58: $this->document = new ProtectedResourceMetadata(
59: $identifier,
60: $authorizationServers,
61: [] === $scopesSupported ? null : new ScopeSet($scopesSupported),
62: ['header'],
63: $resourceName,
64: );
65:
66: $path = rtrim((string) parse_url($identifier->value, \PHP_URL_PATH), '/');
67: $this->paths = '' === $path
68: ? ['/.well-known/oauth-protected-resource']
69: : ['/.well-known/oauth-protected-resource'.$path, '/.well-known/oauth-protected-resource'];
70: }
71:
72: #[\Override]
73: public function handle(ServerRequestInterface $request): ResponseInterface
74: {
75: // The document describes one MCP server, so it belongs only at the well-known paths RFC 9728 derives from that server's URL.
76: if (! \in_array($request->getUri()->getPath(), $this->paths, true)) {
77: return $this->responseFactory->createResponse(HttpStatus::NotFound->value);
78: }
79:
80: if ($request->getMethod() !== 'GET') {
81: return $this->responseFactory->createResponse(HttpStatus::MethodNotAllowed->value)->withHeader('Allow', 'GET');
82: }
83:
84: return $this->responseFactory->createResponse(HttpStatus::Ok->value)
85: ->withHeader('Content-Type', 'application/json')
86: ->withBody($this->streamFactory->createStream(
87: json_encode($this->document->toArray(), \JSON_THROW_ON_ERROR | \JSON_UNESCAPED_SLASHES),
88: ))
89: ;
90: }
91: }
92: