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\Extension\Skills\Server\Store;
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\Resource;
20: use Nexus\Mcp\Core\Schema\Result\ListResourcesResult;
21: use Nexus\Mcp\Core\Schema\Result\ReadResourceResult;
22: use Nexus\Mcp\Extension\Skills\Schema\Result\GetSkillResult;
23: use Nexus\Mcp\Extension\Skills\Schema\Result\ListSkillsResult;
24: use Nexus\Mcp\Extension\Skills\Schema\Result\ReadResourceDirectoryResult;
25: use Nexus\Mcp\Extension\Skills\Schema\Skill;
26: use Nexus\Mcp\Extension\Skills\Server\DirectorySkill;
27: use Nexus\Mcp\Extension\Skills\Server\SkillFile;
28: use Nexus\Mcp\Extension\Skills\Server\SkillProviderInterface;
29: use Nexus\Mcp\Extension\Skills\Skills;
30: use Nexus\Mcp\Server\CursorPaginator;
31: use Nexus\Mcp\Server\Exception\ResourceNotFoundException;
32: use Nexus\Mcp\Server\ServerContext;
33: use Psr\Log\LoggerInterface;
34: use Psr\Log\NullLogger;
35:
36: /**
37: * In-memory implementation of `SkillStoreInterface`.
38: */
39: final readonly class SkillStore implements SkillStoreInterface
40: {
41: private CursorPaginator $paginator;
42:
43: /**
44: * @var array<non-empty-string, Skill>
45: */
46: private array $entries;
47:
48: /**
49: * @var array<non-empty-string, SkillFile>
50: */
51: private array $files;
52:
53: /**
54: * @var array<non-empty-string, Resource>
55: */
56: private array $resources;
57:
58: /**
59: * Children keyed by their own URI, under the URI of the directory that holds them.
60: *
61: * @var array<non-empty-string, array<non-empty-string, Resource>>
62: */
63: private array $directories;
64:
65: /**
66: * @param list<DirectorySkill> $skills The skills enumerated by `skills/list`
67: * @param null|SkillProviderInterface $provider Source of the skills left out of the listing
68: * @param int<1, max> $pageSize
69: * @param int<0, max> $ttlMs
70: */
71: public function __construct(
72: array $skills = [],
73: private ?SkillProviderInterface $provider = null,
74: int $pageSize = CursorPaginator::DEFAULT_PAGE_SIZE,
75: private int $ttlMs = 0,
76: private CacheScope $cacheScope = CacheScope::Private,
77: LoggerInterface $logger = new NullLogger(),
78: ) {
79: Assert::that($skills)
80: ->isList('Skill store skills must be a list, non-list array given.')
81: ->values()->isInstanceOf(DirectorySkill::class)
82: ;
83: Assert::that($pageSize)->isPositiveInt('Skill store page size must be a positive integer, {value} given.');
84: Assert::that($ttlMs)->isNaturalInt('Skill store TTL must be a non-negative integer, {value} given.');
85:
86: $entries = [];
87: $files = [];
88: $resources = [];
89: $directories = [];
90:
91: foreach ($skills as $skill) {
92: $entry = $skill->entry;
93: Assert::that($entries)->not()->hasOffset($entry->uri, \sprintf('Skill "%s" is registered more than once.', $entry->uri));
94:
95: $entries[$entry->uri] = $entry;
96: $bytes = 0;
97:
98: foreach ($skill->files as $uri => $file) {
99: $bytes += \strlen($file->contents);
100:
101: Assert::that(($files[$uri] ?? $file)->contents)->isIdentical(
102: $file->contents,
103: \sprintf('Skill file "%s" is served with different contents by two skills.', $uri),
104: );
105:
106: $files[$uri] = $file;
107:
108: $segments = explode('/', substr($uri, \strlen($skill->rootUri) + 1));
109: $fileName = array_pop($segments);
110: $parentUri = $skill->rootUri;
111:
112: foreach ($segments as $segment) {
113: $directoryUri = $parentUri.'/'.$segment;
114: $directories[$parentUri][$directoryUri] = $this->describe($segment, $directoryUri, Skills::DIRECTORY_MIME_TYPE);
115: $parentUri = $directoryUri;
116: }
117:
118: $resources[$uri] = $uri === $entry->uri
119: ? new Resource(
120: $entry->frontmatter['name'],
121: $uri,
122: description: $entry->frontmatter['description'],
123: mimeType: Skills::MANIFEST_MIME_TYPE,
124: size: \strlen($file->contents),
125: )
126: : $resources[$uri] ?? $this->describe($fileName, $uri, $file->mimeType, \strlen($file->contents));
127: $directories[$parentUri][$uri] = $resources[$uri];
128: }
129:
130: if (\count($skill->files) > Skills::MAX_RESOURCES_PER_SKILL || $bytes > Skills::MAX_BYTES_PER_SKILL) {
131: $logger->warning(
132: 'Skill {uri} exceeds the limits accepted by every host ({files} files, {bytes} bytes), so a host may decline it.',
133: ['uri' => $entry->uri, 'files' => \count($skill->files), 'bytes' => $bytes],
134: );
135: }
136: }
137:
138: $this->entries = $entries;
139: $this->files = $files;
140: $this->resources = $resources;
141: $this->directories = $directories;
142: $this->paginator = new CursorPaginator($pageSize);
143: }
144:
145: #[\Override]
146: public function list(?Cursor $cursor): ListSkillsResult
147: {
148: $page = $this->paginator->paginate($this->entries, $cursor);
149:
150: return new ListSkillsResult($page->entries, $this->ttlMs, $this->cacheScope, $page->nextCursor);
151: }
152:
153: #[\Override]
154: public function get(string $uri, ServerContext $context): GetSkillResult
155: {
156: $entry = $this->entries[$uri]
157: ?? $this->provider?->findSkill($uri, $context)
158: ?? throw new ResourceNotFoundException($uri, $context->requestId);
159:
160: return new GetSkillResult($entry, $this->ttlMs, $this->cacheScope);
161: }
162:
163: #[\Override]
164: public function listResources(?Cursor $cursor): ListResourcesResult
165: {
166: $page = $this->paginator->paginate($this->resources, $cursor);
167:
168: return new ListResourcesResult($page->entries, $this->ttlMs, $this->cacheScope, $page->nextCursor);
169: }
170:
171: #[\Override]
172: public function readFile(string $uri, ServerContext $context): ?ReadResourceResult
173: {
174: $file = $this->files[$uri] ?? (str_starts_with($uri, Skills::URI_PREFIX) ? $this->provider?->findFile($uri, $context) : null);
175:
176: if (null === $file) {
177: return null;
178: }
179:
180: return new ReadResourceResult([$file->toResourceContents()], $this->ttlMs, $this->cacheScope);
181: }
182:
183: #[\Override]
184: public function readDirectory(string $uri, ?Cursor $cursor, ServerContext $context): ReadResourceDirectoryResult
185: {
186: $children = $this->directories[$uri]
187: ?? $this->indexByUri($this->provider?->findDirectory($uri, $context))
188: ?? throw new ResourceNotFoundException($uri, $context->requestId);
189:
190: $page = $this->paginator->paginate($children, $cursor);
191:
192: return new ReadResourceDirectoryResult($page->entries, $page->nextCursor);
193: }
194:
195: /**
196: * @param null|list<Resource> $children
197: *
198: * @return null|array<non-empty-string, Resource>
199: */
200: private function indexByUri(?array $children): ?array
201: {
202: if (null === $children) {
203: return null;
204: }
205:
206: $indexed = [];
207:
208: foreach ($children as $child) {
209: $indexed[$child->uri] = $child;
210: }
211:
212: return $indexed;
213: }
214:
215: /**
216: * @param string $segment The percent-encoded final path segment of the URI
217: * @param non-empty-string $uri
218: * @param null|non-empty-string $mimeType
219: * @param null|int<0, max> $size
220: */
221: private function describe(string $segment, string $uri, ?string $mimeType, ?int $size = null): Resource
222: {
223: return new Resource(rawurldecode($segment), $uri, mimeType: $mimeType, size: $size);
224: }
225: }
226: