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;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Extension\Skills\Frontmatter;
18: use Nexus\Mcp\Extension\Skills\Schema\Skill;
19: use Nexus\Mcp\Extension\Skills\Schema\SkillResource;
20: use Nexus\Mcp\Extension\Skills\Skills;
21:
22: /**
23: * A skill read from a directory on disk, holding the bytes of every file as they stood when it was built.
24: */
25: final readonly class DirectorySkill
26: {
27: private const string PATH_PATTERN = '/\A(?:[A-Za-z0-9][A-Za-z0-9._~-]*\/)*(?=[^\/]{1,64}\z)[a-z0-9]+(?:-[a-z0-9]+)*\z/';
28: private const array MIME_TYPES = [
29: 'css' => 'text/css',
30: 'csv' => 'text/csv',
31: 'gif' => 'image/gif',
32: 'htm' => 'text/html',
33: 'html' => 'text/html',
34: 'jpeg' => 'image/jpeg',
35: 'jpg' => 'image/jpeg',
36: 'js' => 'text/javascript',
37: 'json' => 'application/json',
38: 'md' => 'text/markdown',
39: 'pdf' => 'application/pdf',
40: 'png' => 'image/png',
41: 'py' => 'text/x-python',
42: 'sh' => 'application/x-sh',
43: 'svg' => 'image/svg+xml',
44: 'txt' => 'text/plain',
45: 'xml' => 'application/xml',
46: 'yaml' => 'application/yaml',
47: 'yml' => 'application/yaml',
48: ];
49:
50: public Skill $entry;
51:
52: /**
53: * URI of the skill's directory, the entry's `uri` without its `/SKILL.md` suffix.
54: *
55: * @var non-empty-string
56: */
57: public string $rootUri;
58:
59: /**
60: * Keyed by URI, the `SKILL.md` first.
61: *
62: * @var non-empty-array<non-empty-string, SkillFile>
63: */
64: public array $files;
65:
66: /**
67: * @param non-empty-string $path One or more `/`-separated segments naming the skill, the last being its lowercase, hyphenated `name`
68: * @param non-empty-string $directory Directory that holds the skill's `SKILL.md`
69: *
70: * @throws \InvalidArgumentException
71: */
72: public function __construct(string $path, string $directory)
73: {
74: Assert::that($path)->matchesRegularExpression(
75: self::PATH_PATTERN,
76: 'Skill path must be "/"-separated URI-safe segments ending in a valid skill name, {value} given.',
77: );
78:
79: Assert::that(is_dir($directory))->isTrue(\sprintf('Skill directory "%s" does not exist.', $directory));
80:
81: $rootUri = Skills::URI_PREFIX.$path;
82: $manifestUri = $rootUri.'/'.Skills::MANIFEST_FILENAME;
83: $supportingFiles = $this->listFiles($directory, $rootUri);
84: Assert::that($supportingFiles)->hasOffset($manifestUri, \sprintf('Skill directory "%s" holds no "SKILL.md".', $directory));
85:
86: $manifestFile = $this->loadFile($manifestUri, $supportingFiles[$manifestUri]);
87: $files = [$manifestUri => $manifestFile];
88: unset($supportingFiles[$manifestUri]);
89: ksort($supportingFiles, \SORT_STRING);
90:
91: foreach ($supportingFiles as $uri => $filePath) {
92: $files[$uri] = $this->loadFile($uri, $filePath);
93: }
94:
95: $resources = [];
96:
97: foreach ($files as $file) {
98: $resources[] = new SkillResource($file->uri, SkillResource::computeDigest($file->contents), \strlen($file->contents));
99: }
100:
101: $this->rootUri = $rootUri;
102: $this->files = $files;
103: $this->entry = new Skill($manifestUri, Frontmatter::parse($manifestFile->contents), $resources);
104: }
105:
106: /**
107: * @param non-empty-string $uri
108: */
109: private function loadFile(string $uri, string $path): SkillFile
110: {
111: $contents = @file_get_contents($path);
112: Assert::that($contents)->isString(\sprintf('Skill file "%s" could not be read.', $path));
113:
114: return new SkillFile(
115: $uri,
116: $contents,
117: self::MIME_TYPES[strtolower(pathinfo($path, \PATHINFO_EXTENSION))] ?? 'application/octet-stream',
118: );
119: }
120:
121: /**
122: * The path of every regular file under the directory, keyed by the file's URI, with links and
123: * dot-prefixed entries left out along with everything beneath them.
124: *
125: * @param non-empty-string $rootUri
126: *
127: * @return array<non-empty-string, string>
128: */
129: private function listFiles(string $directory, string $rootUri): array
130: {
131: $paths = [];
132: $iterator = new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator(
133: new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::CURRENT_AS_SELF),
134: static fn(\SplFileInfo $entry): bool => ! $entry->isLink() && ! str_starts_with($entry->getFilename(), '.'),
135: ));
136:
137: foreach ($iterator as $file) {
138: \assert($file instanceof \RecursiveDirectoryIterator);
139:
140: if ($file->isFile()) {
141: $segments = array_map(rawurlencode(...), explode(\DIRECTORY_SEPARATOR, $file->getSubPathname()));
142: $paths[$rootUri.'/'.implode('/', $segments)] = $file->getPathname();
143: }
144: }
145:
146: return $paths;
147: }
148: }
149: