diff --git a/composer.json b/composer.json index b883603b..3c1eac42 100644 --- a/composer.json +++ b/composer.json @@ -32,7 +32,8 @@ "psr/http-server-handler": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0" + "symfony/uid": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/yaml": "^5.4 || ^6.4 || ^7.3 || ^8.0" }, "suggest": { "symfony/finder": "Required for file-based discovery." @@ -80,6 +81,7 @@ "Mcp\\Example\\Server\\OAuthKeycloak\\": "examples/server/oauth-keycloak/", "Mcp\\Example\\Server\\OAuthMicrosoft\\": "examples/server/oauth-microsoft/", "Mcp\\Example\\Server\\SchemaShowcase\\": "examples/server/schema-showcase/", + "Mcp\\Example\\Server\\Skills\\": "examples/server/skills/", "Mcp\\Tests\\": "tests/" }, "classmap": [ diff --git a/docs/examples.md b/docs/examples.md index 61b788e9..51cd5f10 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -367,6 +367,18 @@ and calls back into the server. See the [ext-apps repo](https://github.com/modelcontextprotocol/ext-apps) for the TypeScript SDK and richer view-side patterns. +### Skills + +**File**: `examples/server/skills/` + +A directory of skills exposed through the [Skills extension](extensions.md) +(SEP-2640). `addSkillsFromDirectory()` registers each `SKILL.md` (and its +supporting files) as a `skill://` resource, derives `name`/`description` from the +YAML frontmatter, and serves a `skill://index.json` discovery index. Demonstrates +flat (`skill://code-review/SKILL.md`), nested +(`skill://acme/billing/refunds/SKILL.md`), and supporting-file +(`skill://code-review/references/SECURITY.md`) URIs. + ## Client Examples ### STDIO Discovery Calculator (Client) diff --git a/docs/extensions.md b/docs/extensions.md index f1817026..f8f1a56d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -107,4 +107,83 @@ TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in [`examples/server/mcp-apps/weather-app.html`](../examples/server/mcp-apps/weather-app.html). +## Skills (`io.modelcontextprotocol/skills`) + +The [Skills extension][ext-skills] (SEP-2640) lets servers ship **skills** — +multi-step workflow instructions that tell an agent *how to orchestrate* tools to +reach a goal. Skills are served through the existing **Resources** primitive with +zero protocol changes: each skill is a `skill:///SKILL.md` resource +(plus any supporting files), and the server advertises an empty +`io.modelcontextprotocol/skills` capability. + +The simplest way to expose a directory of skills is `addSkillsFromDirectory()`, +which auto-enables the extension and registers every skill it finds: + +```php +use Mcp\Server; + +$server = Server::builder() + ->setServerInfo('My Server', '1.0.0') + ->addSkillsFromDirectory(__DIR__.'/skills') + ->build(); +``` + +Given this layout, the following `skill://` resources are registered: + +``` +skills/ +├── code-review/ +│ ├── SKILL.md → skill://code-review/SKILL.md +│ └── references/SECURITY.md → skill://code-review/references/SECURITY.md +└── acme/billing/refunds/ + └── SKILL.md → skill://acme/billing/refunds/SKILL.md +``` + +Each `SKILL.md` is served as `text/markdown`. Its YAML frontmatter supplies the +resource `name`/`description`; any remaining frontmatter keys are exposed under the +`io.modelcontextprotocol.skills/` `_meta` namespace. Supporting files are served +with a MIME type guessed from their extension/content. + +```yaml +--- +name: code-review +description: Review a pull request for correctness, security, and style. +version: 1.0.0 +tags: [review, quality] +--- + +# Code Review +... +``` + +> The frontmatter `name` **must** equal the final segment of the skill's directory +> path (`code-review/` → `name: code-review`); a mismatch throws an +> `InvalidArgumentException`. + +By default a discovery index is also served at `skill://index.json` (an +[Agent Skills][agent-skills] discovery document listing every skill). Skills also +appear as normal entries in `resources/list`, so a large skill tree pages via +`resources/list` cursors. Pass `withDiscoveryIndex: false` to skip the index. + +Parsing `SKILL.md` frontmatter requires the [`symfony/yaml`][symfony-yaml] +component, which is a dependency of this SDK. + +### Server-side classes + +| Class | Purpose | +| --- | --- | +| `McpSkills` | Extension marker; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME`, `ENTRY_POINT`, `DISCOVERY_URI`, `META_PREFIX` constants. | +| `SkillProvider` | Walks a directory and registers each skill (and its files) as `skill://` resources. | +| `FrontmatterParser` | Splits a `SKILL.md` into its YAML frontmatter and markdown body. | +| `SkillMetadata` | Value object for parsed frontmatter: `name`, `description`, `extra`. | +| `SkillDiscoveryIndex` | The `skill://index.json` document: `$schema` + `skills`. | +| `SkillDiscoveryEntry` | One index entry: `name`, `type`, `url`, `description`. | +| `SkillType` | Enum: `SkillMd` (`skill-md`), `McpResourceTemplate` (`mcp-resource-template`). | + +A complete example lives in +[`examples/server/skills/`](../examples/server/skills/). + [ext-apps]: https://github.com/modelcontextprotocol/ext-apps +[ext-skills]: https://github.com/modelcontextprotocol/experimental-ext-skills +[agent-skills]: https://agentskills.io +[symfony-yaml]: https://symfony.com/doc/current/components/yaml.html diff --git a/examples/server/skills/README.md b/examples/server/skills/README.md new file mode 100644 index 00000000..2dd0aff1 --- /dev/null +++ b/examples/server/skills/README.md @@ -0,0 +1,45 @@ +# MCP Skills Example + +Demonstrates the **Skills extension** (`io.modelcontextprotocol/skills`, SEP-2640): serving +multi-step workflow instructions ("skills") to clients through the existing MCP **Resources** +primitive, with zero protocol changes. + +## Running + +```bash +php examples/server/skills/server.php +``` + +A single call exposes the whole `skills/` directory: + +```php +Server::builder() + ->setServerInfo('MCP Skills Example', '1.0.0') + ->addSkillsFromDirectory(__DIR__.'/skills') + ->build(); +``` + +This auto-enables the `McpSkills` extension and registers every `SKILL.md` (plus supporting +files) as a `skill://` resource. + +## Layout & URIs + +``` +skills/ +├── code-review/ +│ ├── SKILL.md → skill://code-review/SKILL.md +│ └── references/SECURITY.md → skill://code-review/references/SECURITY.md +└── acme/billing/refunds/ + └── SKILL.md → skill://acme/billing/refunds/SKILL.md +``` + +Plus a discovery index at `skill://index.json` listing every skill. + +## Conventions + +- A skill is any folder containing a `SKILL.md`. Its frontmatter `name` **must** equal the final + segment of the folder path (e.g. `code-review` → `name: code-review`). +- `name`/`description` come from the SKILL.md YAML frontmatter; any extra frontmatter is exposed + under the `io.modelcontextprotocol.skills/` `_meta` namespace. +- Supporting files are served with a MIME type guessed from their extension/content. +- Skills are plain files — no PHP handler class is required. diff --git a/examples/server/skills/server.php b/examples/server/skills/server.php new file mode 100644 index 00000000..5ff340f0 --- /dev/null +++ b/examples/server/skills/server.php @@ -0,0 +1,30 @@ +#!/usr/bin/env php +info('Starting MCP Skills Example Server...'); + +$server = Server::builder() + ->setServerInfo('MCP Skills Example', '1.0.0') + ->setLogger(logger()) + ->addSkillsFromDirectory(__DIR__.'/skills') + ->build(); + +$result = $server->run(transport()); + +logger()->info('Server stopped gracefully.', ['result' => $result]); + +shutdown($result); diff --git a/examples/server/skills/skills/acme/billing/refunds/SKILL.md b/examples/server/skills/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 00000000..5e457933 --- /dev/null +++ b/examples/server/skills/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,25 @@ +--- +name: refunds +description: Process a customer refund following Acme's billing policy and approval thresholds. +version: 1.0.0 +tags: + - billing + - support +--- + +# Processing Refunds + +A nested skill demonstrating multi-segment skill paths (`skill://acme/billing/refunds/SKILL.md`). + +## Policy + +1. Verify the charge exists and has not already been refunded. +2. Refunds up to $100 may be issued directly. +3. Refunds above $100 require a team lead's approval before issuing. + +## Steps + +1. Look up the original charge by order ID. +2. Confirm the refund amount does not exceed the charged amount. +3. Issue the refund and record the reason code. +4. Notify the customer with the expected settlement window. diff --git a/examples/server/skills/skills/code-review/SKILL.md b/examples/server/skills/skills/code-review/SKILL.md new file mode 100644 index 00000000..b0112098 --- /dev/null +++ b/examples/server/skills/skills/code-review/SKILL.md @@ -0,0 +1,38 @@ +--- +name: code-review +description: Review a pull request for correctness, security, and style following this team's conventions. +version: 1.0.0 +tags: + - review + - quality +--- + +# Code Review + +Follow these steps to review a pull request thoroughly and consistently. + +## 1. Understand the change + +- Read the PR description and linked issue to understand the intended behavior. +- Skim the diff top to bottom before commenting to build a mental model. + +## 2. Correctness + +- Check edge cases: empty input, nulls, boundary values, concurrency. +- Verify error handling fails fast and preserves context. +- Confirm tests cover the new behavior and actually assert on it. + +## 3. Security + +- See `references/SECURITY.md` for the security checklist that MUST be applied to + every change touching authentication, input parsing, or external I/O. + +## 4. Style & maintainability + +- Match the surrounding code's naming, structure, and comment density. +- Prefer the simplest implementation that satisfies the requirement. + +## 5. Wrap up + +- Summarize findings grouped by severity (blocking, suggestion, nit). +- Approve only when blocking issues are resolved and CI is green. diff --git a/examples/server/skills/skills/code-review/references/SECURITY.md b/examples/server/skills/skills/code-review/references/SECURITY.md new file mode 100644 index 00000000..de9ef343 --- /dev/null +++ b/examples/server/skills/skills/code-review/references/SECURITY.md @@ -0,0 +1,10 @@ +# Security Review Checklist + +Apply this checklist to every change that touches authentication, input parsing, or external I/O. + +- **Input validation**: All external input is validated and normalized before use. +- **Injection**: Queries, shell commands, and templates use parameterization — never string concatenation. +- **AuthZ**: Every privileged action re-checks the caller's authorization server-side. +- **Secrets**: No credentials, tokens, or keys are logged or committed. +- **Output encoding**: Data rendered into HTML, URLs, or headers is contextually encoded. +- **Dependencies**: New dependencies are pinned and free of known advisories. diff --git a/src/Schema/Extension/Skills/McpSkills.php b/src/Schema/Extension/Skills/McpSkills.php new file mode 100644 index 00000000..01e93ef5 --- /dev/null +++ b/src/Schema/Extension/Skills/McpSkills.php @@ -0,0 +1,59 @@ +/SKILL.md` resource (plus + * any supporting files), and the server advertises this extension during capability negotiation. + * + * Enable on the server via {@see \Mcp\Server\Builder::enableExtension()}, or use the + * {@see \Mcp\Server\Builder::addSkillsFromDirectory()} convenience to expose a directory of skills. + * + * @see https://github.com/modelcontextprotocol/experimental-ext-skills + * + * @author Johannes Wachter + */ +final class McpSkills implements ServerExtensionInterface +{ + public const EXTENSION_ID = 'io.modelcontextprotocol/skills'; + public const MIME_TYPE = 'text/markdown'; + public const URI_SCHEME = 'skill'; + public const ENTRY_POINT = 'SKILL.md'; + public const DISCOVERY_URI = 'skill://index.json'; + + /** + * The (not-yet-standardized) `_meta` namespace prefix under which extra SKILL.md frontmatter + * fields are exposed on a skill resource descriptor. + */ + public const META_PREFIX = 'io.modelcontextprotocol.skills/'; + + public function getId(): string + { + return self::EXTENSION_ID; + } + + /** + * The Skills extension advertises an empty capability payload (`{}`). + * + * @return array + */ + public function getCapabilities(): array + { + return []; + } +} diff --git a/src/Schema/Extension/Skills/SkillDiscoveryEntry.php b/src/Schema/Extension/Skills/SkillDiscoveryEntry.php new file mode 100644 index 00000000..da6003dc --- /dev/null +++ b/src/Schema/Extension/Skills/SkillDiscoveryEntry.php @@ -0,0 +1,72 @@ + + */ +final class SkillDiscoveryEntry implements \JsonSerializable +{ + public function __construct( + public readonly string $name, + public readonly SkillType $type, + public readonly string $url, + public readonly ?string $description = null, + ) { + } + + /** + * @param SkillDiscoveryEntryData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('Invalid or missing "name" in skill discovery entry.'); + } + if (empty($data['type']) || !\is_string($data['type'])) { + throw new InvalidArgumentException('Invalid or missing "type" in skill discovery entry.'); + } + if (empty($data['url']) || !\is_string($data['url'])) { + throw new InvalidArgumentException('Invalid or missing "url" in skill discovery entry.'); + } + + return new self( + name: $data['name'], + type: SkillType::from($data['type']), + url: $data['url'], + description: isset($data['description']) && \is_string($data['description']) ? $data['description'] : null, + ); + } + + /** + * @return SkillDiscoveryEntryData + */ + public function jsonSerialize(): array + { + $data = [ + 'name' => $this->name, + 'type' => $this->type->value, + ]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + $data['url'] = $this->url; + + return $data; + } +} diff --git a/src/Schema/Extension/Skills/SkillDiscoveryIndex.php b/src/Schema/Extension/Skills/SkillDiscoveryIndex.php new file mode 100644 index 00000000..f357e88e --- /dev/null +++ b/src/Schema/Extension/Skills/SkillDiscoveryIndex.php @@ -0,0 +1,62 @@ + + */ +final class SkillDiscoveryIndex implements \JsonSerializable +{ + public const SCHEMA_URL = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json'; + + /** + * @param SkillDiscoveryEntry[] $skills + */ + public function __construct( + public readonly array $skills, + public readonly string $schema = self::SCHEMA_URL, + ) { + } + + /** + * @param SkillDiscoveryIndexData $data + */ + public static function fromArray(array $data): self + { + $skills = []; + foreach ($data['skills'] ?? [] as $entry) { + $skills[] = SkillDiscoveryEntry::fromArray($entry); + } + + return new self( + skills: $skills, + schema: isset($data['$schema']) && \is_string($data['$schema']) ? $data['$schema'] : self::SCHEMA_URL, + ); + } + + /** + * @return array{'$schema': string, skills: SkillDiscoveryEntry[]} + */ + public function jsonSerialize(): array + { + return [ + '$schema' => $this->schema, + 'skills' => $this->skills, + ]; + } +} diff --git a/src/Schema/Extension/Skills/SkillMetadata.php b/src/Schema/Extension/Skills/SkillMetadata.php new file mode 100644 index 00000000..99bc9eef --- /dev/null +++ b/src/Schema/Extension/Skills/SkillMetadata.php @@ -0,0 +1,72 @@ + + */ +final class SkillMetadata implements \JsonSerializable +{ + /** + * @param array $extra additional frontmatter fields (everything but name/description) + */ + public function __construct( + public readonly string $name, + public readonly ?string $description = null, + public readonly array $extra = [], + ) { + } + + /** + * @param array $data the raw frontmatter mapping + */ + public static function fromArray(array $data): self + { + if (empty($data['name']) || !\is_string($data['name'])) { + throw new InvalidArgumentException('SKILL.md frontmatter must contain a non-empty string "name".'); + } + + $description = isset($data['description']) && \is_string($data['description']) ? $data['description'] : null; + + $extra = $data; + unset($extra['name'], $extra['description']); + + return new self( + name: $data['name'], + description: $description, + extra: $extra, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + $data = ['name' => $this->name]; + if (null !== $this->description) { + $data['description'] = $this->description; + } + + return [...$data, ...$this->extra]; + } +} diff --git a/src/Schema/Extension/Skills/SkillType.php b/src/Schema/Extension/Skills/SkillType.php new file mode 100644 index 00000000..20f3aa64 --- /dev/null +++ b/src/Schema/Extension/Skills/SkillType.php @@ -0,0 +1,30 @@ + + */ +enum SkillType: string +{ + /** + * A concrete skill backed by a `SKILL.md` resource. + */ + case SkillMd = 'skill-md'; + + /** + * A parameterized skill namespace backed by an MCP resource template. + */ + case McpResourceTemplate = 'mcp-resource-template'; +} diff --git a/src/Schema/ServerCapabilities.php b/src/Schema/ServerCapabilities.php index 0e47c61d..9ac2096e 100644 --- a/src/Schema/ServerCapabilities.php +++ b/src/Schema/ServerCapabilities.php @@ -189,7 +189,12 @@ public function jsonSerialize(): array } if ($this->extensions) { - $data['extensions'] = (object) $this->extensions; + // An extension MAY advertise an empty capability payload (e.g. `io.modelcontextprotocol/skills`). + // Coerce empty inner arrays to objects so they serialize to `{}` rather than `[]`. + $data['extensions'] = (object) array_map( + static fn (mixed $capabilities): mixed => [] === $capabilities ? new \stdClass() : $capabilities, + $this->extensions, + ); } return $data; diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 198a3497..a8f44625 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -33,6 +33,7 @@ use Mcp\Schema\Annotations; use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\ServerExtensionInterface; +use Mcp\Schema\Extension\Skills\McpSkills; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -55,6 +56,7 @@ use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Skill\SkillProvider; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; @@ -291,6 +293,26 @@ public function enableExtension(ServerExtensionInterface ...$extensions): self return $this; } + /** + * Expose a directory of skills (SEP-2640) as `skill://` resources. + * + * Enables the {@see McpSkills} extension (unless already enabled) and registers every + * `SKILL.md` found under $directory — together with its supporting files — as resources. + * When $withDiscoveryIndex is true, a `skill://index.json` discovery resource is served too. + * + * @see SkillProvider + */ + public function addSkillsFromDirectory(string $directory, bool $withDiscoveryIndex = true, ?SkillProvider $provider = null): self + { + if (!isset($this->extensions[McpSkills::EXTENSION_ID])) { + $this->enableExtension(new McpSkills()); + } + + ($provider ?? new SkillProvider())->registerInto($this, $directory, $withDiscoveryIndex); + + return $this; + } + /** * Register a single custom method handler. * diff --git a/src/Server/Skill/FrontmatterParser.php b/src/Server/Skill/FrontmatterParser.php new file mode 100644 index 00000000..a3b499df --- /dev/null +++ b/src/Server/Skill/FrontmatterParser.php @@ -0,0 +1,64 @@ + + */ +final class FrontmatterParser +{ + /** + * Splits a `SKILL.md` document into its frontmatter mapping and the remaining markdown body. + * + * A document without a leading `---` delimited block is treated as having empty frontmatter. + * + * @return array{0: array, 1: string} the [frontmatter, body] pair + * + * @throws RuntimeException if symfony/yaml is not installed + * @throws InvalidArgumentException if the frontmatter is present but is not a YAML mapping + */ + public function parse(string $content): array + { + if (!preg_match('/^(?:\xEF\xBB\xBF)?---\R(.*?)\R---\R?(.*)$/s', $content, $matches)) { + return [[], $content]; + } + + if (!class_exists(Yaml::class)) { + throw new RuntimeException('Parsing SKILL.md frontmatter requires the "symfony/yaml" component. Run: composer require symfony/yaml'); + } + + $data = Yaml::parse($matches[1]) ?? []; + if (!\is_array($data) || ([] !== $data && array_is_list($data))) { + throw new InvalidArgumentException('SKILL.md frontmatter must be a YAML mapping.'); + } + + /* @var array $data */ + return [$data, $matches[2]]; + } + + /** + * Parses the frontmatter of a `SKILL.md` document into a {@see SkillMetadata} value object. + */ + public function parseMetadata(string $content): SkillMetadata + { + [$frontmatter] = $this->parse($content); + + return SkillMetadata::fromArray($frontmatter); + } +} diff --git a/src/Server/Skill/SkillProvider.php b/src/Server/Skill/SkillProvider.php new file mode 100644 index 00000000..d11a8c88 --- /dev/null +++ b/src/Server/Skill/SkillProvider.php @@ -0,0 +1,262 @@ + + */ +final class SkillProvider +{ + /** + * @var array used to keep generated resource names unique + */ + private array $usedNames = []; + + public function __construct( + private readonly FrontmatterParser $frontmatter = new FrontmatterParser(), + ) { + } + + /** + * Walks $baseDirectory and registers every discovered skill (and its supporting files) as + * `skill://` resources on $builder, optionally serving a `skill://index.json` discovery index. + * + * @return SkillDiscoveryEntry[] the discovered skills + * + * @throws InvalidArgumentException if the directory is missing, or a skill violates the spec + */ + public function registerInto(Builder $builder, string $baseDirectory, bool $withDiscoveryIndex = true): array + { + $base = realpath($baseDirectory); + if (false === $base || !is_dir($base)) { + throw new InvalidArgumentException(\sprintf('Skills directory "%s" does not exist or is not a directory.', $baseDirectory)); + } + + $this->usedNames = []; + $entries = []; + + foreach ($this->findSkillManifests($base) as $manifestPath) { + $entries[] = $this->registerSkill($builder, $base, $manifestPath); + } + + if ($withDiscoveryIndex) { + // Return the serialized array (not the DTO) so ResourceResultFormatter JSON-encodes it. + $index = (new SkillDiscoveryIndex($entries))->jsonSerialize(); + $builder->addResource( + static fn (): array => $index, + McpSkills::DISCOVERY_URI, + name: 'skills-index', + title: 'Skills discovery index', + description: 'Agent Skills discovery index of all skills served by this server.', + mimeType: 'application/json', + ); + } + + return $entries; + } + + private function registerSkill(Builder $builder, string $base, string $manifestPath): SkillDiscoveryEntry + { + $skillDir = \dirname($manifestPath); + $skillPath = $this->relativePath($base, $skillDir); + + $metadata = $this->frontmatter->parseMetadata((string) file_get_contents($manifestPath)); + + $lastSegment = basename($skillPath); + if ($lastSegment !== $metadata->name) { + throw new InvalidArgumentException(\sprintf('Skill at "%s": frontmatter name "%s" must match the final path segment "%s".', $skillPath, $metadata->name, $lastSegment)); + } + + // Register the SKILL.md entry point. + $entryUri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, McpSkills::ENTRY_POINT); + $this->registerFile($builder, $base, $manifestPath, $entryUri, McpSkills::MIME_TYPE, $metadata); + + // Register all supporting files within the skill directory. + foreach ($this->findSupportingFiles($skillDir, $manifestPath) as $filePath) { + $relative = $this->relativePath($skillDir, $filePath); + $uri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, $relative); + $this->registerFile($builder, $base, $filePath, $uri, $this->guessMimeType($filePath), null); + } + + return new SkillDiscoveryEntry( + name: $metadata->name, + type: SkillType::SkillMd, + url: $entryUri, + description: $metadata->description, + ); + } + + private function registerFile(Builder $builder, string $base, string $filePath, string $uri, string $mimeType, ?SkillMetadata $metadata): void + { + $absolute = realpath($filePath); + if (false === $absolute || !str_starts_with($absolute, $base.\DIRECTORY_SEPARATOR)) { + throw new InvalidArgumentException(\sprintf('Skill file "%s" resolves outside the skills directory.', $filePath)); + } + + $meta = null; + $title = null; + $description = null; + if (null !== $metadata) { + $title = $metadata->name; + $description = $metadata->description; + if ([] !== $metadata->extra) { + $meta = [McpSkills::META_PREFIX => $metadata->extra]; + } + } + + $builder->addResource( + static fn (): \SplFileInfo => new \SplFileInfo($absolute), + $uri, + name: $this->uniqueResourceName($uri), + title: $title, + description: $description, + mimeType: $mimeType, + meta: $meta, + ); + } + + /** + * @return iterable absolute paths to every SKILL.md under $base + */ + private function findSkillManifests(string $base): iterable + { + if (class_exists(Finder::class)) { + $finder = (new Finder())->files()->in($base)->name(McpSkills::ENTRY_POINT)->sortByName(); + foreach ($finder as $file) { + yield $file->getPathname(); + } + + return; + } + + yield from $this->iterateFiles($base, static fn (string $path): bool => McpSkills::ENTRY_POINT === basename($path)); + } + + /** + * @return iterable absolute paths to all files in $skillDir except the manifest + */ + private function findSupportingFiles(string $skillDir, string $manifestPath): iterable + { + if (class_exists(Finder::class)) { + $finder = (new Finder())->files()->in($skillDir)->sortByName(); + foreach ($finder as $file) { + if ($file->getPathname() !== $manifestPath) { + yield $file->getPathname(); + } + } + + return; + } + + yield from $this->iterateFiles($skillDir, static fn (string $path): bool => $path !== $manifestPath); + } + + /** + * @param callable(string): bool $accept + * + * @return iterable + */ + private function iterateFiles(string $directory, callable $accept): iterable + { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + ); + + $paths = []; + foreach ($iterator as $file) { + if ($file instanceof \SplFileInfo && $file->isFile() && $accept($file->getPathname())) { + $paths[] = $file->getPathname(); + } + } + + sort($paths); + + yield from $paths; + } + + /** + * Returns $path relative to $base, using forward slashes. + */ + private function relativePath(string $base, string $path): string + { + $relative = ltrim(substr($path, \strlen($base)), \DIRECTORY_SEPARATOR); + + return str_replace(\DIRECTORY_SEPARATOR, '/', $relative); + } + + /** + * Derives a unique, schema-valid resource name from a skill URI. + * + * {@see \Mcp\Schema\ResourceDefinition} restricts names to `[a-zA-Z0-9_-]+`, so slashes and dots + * in the URI path are replaced; the real path is preserved in the URI itself. + */ + private function uniqueResourceName(string $uri): string + { + $path = substr($uri, \strlen(McpSkills::URI_SCHEME.'://')); + $name = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $path) ?? ''; + $name = trim($name, '-'); + if ('' === $name) { + $name = 'skill'; + } + + $candidate = $name; + $suffix = 1; + while (isset($this->usedNames[$candidate])) { + $candidate = $name.'-'.(++$suffix); + } + $this->usedNames[$candidate] = true; + + return $candidate; + } + + private function guessMimeType(string $path): string + { + $byExtension = [ + 'md' => 'text/markdown', + 'markdown' => 'text/markdown', + 'json' => 'application/json', + 'txt' => 'text/plain', + 'csv' => 'text/csv', + 'yaml' => 'application/yaml', + 'yml' => 'application/yaml', + ]; + + $extension = strtolower(pathinfo($path, \PATHINFO_EXTENSION)); + if (isset($byExtension[$extension])) { + return $byExtension[$extension]; + } + + $finfo = new \finfo(\FILEINFO_MIME_TYPE); + $detected = $finfo->file($path); + + return \is_string($detected) && '' !== $detected ? $detected : 'application/octet-stream'; + } +} diff --git a/tests/Inspector/Stdio/StdioSkillsTest.php b/tests/Inspector/Stdio/StdioSkillsTest.php new file mode 100644 index 00000000..3b81dbb9 --- /dev/null +++ b/tests/Inspector/Stdio/StdioSkillsTest.php @@ -0,0 +1,50 @@ + [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'skill://code-review/SKILL.md', + ], + 'testName' => 'read_skill_md', + ], + 'Read Skill Supporting File' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'skill://code-review/references/SECURITY.md', + ], + 'testName' => 'read_supporting_file', + ], + 'Read Skill Discovery Index' => [ + 'method' => 'resources/read', + 'options' => [ + 'uri' => 'skill://index.json', + ], + 'testName' => 'read_discovery_index', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 3).'/examples/server/skills/server.php'; + } +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-prompts_list.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-prompts_list.json new file mode 100644 index 00000000..7292222c --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-prompts_list.json @@ -0,0 +1,3 @@ +{ + "prompts": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_list.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_list.json new file mode 100644 index 00000000..581be9dc --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_list.json @@ -0,0 +1,48 @@ +{ + "resources": [ + { + "name": "acme-billing-refunds-SKILL-md", + "title": "refunds", + "uri": "skill://acme/billing/refunds/SKILL.md", + "description": "Process a customer refund following Acme's billing policy and approval thresholds.", + "mimeType": "text/markdown", + "_meta": { + "io.modelcontextprotocol.skills/": { + "version": "1.0.0", + "tags": [ + "billing", + "support" + ] + } + } + }, + { + "name": "code-review-SKILL-md", + "title": "code-review", + "uri": "skill://code-review/SKILL.md", + "description": "Review a pull request for correctness, security, and style following this team's conventions.", + "mimeType": "text/markdown", + "_meta": { + "io.modelcontextprotocol.skills/": { + "version": "1.0.0", + "tags": [ + "review", + "quality" + ] + } + } + }, + { + "name": "code-review-references-SECURITY-md", + "uri": "skill://code-review/references/SECURITY.md", + "mimeType": "text/markdown" + }, + { + "name": "skills-index", + "title": "Skills discovery index", + "uri": "skill://index.json", + "description": "Agent Skills discovery index of all skills served by this server.", + "mimeType": "application/json" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_discovery_index.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_discovery_index.json new file mode 100644 index 00000000..aec719f8 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_discovery_index.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "skill://index.json", + "mimeType": "application/json", + "text": "{\n \"$schema\": \"https:\\/\\/schemas.agentskills.io\\/discovery\\/0.2.0\\/schema.json\",\n \"skills\": [\n {\n \"name\": \"refunds\",\n \"type\": \"skill-md\",\n \"description\": \"Process a customer refund following Acme's billing policy and approval thresholds.\",\n \"url\": \"skill:\\/\\/acme\\/billing\\/refunds\\/SKILL.md\"\n },\n {\n \"name\": \"code-review\",\n \"type\": \"skill-md\",\n \"description\": \"Review a pull request for correctness, security, and style following this team's conventions.\",\n \"url\": \"skill:\\/\\/code-review\\/SKILL.md\"\n }\n ]\n}" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json new file mode 100644 index 00000000..1e792437 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json @@ -0,0 +1,18 @@ +{ + "contents": [ + { + "uri": "skill://code-review/SKILL.md", + "mimeType": "text/markdown", + "_meta": { + "io.modelcontextprotocol.skills/": { + "version": "1.0.0", + "tags": [ + "review", + "quality" + ] + } + }, + "text": "---\nname: code-review\ndescription: Review a pull request for correctness, security, and style following this team's conventions.\nversion: 1.0.0\ntags:\n - review\n - quality\n---\n\n# Code Review\n\nFollow these steps to review a pull request thoroughly and consistently.\n\n## 1. Understand the change\n\n- Read the PR description and linked issue to understand the intended behavior.\n- Skim the diff top to bottom before commenting to build a mental model.\n\n## 2. Correctness\n\n- Check edge cases: empty input, nulls, boundary values, concurrency.\n- Verify error handling fails fast and preserves context.\n- Confirm tests cover the new behavior and actually assert on it.\n\n## 3. Security\n\n- See `references/SECURITY.md` for the security checklist that MUST be applied to\n every change touching authentication, input parsing, or external I/O.\n\n## 4. Style & maintainability\n\n- Match the surrounding code's naming, structure, and comment density.\n- Prefer the simplest implementation that satisfies the requirement.\n\n## 5. Wrap up\n\n- Summarize findings grouped by severity (blocking, suggestion, nit).\n- Approve only when blocking issues are resolved and CI is green.\n" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json new file mode 100644 index 00000000..ad226212 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json @@ -0,0 +1,9 @@ +{ + "contents": [ + { + "uri": "skill://code-review/references/SECURITY.md", + "mimeType": "text/markdown", + "text": "# Security Review Checklist\n\nApply this checklist to every change that touches authentication, input parsing, or external I/O.\n\n- **Input validation**: All external input is validated and normalized before use.\n- **Injection**: Queries, shell commands, and templates use parameterization — never string concatenation.\n- **AuthZ**: Every privileged action re-checks the caller's authorization server-side.\n- **Secrets**: No credentials, tokens, or keys are logged or committed.\n- **Output encoding**: Data rendered into HTML, URLs, or headers is contextually encoded.\n- **Dependencies**: New dependencies are pinned and free of known advisories.\n" + } + ] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json new file mode 100644 index 00000000..e867d9d2 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json @@ -0,0 +1,3 @@ +{ + "resourceTemplates": [] +} diff --git a/tests/Inspector/Stdio/snapshots/StdioSkillsTest-tools_list.json b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-tools_list.json new file mode 100644 index 00000000..bcd83b24 --- /dev/null +++ b/tests/Inspector/Stdio/snapshots/StdioSkillsTest-tools_list.json @@ -0,0 +1,3 @@ +{ + "tools": [] +} diff --git a/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php new file mode 100644 index 00000000..ab41e897 --- /dev/null +++ b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php @@ -0,0 +1,148 @@ +assertSame('io.modelcontextprotocol/skills', $extension->getId()); + $this->assertSame([], $extension->getCapabilities()); + } + + public function testCapabilitiesSerializeAsEmptyObject(): void + { + $capabilities = new ServerCapabilities(extensions: [McpSkills::EXTENSION_ID => (new McpSkills())->getCapabilities()]); + + $json = json_encode($capabilities, \JSON_UNESCAPED_SLASHES); + + // The empty extension payload MUST serialize to `{}`, not `[]`. + $this->assertStringContainsString('"io.modelcontextprotocol/skills":{}', $json); + $this->assertStringNotContainsString('"io.modelcontextprotocol/skills":[]', $json); + } + + public function testSkillTypeEnum(): void + { + $this->assertSame('skill-md', SkillType::SkillMd->value); + $this->assertSame('mcp-resource-template', SkillType::McpResourceTemplate->value); + } + + public function testSkillDiscoveryEntrySerialization(): void + { + $entry = new SkillDiscoveryEntry( + name: 'code-review', + type: SkillType::SkillMd, + url: 'skill://code-review/SKILL.md', + description: 'Review a pull request.', + ); + + $serialized = $entry->jsonSerialize(); + + $this->assertSame('code-review', $serialized['name']); + $this->assertSame('skill-md', $serialized['type']); + $this->assertSame('Review a pull request.', $serialized['description']); + $this->assertSame('skill://code-review/SKILL.md', $serialized['url']); + } + + public function testSkillDiscoveryEntryOmitsNullDescription(): void + { + $entry = new SkillDiscoveryEntry('refunds', SkillType::SkillMd, 'skill://refunds/SKILL.md'); + + $serialized = $entry->jsonSerialize(); + + $this->assertArrayNotHasKey('description', $serialized); + $this->assertSame('skill://refunds/SKILL.md', $serialized['url']); + } + + public function testSkillDiscoveryEntryFromArray(): void + { + $entry = SkillDiscoveryEntry::fromArray([ + 'name' => 'code-review', + 'type' => 'skill-md', + 'url' => 'skill://code-review/SKILL.md', + ]); + + $this->assertSame('code-review', $entry->name); + $this->assertSame(SkillType::SkillMd, $entry->type); + $this->assertSame('skill://code-review/SKILL.md', $entry->url); + $this->assertNull($entry->description); + } + + public function testSkillDiscoveryIndexSerialization(): void + { + $index = new SkillDiscoveryIndex([ + new SkillDiscoveryEntry('code-review', SkillType::SkillMd, 'skill://code-review/SKILL.md'), + ]); + + $serialized = $index->jsonSerialize(); + + $this->assertSame(SkillDiscoveryIndex::SCHEMA_URL, $serialized['$schema']); + $this->assertCount(1, $serialized['skills']); + $this->assertInstanceOf(SkillDiscoveryEntry::class, $serialized['skills'][0]); + } + + public function testSkillDiscoveryIndexRoundTrip(): void + { + $index = SkillDiscoveryIndex::fromArray([ + '$schema' => SkillDiscoveryIndex::SCHEMA_URL, + 'skills' => [ + ['name' => 'refunds', 'type' => 'skill-md', 'url' => 'skill://acme/billing/refunds/SKILL.md'], + ], + ]); + + $this->assertCount(1, $index->skills); + $this->assertSame('refunds', $index->skills[0]->name); + $this->assertSame('skill://acme/billing/refunds/SKILL.md', $index->skills[0]->url); + } + + public function testSkillMetadataFromArrayExtractsExtra(): void + { + $metadata = SkillMetadata::fromArray([ + 'name' => 'code-review', + 'description' => 'Review a pull request.', + 'version' => '1.0.0', + 'tags' => ['review', 'quality'], + ]); + + $this->assertSame('code-review', $metadata->name); + $this->assertSame('Review a pull request.', $metadata->description); + $this->assertSame(['version' => '1.0.0', 'tags' => ['review', 'quality']], $metadata->extra); + } + + public function testSkillMetadataRequiresName(): void + { + $this->expectException(\Mcp\Exception\InvalidArgumentException::class); + + SkillMetadata::fromArray(['description' => 'no name here']); + } + + public function testSkillMetadataSerializationMergesExtra(): void + { + $metadata = new SkillMetadata('refunds', 'Process refunds.', ['version' => '2.0.0']); + + $this->assertSame([ + 'name' => 'refunds', + 'description' => 'Process refunds.', + 'version' => '2.0.0', + ], $metadata->jsonSerialize()); + } +} diff --git a/tests/Unit/Server/Skill/Fixtures/mismatch/wrong-name/SKILL.md b/tests/Unit/Server/Skill/Fixtures/mismatch/wrong-name/SKILL.md new file mode 100644 index 00000000..a325c28e --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/mismatch/wrong-name/SKILL.md @@ -0,0 +1,6 @@ +--- +name: not-the-folder +description: The frontmatter name does not match the folder name. +--- + +# Mismatch diff --git a/tests/Unit/Server/Skill/Fixtures/skills/acme/billing/refunds/SKILL.md b/tests/Unit/Server/Skill/Fixtures/skills/acme/billing/refunds/SKILL.md new file mode 100644 index 00000000..629f21c0 --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/skills/acme/billing/refunds/SKILL.md @@ -0,0 +1,8 @@ +--- +name: refunds +description: Process refunds. +--- + +# Refunds + +Body. diff --git a/tests/Unit/Server/Skill/Fixtures/skills/code-review/SKILL.md b/tests/Unit/Server/Skill/Fixtures/skills/code-review/SKILL.md new file mode 100644 index 00000000..210a9927 --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/skills/code-review/SKILL.md @@ -0,0 +1,11 @@ +--- +name: code-review +description: Review a pull request. +version: 1.0.0 +tags: + - review +--- + +# Code Review + +Body. diff --git a/tests/Unit/Server/Skill/Fixtures/skills/code-review/references/SECURITY.md b/tests/Unit/Server/Skill/Fixtures/skills/code-review/references/SECURITY.md new file mode 100644 index 00000000..305f56ac --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/skills/code-review/references/SECURITY.md @@ -0,0 +1,3 @@ +# Security Checklist + +Supporting file. diff --git a/tests/Unit/Server/Skill/FrontmatterParserTest.php b/tests/Unit/Server/Skill/FrontmatterParserTest.php new file mode 100644 index 00000000..54cbbab7 --- /dev/null +++ b/tests/Unit/Server/Skill/FrontmatterParserTest.php @@ -0,0 +1,89 @@ +parse($content); + + $this->assertSame(['name' => 'code-review', 'description' => 'Review a PR.'], $frontmatter); + $this->assertSame("\n# Heading\n\nBody text.", $body); + } + + public function testDocumentWithoutFrontmatterHasEmptyFrontmatter(): void + { + $content = "# Just markdown\n\nNo frontmatter here."; + + [$frontmatter, $body] = (new FrontmatterParser())->parse($content); + + $this->assertSame([], $frontmatter); + $this->assertSame($content, $body); + } + + public function testHandlesCrlfLineEndings(): void + { + $content = "---\r\nname: refunds\r\n---\r\n\r\n# Refunds\r\n"; + + [$frontmatter] = (new FrontmatterParser())->parse($content); + + $this->assertSame('refunds', $frontmatter['name']); + } + + public function testHandlesByteOrderMark(): void + { + $content = "\xEF\xBB\xBF---\nname: refunds\n---\n\nBody."; + + [$frontmatter] = (new FrontmatterParser())->parse($content); + + $this->assertSame('refunds', $frontmatter['name']); + } + + public function testParsesListsAndMultilineValues(): void + { + $content = "---\nname: code-review\ntags:\n - review\n - quality\n---\nbody"; + + [$frontmatter] = (new FrontmatterParser())->parse($content); + + $this->assertSame(['review', 'quality'], $frontmatter['tags']); + } + + public function testThrowsWhenFrontmatterIsNotAMapping(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('YAML mapping'); + + (new FrontmatterParser())->parse("---\n- just\n- a\n- list\n---\nbody"); + } + + public function testParseMetadataReturnsValueObject(): void + { + $metadata = (new FrontmatterParser())->parseMetadata("---\nname: refunds\ndescription: Process refunds.\n---\nbody"); + + $this->assertSame('refunds', $metadata->name); + $this->assertSame('Process refunds.', $metadata->description); + } + + public function testParseMetadataThrowsWhenNameMissing(): void + { + $this->expectException(InvalidArgumentException::class); + + (new FrontmatterParser())->parseMetadata("---\ndescription: no name\n---\nbody"); + } +} diff --git a/tests/Unit/Server/Skill/SkillProviderTest.php b/tests/Unit/Server/Skill/SkillProviderTest.php new file mode 100644 index 00000000..3bbe076a --- /dev/null +++ b/tests/Unit/Server/Skill/SkillProviderTest.php @@ -0,0 +1,155 @@ +registerInto($builder, self::FIXTURES); + + $resources = $this->registeredResources($builder); + $uris = array_column($resources, 'uri'); + + $this->assertContains('skill://code-review/SKILL.md', $uris); + $this->assertContains('skill://code-review/references/SECURITY.md', $uris); + $this->assertContains('skill://acme/billing/refunds/SKILL.md', $uris); + $this->assertContains('skill://index.json', $uris); + } + + public function testSkillManifestCarriesFrontmatterMetadata(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, self::FIXTURES); + + $resource = $this->resourceByUri($builder, 'skill://code-review/SKILL.md'); + + $this->assertSame(McpSkills::MIME_TYPE, $resource['mimeType']); + $this->assertSame('code-review', $resource['title']); + $this->assertSame('Review a pull request.', $resource['description']); + $this->assertSame( + [McpSkills::META_PREFIX => ['version' => '1.0.0', 'tags' => ['review']]], + $resource['meta'], + ); + } + + public function testResourceNamesAreSanitizedAndSchemaValid(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, self::FIXTURES); + + foreach ($this->registeredResources($builder) as $resource) { + $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_-]+$/', $resource['name']); + } + } + + public function testReturnsDiscoveryEntries(): void + { + $builder = Server::builder(); + + $entries = (new SkillProvider())->registerInto($builder, self::FIXTURES); + + $this->assertCount(2, $entries); + foreach ($entries as $entry) { + $this->assertSame(SkillType::SkillMd, $entry->type); + } + $names = array_map(static fn ($e) => $e->name, $entries); + $this->assertContains('code-review', $names); + $this->assertContains('refunds', $names); + } + + public function testDiscoveryIndexCanBeDisabled(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, self::FIXTURES, withDiscoveryIndex: false); + + $uris = array_column($this->registeredResources($builder), 'uri'); + $this->assertNotContains('skill://index.json', $uris); + } + + public function testSupportingFileMimeTypeIsGuessed(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, self::FIXTURES); + + $resource = $this->resourceByUri($builder, 'skill://code-review/references/SECURITY.md'); + $this->assertSame('text/markdown', $resource['mimeType']); + } + + public function testThrowsWhenFrontmatterNameDoesNotMatchFolder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must match the final path segment'); + + (new SkillProvider())->registerInto(Server::builder(), __DIR__.'/Fixtures/mismatch'); + } + + public function testThrowsWhenDirectoryDoesNotExist(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SkillProvider())->registerInto(Server::builder(), __DIR__.'/Fixtures/does-not-exist'); + } + + public function testBuilderHelperAutoEnablesExtension(): void + { + $builder = Server::builder()->addSkillsFromDirectory(self::FIXTURES); + + $extensions = $this->readPrivate($builder, 'extensions'); + $this->assertArrayHasKey(McpSkills::EXTENSION_ID, $extensions); + } + + /** + * @return array> + */ + private function registeredResources(Builder $builder): array + { + return $this->readPrivate($builder, 'resources'); + } + + /** + * @return array + */ + private function resourceByUri(Builder $builder, string $uri): array + { + foreach ($this->registeredResources($builder) as $resource) { + if ($resource['uri'] === $uri) { + return $resource; + } + } + + $this->fail(\sprintf('No resource registered for URI "%s".', $uri)); + } + + private function readPrivate(Builder $builder, string $property): mixed + { + $reflection = new \ReflectionProperty(Builder::class, $property); + + return $reflection->getValue($builder); + } +}