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\Core\Auth;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\SafeDisplay;
18:
19: /**
20: * The canonical URI of an MCP server, as carried by the OAuth `resource` parameter.
21: *
22: * @see https://www.rfc-editor.org/rfc/rfc8707.html#section-2
23: * @see https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization#canonical-server-uri
24: */
25: final readonly class ResourceIdentifier
26: {
27: public string $value;
28: public string $origin;
29: private string $path;
30:
31: public function __construct(string $uri)
32: {
33: $canonical = $this->canonicalise($uri);
34:
35: Assert::that($canonical)->isArray(\sprintf(
36: 'The MCP server resource identifier must be an absolute URI carrying no fragment or userinfo, "%s" given.',
37: SafeDisplay::sanitiseCause($uri),
38: ));
39:
40: $this->value = $canonical[0];
41: $this->origin = $canonical[1];
42: $this->path = $canonical[2];
43: }
44:
45: public function sharesOriginWith(string $uri): bool
46: {
47: $canonical = $this->canonicalise($uri);
48:
49: return null !== $canonical && $canonical[1] === $this->origin;
50: }
51:
52: /**
53: * Whether a token minted for this resource may be presented to `$uri`: the resource itself or
54: * anything under its path. A path a server could normalise out of the subtree (dot segments,
55: * percent-encoded dots, slashes, or backslashes) is never covered.
56: */
57: public function covers(string $uri): bool
58: {
59: $canonical = $this->canonicalise($uri);
60:
61: if (null === $canonical || $canonical[1] !== $this->origin) {
62: return false;
63: }
64:
65: if (preg_match('~(?:^|/)\.{1,2}(?:/|\z)|%2e|%2f|%5c|\\\\~i', $canonical[2]) === 1) {
66: return false;
67: }
68:
69: $base = rtrim($this->path, '/');
70:
71: return $canonical[2] === $base || str_starts_with($canonical[2], $base.'/');
72: }
73:
74: /**
75: * @param list<string> $audience
76: */
77: public function matchesAudience(array $audience): bool
78: {
79: foreach ($audience as $value) {
80: $canonical = $this->canonicalise($value);
81:
82: if (null !== $canonical && $canonical[0] === $this->value) {
83: return true;
84: }
85: }
86:
87: return false;
88: }
89:
90: /**
91: * The canonical identifier, its origin, and its path, or `null` when the URI is not a usable
92: * resource identifier.
93: *
94: * @return null|array{string, string, string}
95: */
96: private function canonicalise(string $uri): ?array
97: {
98: $parts = parse_url($uri);
99:
100: if (false === $parts || ! isset($parts['scheme'], $parts['host']) || isset($parts['fragment'])) {
101: return null;
102: }
103:
104: if (isset($parts['user']) || isset($parts['pass'])) {
105: return null;
106: }
107:
108: $scheme = strtolower($parts['scheme']);
109: $host = strtolower($parts['host']);
110: $path = $parts['path'] ?? '';
111: $origin = \sprintf('%s://%s%s', $scheme, $host, $this->renderNonDefaultPort($scheme, $parts['port'] ?? null));
112:
113: $path = '/' === $path ? '' : $path;
114:
115: return [
116: $origin.$path.(isset($parts['query']) ? '?'.$parts['query'] : ''),
117: $origin,
118: $path,
119: ];
120: }
121:
122: private function renderNonDefaultPort(string $scheme, ?int $port): string
123: {
124: $default = match ($scheme) {
125: 'https' => 443,
126: 'http' => 80,
127: default => null,
128: };
129:
130: return null === $port || $port === $default ? '' : ':'.$port;
131: }
132: }
133: