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