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\Schema;
15:
16: use Nexus\Assert\Assert;
17: use Nexus\Mcp\Core\Schema\Arrayable;
18: use Nexus\Mcp\Extension\Skills\Skills;
19:
20: /**
21: * One skill served by a server, as described by `skills/list` and `skills/get`.
22: *
23: * @phpstan-type Frontmatter array{name: non-empty-string, description: non-empty-string, ...<string, mixed>}
24: *
25: * @implements Arrayable<array{
26: * uri: non-empty-string,
27: * frontmatter: Frontmatter,
28: * resources: 'dynamic'|list<template-type<SkillResource, Arrayable, 'T'>>,
29: * }>
30: *
31: * @see https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx
32: */
33: final readonly class Skill implements Arrayable
34: {
35: private const string MANIFEST_SUFFIX = '/'.Skills::MANIFEST_FILENAME;
36:
37: /**
38: * @param non-empty-string $uri URI of the skill's `SKILL.md`
39: * @param Frontmatter $frontmatter The `SKILL.md` YAML frontmatter, every field of it
40: * @param 'dynamic'|list<SkillResource> $resources Every file of the skill, or `dynamic` for one whose content is generated and carries no digests
41: */
42: public function __construct(
43: public string $uri,
44: public array $frontmatter,
45: public array|string $resources,
46: ) {
47: Assert::that($uri)
48: ->isNonEmptyString('skill "uri" must be a non-empty string.')
49: ->endsWith(self::MANIFEST_SUFFIX, 'skill "uri" must name a "SKILL.md", {value} given.')
50: ;
51: Assert::that($frontmatter)
52: ->isMap('skill "frontmatter" must be a string-keyed object.')
53: ->hasOffset('name', 'skill "frontmatter" must carry a "name".')
54: ->hasOffset('description', 'skill "frontmatter" must carry a "description".')
55: ;
56: Assert::that($frontmatter['name'])->isNonEmptyString('skill "frontmatter.name" must be a non-empty string.');
57: Assert::that($frontmatter['description'])->isNonEmptyString('skill "frontmatter.description" must be a non-empty string.');
58:
59: $rootUri = substr($uri, 0, -\strlen(self::MANIFEST_SUFFIX));
60: Assert::that($rootUri)->isNonEmptyString('skill "uri" must name a skill directory before "SKILL.md".');
61:
62: $segments = explode('/', $rootUri);
63: Assert::that(end($segments))->isIdentical(
64: $frontmatter['name'],
65: 'skill "uri" must end its skill path in the "frontmatter.name" {other}, {value} given.',
66: );
67:
68: if (Skills::DYNAMIC_RESOURCES === $resources) {
69: return;
70: }
71:
72: Assert::that($resources)
73: ->isList('skill "resources" must be a list or "dynamic".')
74: ->values()->isInstanceOf(SkillResource::class)
75: ;
76:
77: $seen = [];
78:
79: foreach ($resources as $resource) {
80: Assert::that($resource->uri === $uri || str_starts_with($resource->uri, $rootUri.'/'))->isTrue(\sprintf(
81: 'each skill "resources" entry must name a file under "%s".',
82: $rootUri,
83: ));
84: Assert::that($seen)->not()->hasOffset($resource->uri, 'each skill "resources" entry must name its file once.');
85:
86: $seen[$resource->uri] = $resource;
87: }
88:
89: Assert::that($seen)->hasOffset($uri, 'skill "resources" must list the "SKILL.md" named by the "uri".');
90: }
91:
92: #[\Override]
93: public static function fromArray(array $data): static
94: {
95: Assert::that($data)->hasOffset('uri', 'skill is missing the required "uri" key.');
96: $uri = $data['uri'];
97: Assert::that($uri)->isNonEmptyString('skill "uri" must be a non-empty string, {type} given.');
98:
99: Assert::that($data)->hasOffset('frontmatter', 'skill is missing the required "frontmatter" key.');
100: $frontmatter = $data['frontmatter'];
101: Assert::that($frontmatter)
102: ->isArray('skill "frontmatter" must be an object, {type} given.')
103: ->isMap('skill "frontmatter" must be a string-keyed object.')
104: ->hasOffset('name', 'skill "frontmatter" is missing the required "name" key.')
105: ->hasOffset('description', 'skill "frontmatter" is missing the required "description" key.')
106: ;
107: Assert::that($frontmatter['name'])->isNonEmptyString('skill "frontmatter.name" must be a non-empty string, {type} given.');
108: Assert::that($frontmatter['description'])->isNonEmptyString('skill "frontmatter.description" must be a non-empty string, {type} given.');
109:
110: Assert::that($data)->hasOffset('resources', 'skill is missing the required "resources" key.');
111: $resources = $data['resources'];
112:
113: if (Skills::DYNAMIC_RESOURCES === $resources) {
114: return new self(uri: $uri, frontmatter: $frontmatter, resources: $resources);
115: }
116:
117: Assert::that($resources)
118: ->isList('skill "resources" must be a list or "dynamic", {type} given.')
119: ->values()
120: ->isArray('each skill "resources" entry must be an object, {type} given.')
121: ->isMap('each skill "resources" entry must be a string-keyed object.')
122: ;
123:
124: return new self(uri: $uri, frontmatter: $frontmatter, resources: array_map(SkillResource::fromArray(...), $resources));
125: }
126:
127: #[\Override]
128: public function toArray(): array
129: {
130: return [
131: 'uri' => $this->uri,
132: 'frontmatter' => $this->frontmatter,
133: 'resources' => \is_array($this->resources)
134: ? array_map(static fn(SkillResource $resource): array => $resource->toArray(), $this->resources)
135: : $this->resources,
136: ];
137: }
138:
139: #[\Override]
140: public function jsonSerialize(): array
141: {
142: return $this->toArray();
143: }
144: }
145: