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\Resource;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Exception\InvalidParamsException;
18: use Nexus\Mcp\Core\SafeDisplay;
19: use Nexus\Mcp\Core\Schema\Cursor;
20: use Nexus\Mcp\Core\Schema\Enum\CacheScope;
21: use Nexus\Mcp\Core\Schema\Resource\ResourceTemplate;
22: use Nexus\Mcp\Core\Schema\Result\InputRequiredResult;
23: use Nexus\Mcp\Core\Schema\Result\ListResourceTemplatesResult;
24: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
25: use Nexus\Mcp\Core\UriTemplate\Matcher;
26: use Nexus\Mcp\Core\UriTemplate\Validator;
27: use Nexus\Mcp\Core\Validation\IconSrcValidator;
28: use Nexus\Mcp\Core\Validation\IdentifierNameValidator;
29: use Nexus\Mcp\Server\CursorPaginator;
30: use Nexus\Mcp\Server\Exception\ResourceNotFoundException;
31: use Nexus\Mcp\Server\ServerContext;
32:
33: /**
34: * In-memory implementation of `MutableResourceTemplateStoreInterface`.
35: */
36: final class ResourceTemplateStore implements MutableResourceTemplateStoreInterface
37: {
38: private readonly CursorPaginator $paginator;
39:
40: /**
41: * Keyed by entry key so a removal drops the pattern with its entry, ordered by descending literal
42: * length so the most specific template answers first.
43: *
44: * @var array<non-empty-string, array{pattern: non-empty-string, literals: int<0, max>, entry: ResourceTemplateEntry}>
45: */
46: private array $compiled = [];
47:
48: /**
49: * @var list<\Closure(): void>
50: */
51: private array $listChangedListeners = [];
52:
53: /**
54: * @param array<non-empty-string, ResourceTemplateEntry> $entries
55: */
56: public function __construct(
57: private array $entries = [],
58: int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE,
59: private readonly int $ttlMs = 0,
60: private readonly CacheScope $cacheScope = CacheScope::Private,
61: ) {
62: Assert::that($entries)
63: ->keys()
64: ->isNonEmptyString('Resource template store entry key must be a non-empty string.')
65: ;
66:
67: foreach ($entries as $entry) {
68: IdentifierNameValidator::validate($entry->template->name, 'resource template "name"');
69: IconSrcValidator::validate($entry->template->icons, 'resource template');
70: }
71:
72: Assert::that($pageSize)->isPositiveInt('Resource template store page size must be a positive integer, {value} given.');
73: Assert::that($ttlMs)->isNaturalInt('Resource template store TTL must be a non-negative integer, {value} given.');
74:
75: $this->paginator = new CursorPaginator($pageSize);
76:
77: foreach ($entries as $key => $entry) {
78: Assert::that($entry->template->uriTemplate)
79: ->isIdentical($key, 'Resource template store entry key "{other}" must match its template URI "{value}".')
80: ;
81:
82: $this->indexTemplate($key, $entry);
83: }
84: }
85:
86: #[\Override]
87: public function onListChanged(\Closure $listener): void
88: {
89: $this->listChangedListeners[] = $listener;
90: }
91:
92: #[\Override]
93: public function addResourceTemplate(ResourceTemplate $template, TemplatedResourceReaderInterface $reader): void
94: {
95: IdentifierNameValidator::validate($template->name, 'resource template "name"');
96: IconSrcValidator::validate($template->icons, 'resource template');
97:
98: $uriTemplate = $template->uriTemplate;
99: $entry = new ResourceTemplateEntry($template, $reader);
100:
101: // Indexing validates, so it runs first: a rejected template must leave neither map touched.
102: $this->indexTemplate($uriTemplate, $entry);
103: $this->entries[$uriTemplate] = $entry;
104:
105: $this->announceListChange();
106: }
107:
108: #[\Override]
109: public function removeResourceTemplate(string $uriTemplate): bool
110: {
111: if (! \array_key_exists($uriTemplate, $this->entries)) {
112: return false;
113: }
114:
115: unset($this->entries[$uriTemplate], $this->compiled[$uriTemplate]);
116:
117: $this->announceListChange();
118:
119: return true;
120: }
121:
122: #[\Override]
123: public function list(?Cursor $cursor): ListResourceTemplatesResult
124: {
125: $page = $this->paginator->paginate($this->entries, $cursor);
126:
127: return new ListResourceTemplatesResult(
128: resourceTemplates: array_map(
129: static fn(ResourceTemplateEntry $entry): ResourceTemplate => $entry->template,
130: $page->entries,
131: ),
132: ttlMs: $this->ttlMs,
133: cacheScope: $this->cacheScope,
134: nextCursor: $page->nextCursor,
135: );
136: }
137:
138: #[\Override]
139: public function read(string $uri, ServerContext $context): InputRequiredResult|ReadResourceResult
140: {
141: foreach ($this->compiled as ['pattern' => $pattern, 'entry' => $entry]) {
142: $bindings = Matcher::matchCompiled($pattern, $uri);
143:
144: if (null !== $bindings) {
145: try {
146: return $entry->reader->read($uri, $bindings, $context);
147: } catch (InvalidParamsException $e) {
148: throw new InvalidParamsException(
149: $context->requestId,
150: SafeDisplay::sanitiseCause(\sprintf('Invalid arguments for resource "%s": %s', $uri, $e->getMessage())),
151: );
152: }
153: }
154: }
155:
156: throw new ResourceNotFoundException($uri, $context->requestId);
157: }
158:
159: /**
160: * @param non-empty-string $uriTemplate
161: */
162: private function indexTemplate(string $uriTemplate, ResourceTemplateEntry $entry): void
163: {
164: Validator::validate($uriTemplate, 'ResourceTemplate');
165: $this->compiled[$uriTemplate] = [
166: 'pattern' => Matcher::compile($uriTemplate),
167: 'literals' => \strlen((string) preg_replace('/\{[A-Za-z_][A-Za-z0-9_]*\}/', '', $uriTemplate)),
168: 'entry' => $entry,
169: ];
170: uasort($this->compiled, static fn(array $a, array $b): int => $b['literals'] <=> $a['literals']);
171: }
172:
173: private function announceListChange(): void
174: {
175: foreach ($this->listChangedListeners as $listener) {
176: $listener();
177: }
178: }
179: }
180: