| 1: | <?php |
| 2: | |
| 3: | declare(strict_types=1); |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 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: | |
| 38: | |
| 39: | final readonly class SkillStore implements SkillStoreInterface |
| 40: | { |
| 41: | private CursorPaginator $paginator; |
| 42: | |
| 43: | |
| 44: | |
| 45: | |
| 46: | private array $entries; |
| 47: | |
| 48: | |
| 49: | |
| 50: | |
| 51: | private array $files; |
| 52: | |
| 53: | |
| 54: | |
| 55: | |
| 56: | private array $resources; |
| 57: | |
| 58: | |
| 59: | |
| 60: | |
| 61: | |
| 62: | |
| 63: | private array $directories; |
| 64: | |
| 65: | |
| 66: | |
| 67: | |
| 68: | |
| 69: | |
| 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: | |
| 197: | |
| 198: | |
| 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: | |
| 217: | |
| 218: | |
| 219: | |
| 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: | |