From eff04510b0d0ff98fc19ada2b87271fdfd35e94c Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sun, 13 Sep 2026 20:37:10 +0200 Subject: [PATCH 1/5] [Schema][Server] Rework Skills extension for the redesigned SEP-2640 The ext-skills spec moved past PR #372 before it was published as specification/stable/skills.mdx: discovery is no longer a skill://index.json resource but two mandatory RPC methods, skills/list and skills/get, each returning a complete {uri, digest, size} manifest of every file in the skill. This rewrites the extension to match, built directly against current main. McpSkills is now a full ExtensionInterface implementation contributing ListSkillsRequest/Result and GetSkillRequest/Result plus their handlers, backed by a SkillRegistry that SkillProvider populates while it registers skill:// resources. The old discovery-index and archive layer is gone along with SkillDiscoveryIndex/Entry and SkillArchive/Archiver, since no listing ever pointed at them once the index disappeared from the spec. SkillMetadata now requires description, matching the spec's Skill.frontmatter shape. The SKILL.md resource's name comes directly from the frontmatter name instead of a generated slug, since names don't need to be registry-unique. Extra frontmatter fields land under _meta as one io.modelcontextprotocol.skills/ prefixed key each, not one key holding the whole map. SkillProvider also enforces the extension's fixed per-skill limits (512 resources, 16 MiB) and records each file's byte size on its resource definition. Current main already carries the ServerCapabilities empty-extension-payload fix this PR originally introduced, independently. --- composer.json | 3 +- docs/advanced/extensions.md | 89 ++++++ docs/examples.md | 1 + examples/server/skills/README.md | 45 +++ examples/server/skills/server.php | 30 ++ .../skills/acme/billing/refunds/SKILL.md | 25 ++ .../server/skills/skills/code-review/SKILL.md | 38 +++ .../skills/code-review/references/SECURITY.md | 10 + .../Extension/Skills/GetSkillRequest.php | 54 ++++ .../Extension/Skills/GetSkillResult.php | 61 ++++ .../Extension/Skills/ListSkillsRequest.php | 53 ++++ .../Extension/Skills/ListSkillsResult.php | 71 +++++ src/Schema/Extension/Skills/McpSkills.php | 85 ++++++ src/Schema/Extension/Skills/Skill.php | 89 ++++++ src/Schema/Extension/Skills/SkillMetadata.php | 71 +++++ src/Schema/Extension/Skills/SkillResource.php | 73 +++++ src/Server/Builder.php | 31 ++ .../Request/Skills/GetSkillHandler.php | 57 ++++ .../Request/Skills/ListSkillsHandler.php | 70 +++++ src/Server/Skill/FrontmatterParser.php | 64 ++++ src/Server/Skill/SkillProvider.php | 279 ++++++++++++++++++ src/Server/Skill/SkillRegistry.php | 50 ++++ src/Server/Wire/Rev2026Codec.php | 2 + .../Interop/Server/Stdio/StdioSkillsTest.php | 41 +++ .../StdioSkillsTest-prompts_list.json | 4 + .../StdioSkillsTest-resources_list.json | 39 +++ ...illsTest-resources_read-read_skill_md.json | 17 ++ ...t-resources_read-read_supporting_file.json | 10 + ...ioSkillsTest-resources_templates_list.json | 4 + .../snapshots/StdioSkillsTest-tools_list.json | 4 + .../Schema/Extension/Skills/McpSkillsTest.php | 61 ++++ .../Extension/Skills/SkillMetadataTest.php | 58 ++++ .../Extension/Skills/SkillResourceTest.php | 66 +++++ .../Schema/Extension/Skills/SkillTest.php | 97 ++++++ .../Request/Skills/GetSkillHandlerTest.php | 71 +++++ .../Request/Skills/ListSkillsHandlerTest.php | 122 ++++++++ .../Fixtures/mismatch/wrong-name/SKILL.md | 6 + .../skills/acme/billing/refunds/SKILL.md | 8 + .../Fixtures/skills/code-review/SKILL.md | 11 + .../skills/code-review/references/SECURITY.md | 3 + .../Server/Skill/FrontmatterParserTest.php | 89 ++++++ tests/Unit/Server/Skill/SkillProviderTest.php | 256 ++++++++++++++++ tests/Unit/Server/Skill/SkillRegistryTest.php | 62 ++++ 43 files changed, 2379 insertions(+), 1 deletion(-) create mode 100644 examples/server/skills/README.md create mode 100644 examples/server/skills/server.php create mode 100644 examples/server/skills/skills/acme/billing/refunds/SKILL.md create mode 100644 examples/server/skills/skills/code-review/SKILL.md create mode 100644 examples/server/skills/skills/code-review/references/SECURITY.md create mode 100644 src/Schema/Extension/Skills/GetSkillRequest.php create mode 100644 src/Schema/Extension/Skills/GetSkillResult.php create mode 100644 src/Schema/Extension/Skills/ListSkillsRequest.php create mode 100644 src/Schema/Extension/Skills/ListSkillsResult.php create mode 100644 src/Schema/Extension/Skills/McpSkills.php create mode 100644 src/Schema/Extension/Skills/Skill.php create mode 100644 src/Schema/Extension/Skills/SkillMetadata.php create mode 100644 src/Schema/Extension/Skills/SkillResource.php create mode 100644 src/Server/Handler/Request/Skills/GetSkillHandler.php create mode 100644 src/Server/Handler/Request/Skills/ListSkillsHandler.php create mode 100644 src/Server/Skill/FrontmatterParser.php create mode 100644 src/Server/Skill/SkillProvider.php create mode 100644 src/Server/Skill/SkillRegistry.php create mode 100644 tests/Interop/Server/Stdio/StdioSkillsTest.php create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-prompts_list.json create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_list.json create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json create mode 100644 tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-tools_list.json create mode 100644 tests/Unit/Schema/Extension/Skills/McpSkillsTest.php create mode 100644 tests/Unit/Schema/Extension/Skills/SkillMetadataTest.php create mode 100644 tests/Unit/Schema/Extension/Skills/SkillResourceTest.php create mode 100644 tests/Unit/Schema/Extension/Skills/SkillTest.php create mode 100644 tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php create mode 100644 tests/Unit/Server/Handler/Request/Skills/ListSkillsHandlerTest.php create mode 100644 tests/Unit/Server/Skill/Fixtures/mismatch/wrong-name/SKILL.md create mode 100644 tests/Unit/Server/Skill/Fixtures/skills/acme/billing/refunds/SKILL.md create mode 100644 tests/Unit/Server/Skill/Fixtures/skills/code-review/SKILL.md create mode 100644 tests/Unit/Server/Skill/Fixtures/skills/code-review/references/SECURITY.md create mode 100644 tests/Unit/Server/Skill/FrontmatterParserTest.php create mode 100644 tests/Unit/Server/Skill/SkillProviderTest.php create mode 100644 tests/Unit/Server/Skill/SkillRegistryTest.php diff --git a/composer.json b/composer.json index be233eeeb..c4f0ca7c9 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "psr/http-server-middleware": "^1.0", "psr/log": "^1.0 || ^2.0 || ^3.0", "symfony/deprecation-contracts": "^2.5 || ^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": { "firebase/php-jwt": "Required for the JwtTokenValidator.", diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index d43aab40d..fc04921a6 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -193,4 +193,93 @@ TypeScript SDK (`@modelcontextprotocol/ext-apps`), and view-side examples. A working minimal view is included in [`examples/server/mcp-apps/weather-app.html`](https://github.com/modelcontextprotocol/php-sdk/blob/main/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. Each skill file is served through the existing **Resources** +primitive (`skill:///SKILL.md` plus any supporting files), and the +extension adds two mandatory RPC methods: + +- `skills/list` — enumerates the skills a server serves, paginated like + `resources/list`. +- `skills/get` — returns the entry for a single skill by its `SKILL.md` URI. + +Both return a `Skill` entry: the skill's frontmatter verbatim, and a complete, +`{uri, digest, size}` manifest of every file the skill comprises (`SKILL.md` +included), so a host can build its registry, present the skill for approval, and +verify every later read without fetching anything first. + +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, and a +matching `Skill` entry is added to the `skills/list`/`skills/get` catalog: + +``` +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's `name` and +`description` become the resource `name`/`description`; any remaining frontmatter +keys are exposed under the `io.modelcontextprotocol.skills/` `_meta` namespace on +the resource, and pass through verbatim in the `skills/list`/`skills/get` entry's +`frontmatter`. 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`), and `description` is required; a +> violation throws an `InvalidArgumentException`. + +The extension fixes two per-skill limits so every conforming host knows what it +must accept: 512 resources and 16 MiB total content. `addSkillsFromDirectory()` +throws if a skill exceeds either. + +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; provides `EXTENSION_ID`, `MIME_TYPE`, `URI_SCHEME`, `ENTRY_POINT`, `META_PREFIX` constants and the `skills/list`/`skills/get` handlers. | +| `SkillProvider` | Walks a directory and registers each skill (and its files) as `skill://` resources, recording each skill's manifest in a `SkillRegistry`. | +| `SkillRegistry` | The skills a server serves, keyed by `SKILL.md` URI; backs `skills/list`/`skills/get`. | +| `FrontmatterParser` | Splits a `SKILL.md` into its YAML frontmatter and markdown body. | +| `SkillMetadata` | Value object for parsed frontmatter: `name`, `description`, `extra`. | +| `Skill` | One `skills/list`/`skills/get` entry: `uri`, `frontmatter`, `resources`. | +| `SkillResource` | One file of a skill's manifest: `uri`, `digest`, `size`. | + +A complete example lives in +[`examples/server/skills/`](https://github.com/modelcontextprotocol/php-sdk/blob/main/examples/server/skills/). + +[ext-skills]: https://github.com/modelcontextprotocol/ext-skills +[symfony-yaml]: https://github.com/symfony/yaml + [ext-apps]: https://github.com/modelcontextprotocol/ext-apps diff --git a/docs/examples.md b/docs/examples.md index 05af48655..ba11fb4ad 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -33,6 +33,7 @@ npx @modelcontextprotocol/inspector php examples/server/discovery-calculator/ser | [`elicitation`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/elicitation) | Asking the user for input mid-call with `ClientGateway::elicit()` and typed elicitation schemas, on either protocol era | [Asking for input](handlers/input-required.md) | | [`custom-method-handlers`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/custom-method-handlers) | Replacing the built-in `tools/list` and `tools/call` request handlers | [Custom message handlers](advanced/custom-handlers.md) | | [`mcp-apps`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/mcp-apps) | The MCP Apps extension: a tool that ships an interactive HTML view | [Protocol extensions](advanced/extensions.md) | +| [`skills`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/skills) | The Skills extension: `skills/list`/`skills/get` over a directory of `SKILL.md` files | [Protocol extensions](advanced/extensions.md) | | [`stateless-lifecycle`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/stateless-lifecycle) | Revision `2026-07-28`: cache policy, request state, notification bus | [Serving both eras](run/protocol-eras.md), [Caching](run/caching.md), [Subscriptions](run/subscriptions.md) | | [`oauth-keycloak`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-keycloak) | OAuth authorization against a Keycloak instance (own README) | [Authorization](run/authorization.md) | | [`oauth-microsoft`](https://github.com/modelcontextprotocol/php-sdk/tree/main/examples/server/oauth-microsoft) | OAuth authorization against Microsoft Entra ID (own README) | [Authorization](run/authorization.md) | diff --git a/examples/server/skills/README.md b/examples/server/skills/README.md new file mode 100644 index 000000000..9c2b5dc37 --- /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. Each skill file is served through the +existing MCP **Resources** primitive, and the extension adds two RPC methods, `skills/list` and +`skills/get`, that return a complete, digest-and-size manifest of a skill's files. + +## 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, and its manifest as a `skills/list`/`skills/get` entry. + +## 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 +``` + +## 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 and are always present in the + `skills/list`/`skills/get` entry; any extra frontmatter passes through verbatim, and is also + exposed on the SKILL.md resource 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 000000000..5ff340f0a --- /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 000000000..5e457933f --- /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 000000000..b0112098e --- /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 000000000..de9ef3432 --- /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/GetSkillRequest.php b/src/Schema/Extension/Skills/GetSkillRequest.php new file mode 100644 index 000000000..e2c81d861 --- /dev/null +++ b/src/Schema/Extension/Skills/GetSkillRequest.php @@ -0,0 +1,54 @@ + + */ +final class GetSkillRequest extends Request +{ + /** + * @param non-empty-string $uri URI of the skill's SKILL.md + */ + public function __construct( + public readonly string $uri, + ) { + } + + public static function getMethod(): string + { + return 'skills/get'; + } + + protected static function fromParams(?array $params): static + { + if (!isset($params['uri']) || !\is_string($params['uri']) || '' === $params['uri']) { + throw new InvalidArgumentException('Missing or invalid "uri" parameter for skills/get.'); + } + + return new self($params['uri']); + } + + /** + * @return array{uri: non-empty-string} + */ + protected function getParams(): array + { + return ['uri' => $this->uri]; + } +} diff --git a/src/Schema/Extension/Skills/GetSkillResult.php b/src/Schema/Extension/Skills/GetSkillResult.php new file mode 100644 index 000000000..d780056be --- /dev/null +++ b/src/Schema/Extension/Skills/GetSkillResult.php @@ -0,0 +1,61 @@ + + */ +final class GetSkillResult implements ResultInterface +{ + /** + * @param ?int $ttlMs how long a client may consider this fresh, in milliseconds. Null + * leaves it to the server's configured {@see \Mcp\Server\Wire\CachePolicy}. + * @param ?CacheScope $cacheScope who may cache it. Null defers to the policy. + */ + public function __construct( + public readonly Skill $skill, + public readonly ?int $ttlMs = null, + public readonly ?CacheScope $cacheScope = null, + ) { + if (null !== $this->ttlMs && $this->ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A skills/get "ttlMs" must be zero or more, got %d.', $this->ttlMs)); + } + } + + /** + * @return array{ + * skill: Skill, + * ttlMs?: int, + * cacheScope?: string, + * } + */ + public function jsonSerialize(): array + { + $data = ['skill' => $this->skill]; + + if (null !== $this->ttlMs) { + $data['ttlMs'] = $this->ttlMs; + } + + if (null !== $this->cacheScope) { + $data['cacheScope'] = $this->cacheScope->value; + } + + return $data; + } +} diff --git a/src/Schema/Extension/Skills/ListSkillsRequest.php b/src/Schema/Extension/Skills/ListSkillsRequest.php new file mode 100644 index 000000000..ad57ddc3e --- /dev/null +++ b/src/Schema/Extension/Skills/ListSkillsRequest.php @@ -0,0 +1,53 @@ + + */ +final class ListSkillsRequest extends Request +{ + /** + * @param string|null $cursor an opaque token representing the current pagination position + */ + public function __construct( + public readonly ?string $cursor = null, + ) { + } + + public static function getMethod(): string + { + return 'skills/list'; + } + + protected static function fromParams(?array $params): static + { + if (isset($params['cursor']) && !\is_string($params['cursor'])) { + throw new InvalidArgumentException('Invalid "cursor" parameter for skills/list.'); + } + + return new self($params['cursor'] ?? null); + } + + /** + * @return array{cursor: string}|null + */ + protected function getParams(): ?array + { + return null !== $this->cursor ? ['cursor' => $this->cursor] : null; + } +} diff --git a/src/Schema/Extension/Skills/ListSkillsResult.php b/src/Schema/Extension/Skills/ListSkillsResult.php new file mode 100644 index 000000000..cdf68a643 --- /dev/null +++ b/src/Schema/Extension/Skills/ListSkillsResult.php @@ -0,0 +1,71 @@ + + */ +final class ListSkillsResult implements ResultInterface +{ + /** + * @param Skill[] $skills + * @param string|null $nextCursor an opaque token for the next page, present when more results follow + * @param ?int $ttlMs how long a client may consider this fresh, in milliseconds. Null + * leaves it to the server's configured {@see \Mcp\Server\Wire\CachePolicy}. + * @param ?CacheScope $cacheScope who may cache it. Null defers to the policy. + */ + public function __construct( + public readonly array $skills, + public readonly ?string $nextCursor = null, + public readonly ?int $ttlMs = null, + public readonly ?CacheScope $cacheScope = null, + ) { + if (null !== $this->ttlMs && $this->ttlMs < 0) { + throw new InvalidArgumentException(\sprintf('A skills/list "ttlMs" must be zero or more, got %d.', $this->ttlMs)); + } + } + + /** + * @return array{ + * skills: array, + * nextCursor?: string, + * ttlMs?: int, + * cacheScope?: string, + * } + */ + public function jsonSerialize(): array + { + $data = ['skills' => array_values($this->skills)]; + + if (null !== $this->nextCursor) { + $data['nextCursor'] = $this->nextCursor; + } + + // Only what this result actually decided; the wire codec fills the rest + // from policy, and an absent member is the signal for it to do so. + if (null !== $this->ttlMs) { + $data['ttlMs'] = $this->ttlMs; + } + + if (null !== $this->cacheScope) { + $data['cacheScope'] = $this->cacheScope->value; + } + + return $data; + } +} diff --git a/src/Schema/Extension/Skills/McpSkills.php b/src/Schema/Extension/Skills/McpSkills.php new file mode 100644 index 000000000..adc7c4eb8 --- /dev/null +++ b/src/Schema/Extension/Skills/McpSkills.php @@ -0,0 +1,85 @@ +/SKILL.md` (plus supporting files). The extension adds two + * mandatory RPC methods — `skills/list` and `skills/get` — that return a complete, digest-and-size + * manifest of a skill's files, so a host can build its registry, present a skill for approval, and + * verify every later read without first fetching the files. + * + * 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. + * + * `resources/directory/read`, gated behind the `directoryRead` capability setting, is not yet + * implemented and is not declared. + * + * @see https://github.com/modelcontextprotocol/ext-skills + * + * @author Johannes Wachter + */ +final class McpSkills implements ExtensionInterface +{ + public const EXTENSION_ID = 'io.modelcontextprotocol/skills'; + public const MIME_TYPE = 'text/markdown'; + public const URI_SCHEME = 'skill'; + public const ENTRY_POINT = 'SKILL.md'; + + /** + * The `_meta` namespace prefix reserved by this extension under which extra SKILL.md + * frontmatter fields MAY be exposed on a skill resource descriptor. + */ + public const META_PREFIX = 'io.modelcontextprotocol.skills/'; + + public function __construct( + private readonly SkillRegistry $registry, + private readonly int $pageSize = 20, + ) { + } + + public function getId(): ExtensionIdentifier + { + return new ExtensionIdentifier(self::EXTENSION_ID); + } + + /** + * The Skills extension advertises an empty capability payload (`{}`): `directoryRead` + * is not yet implemented. + * + * @return array + */ + public function getCapabilities(): array + { + return []; + } + + public function getMessages(): array + { + return [ListSkillsRequest::class, GetSkillRequest::class]; + } + + public function getRequestHandlers(): iterable + { + yield new ListSkillsHandler($this->registry, $this->pageSize); + yield new GetSkillHandler($this->registry); + } +} diff --git a/src/Schema/Extension/Skills/Skill.php b/src/Schema/Extension/Skills/Skill.php new file mode 100644 index 000000000..375085297 --- /dev/null +++ b/src/Schema/Extension/Skills/Skill.php @@ -0,0 +1,89 @@ + + */ +final class Skill implements \JsonSerializable +{ + /** + * @param string $uri resource URI of the skill's SKILL.md, readable via resources/read + * @param SkillMetadata $frontmatter the skill's SKILL.md YAML frontmatter, rendered verbatim + * @param SkillResource[]|'dynamic' $resources a complete enumeration of SKILL.md and every supporting + * file, or "dynamic" when stable digests cannot be published + */ + public function __construct( + public readonly string $uri, + public readonly SkillMetadata $frontmatter, + public readonly array|string $resources, + ) { + if ('dynamic' !== $this->resources && !array_is_list($this->resources)) { + throw new InvalidArgumentException('A skill\'s "resources" must be a list of SkillResource or the string "dynamic".'); + } + } + + /** + * @param SkillData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in skill entry.'); + } + if (!isset($data['frontmatter']) || !\is_array($data['frontmatter'])) { + throw new InvalidArgumentException('Invalid or missing "frontmatter" in skill entry.'); + } + + $resources = $data['resources'] ?? null; + if ('dynamic' === $resources) { + $parsedResources = 'dynamic'; + } elseif (\is_array($resources)) { + $parsedResources = array_map(SkillResource::fromArray(...), $resources); + } else { + throw new InvalidArgumentException('A skill entry\'s "resources" must be an array or the string "dynamic".'); + } + + return new self( + uri: $data['uri'], + frontmatter: SkillMetadata::fromArray($data['frontmatter']), + resources: $parsedResources, + ); + } + + /** + * @return array{uri: string, frontmatter: SkillMetadata, resources: array|'dynamic'} + */ + public function jsonSerialize(): array + { + return [ + 'uri' => $this->uri, + 'frontmatter' => $this->frontmatter, + 'resources' => 'dynamic' === $this->resources ? 'dynamic' : $this->resources, + ]; + } +} diff --git a/src/Schema/Extension/Skills/SkillMetadata.php b/src/Schema/Extension/Skills/SkillMetadata.php new file mode 100644 index 000000000..ad4aa4c01 --- /dev/null +++ b/src/Schema/Extension/Skills/SkillMetadata.php @@ -0,0 +1,71 @@ + + */ +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, + 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".'); + } + + if (empty($data['description']) || !\is_string($data['description'])) { + throw new InvalidArgumentException('SKILL.md frontmatter must contain a non-empty string "description".'); + } + + $extra = $data; + unset($extra['name'], $extra['description']); + + return new self( + name: $data['name'], + description: $data['description'], + extra: $extra, + ); + } + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + ...$this->extra, + ]; + } +} diff --git a/src/Schema/Extension/Skills/SkillResource.php b/src/Schema/Extension/Skills/SkillResource.php new file mode 100644 index 000000000..c9e115768 --- /dev/null +++ b/src/Schema/Extension/Skills/SkillResource.php @@ -0,0 +1,73 @@ + + */ +final class SkillResource implements \JsonSerializable +{ + /** + * @param string $uri resource URI of the file + * @param string $digest SHA-256 digest of the file's raw bytes, formatted as `sha256:{hex}` + * @param int $size length in bytes of the file's raw content (the same bytes `digest` covers) + */ + public function __construct( + public readonly string $uri, + public readonly string $digest, + public readonly int $size, + ) { + if (1 !== preg_match('/^sha256:[0-9a-f]{64}$/', $digest)) { + throw new InvalidArgumentException(\sprintf('A skill resource digest must be "sha256:" followed by 64 lowercase hex characters, got "%s".', $digest)); + } + + if ($size < 0) { + throw new InvalidArgumentException(\sprintf('A skill resource "size" must be zero or more, got %d.', $size)); + } + } + + /** + * @param SkillResourceData $data + */ + public static function fromArray(array $data): self + { + if (empty($data['uri']) || !\is_string($data['uri'])) { + throw new InvalidArgumentException('Invalid or missing "uri" in skill resource.'); + } + if (empty($data['digest']) || !\is_string($data['digest'])) { + throw new InvalidArgumentException('Invalid or missing "digest" in skill resource.'); + } + if (!isset($data['size']) || !\is_int($data['size'])) { + throw new InvalidArgumentException('Invalid or missing "size" in skill resource.'); + } + + return new self($data['uri'], $data['digest'], $data['size']); + } + + /** + * @return SkillResourceData + */ + public function jsonSerialize(): array + { + return [ + 'uri' => $this->uri, + 'digest' => $this->digest, + 'size' => $this->size, + ]; + } +} diff --git a/src/Server/Builder.php b/src/Server/Builder.php index d16ec8b64..6d509d4e3 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -35,6 +35,7 @@ use Mcp\Schema\Enum\ProtocolVersion; use Mcp\Schema\Extension\AbstractExtension; use Mcp\Schema\Extension\ExtensionInterface; +use Mcp\Schema\Extension\Skills\McpSkills; use Mcp\Schema\Icon; use Mcp\Schema\Implementation; use Mcp\Schema\Prompt; @@ -58,6 +59,8 @@ use Mcp\Server\Session\SessionManager; use Mcp\Server\Session\SessionManagerInterface; use Mcp\Server\Session\SessionStoreInterface; +use Mcp\Server\Skill\SkillProvider; +use Mcp\Server\Skill\SkillRegistry; use Mcp\Server\Stateless\RequestStateCodec; use Mcp\Server\Stateless\StandardHeaderValidator; use Mcp\Server\Stateless\StatelessProtocol; @@ -256,6 +259,12 @@ final class Builder */ private array $extensions = []; + /** + * The registry backing {@see McpSkills} once {@see self::addSkillsFromDirectory()} has been + * called at least once, shared across calls so multiple directories accumulate into one extension. + */ + private ?SkillRegistry $skillRegistry = null; + /** * @var LoaderInterface[] */ @@ -469,6 +478,28 @@ public function enableExtension(ExtensionInterface ...$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, + * making them servable through `skills/list` and `skills/get`. Calling this more than once + * accumulates every directory's skills into the same extension. + * + * @see SkillProvider + */ + public function addSkillsFromDirectory(string $directory, ?SkillProvider $provider = null): self + { + if (null === $this->skillRegistry) { + $this->skillRegistry = new SkillRegistry(); + $this->enableExtension(new McpSkills($this->skillRegistry)); + } + + ($provider ?? new SkillProvider())->registerInto($this, $this->skillRegistry, $directory); + + return $this; + } + /** * Register a single custom method handler. * diff --git a/src/Server/Handler/Request/Skills/GetSkillHandler.php b/src/Server/Handler/Request/Skills/GetSkillHandler.php new file mode 100644 index 000000000..9b99bffbc --- /dev/null +++ b/src/Server/Handler/Request/Skills/GetSkillHandler.php @@ -0,0 +1,57 @@ + + * + * @author Johannes Wachter + */ +final class GetSkillHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly SkillRegistry $registry, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof GetSkillRequest; + } + + /** + * @throws InvalidArgumentException if the URI does not identify a skill the server serves; + * rendered as -32602 (Invalid params), per the extension's + * error handling + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof GetSkillRequest); + + $skill = $this->registry->get($request->uri); + + if (null === $skill) { + throw new InvalidArgumentException(\sprintf('No skill is served at %s', $request->uri)); + } + + return new Response($request->getId(), new GetSkillResult($skill)); + } +} diff --git a/src/Server/Handler/Request/Skills/ListSkillsHandler.php b/src/Server/Handler/Request/Skills/ListSkillsHandler.php new file mode 100644 index 000000000..df6618018 --- /dev/null +++ b/src/Server/Handler/Request/Skills/ListSkillsHandler.php @@ -0,0 +1,70 @@ + + * + * @author Johannes Wachter + */ +final class ListSkillsHandler implements RequestHandlerInterface +{ + public function __construct( + private readonly SkillRegistry $registry, + private readonly int $pageSize = 20, + ) { + } + + public function supports(Request $request): bool + { + return $request instanceof ListSkillsRequest; + } + + /** + * @throws InvalidCursorException + */ + public function handle(Request $request, SessionInterface $session): Response + { + \assert($request instanceof ListSkillsRequest); + + $skills = $this->registry->all(); + + $offset = 0; + if (null !== $request->cursor) { + $decoded = base64_decode($request->cursor, true); + if (false === $decoded || !is_numeric($decoded)) { + throw new InvalidCursorException($request->cursor); + } + + $offset = (int) $decoded; + if ($offset < 0 || $offset > \count($skills)) { + throw new InvalidCursorException($request->cursor); + } + } + + $page = \array_slice($skills, $offset, $this->pageSize); + + $nextOffset = $offset + $this->pageSize; + $nextCursor = $nextOffset < \count($skills) ? base64_encode((string) $nextOffset) : null; + + return new Response($request->getId(), new ListSkillsResult($page, $nextCursor)); + } +} diff --git a/src/Server/Skill/FrontmatterParser.php b/src/Server/Skill/FrontmatterParser.php new file mode 100644 index 000000000..a3b499dfe --- /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 000000000..80e079036 --- /dev/null +++ b/src/Server/Skill/SkillProvider.php @@ -0,0 +1,279 @@ + + */ +final class SkillProvider +{ + /** Resources per skill this extension fixes as the limit every conforming host must accept. */ + private const MAX_RESOURCES_PER_SKILL = 512; + + /** Total file size per skill this extension fixes as the limit every conforming host must accept. */ + private const MAX_TOTAL_SIZE_PER_SKILL = 16_777_216; + + public function __construct( + private readonly FrontmatterParser $frontmatter = new FrontmatterParser(), + ) { + } + + /** + * Walks $baseDirectory, registers every discovered skill (and its supporting files) as + * `skill://` resources on $builder, and records each skill's manifest in $registry. + * + * @return Skill[] the discovered skills + * + * @throws InvalidArgumentException if the directory is missing, a skill violates the spec, or + * a skill exceeds the extension's per-skill resource/size limits + */ + public function registerInto(Builder $builder, SkillRegistry $registry, string $baseDirectory): 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)); + } + + $skills = []; + + foreach ($this->findSkillManifests($base) as $manifestPath) { + $skill = $this->registerSkill($builder, $base, $manifestPath); + $registry->add($skill); + $skills[] = $skill; + } + + return $skills; + } + + private function registerSkill(Builder $builder, string $base, string $manifestPath): Skill + { + $skillDir = \dirname($manifestPath); + $skillPath = $this->relativePath($base, $skillDir); + + $content = (string) file_get_contents($manifestPath); + $metadata = $this->frontmatter->parseMetadata($content); + + $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)); + } + + $entryUri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, McpSkills::ENTRY_POINT); + $entrySize = \strlen($content); + $entryDigest = 'sha256:'.hash('sha256', $content); + + $this->registerFile( + $builder, + $base, + $manifestPath, + $entryUri, + name: $metadata->name, + mimeType: McpSkills::MIME_TYPE, + description: $metadata->description, + size: $entrySize, + meta: $this->metaFor($metadata), + ); + + $resources = [new SkillResource($entryUri, $entryDigest, $entrySize)]; + + foreach ($this->findSupportingFiles($skillDir, $manifestPath) as $filePath) { + $relative = $this->relativePath($skillDir, $filePath); + $uri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, $relative); + $size = (int) filesize($filePath); + $digest = 'sha256:'.hash_file('sha256', $filePath); + + $this->registerFile( + $builder, + $base, + $filePath, + $uri, + name: basename($filePath), + mimeType: $this->guessMimeType($filePath), + description: null, + size: $size, + meta: null, + ); + + $resources[] = new SkillResource($uri, $digest, $size); + } + + $this->checkLimits($skillPath, $resources); + + return new Skill($entryUri, $metadata, $resources); + } + + /** + * @param SkillResource[] $resources + */ + private function checkLimits(string $skillPath, array $resources): void + { + if (\count($resources) > self::MAX_RESOURCES_PER_SKILL) { + throw new InvalidArgumentException(\sprintf('Skill "%s" has %d resources, exceeding the %d this extension fixes as the per-skill limit.', $skillPath, \count($resources), self::MAX_RESOURCES_PER_SKILL)); + } + + $totalSize = array_sum(array_map(static fn (SkillResource $r): int => $r->size, $resources)); + if ($totalSize > self::MAX_TOTAL_SIZE_PER_SKILL) { + throw new InvalidArgumentException(\sprintf('Skill "%s" totals %d bytes, exceeding the %d bytes this extension fixes as the per-skill limit.', $skillPath, $totalSize, self::MAX_TOTAL_SIZE_PER_SKILL)); + } + } + + /** + * @return array|null the `_meta` map for the skill's SKILL.md resource, each + * extra frontmatter field under its own {@see McpSkills::META_PREFIX}-prefixed key + */ + private function metaFor(SkillMetadata $metadata): ?array + { + if ([] === $metadata->extra) { + return null; + } + + $meta = []; + foreach ($metadata->extra as $key => $value) { + $meta[McpSkills::META_PREFIX.$key] = $value; + } + + return $meta; + } + + /** + * @param array|null $meta + */ + private function registerFile(Builder $builder, string $base, string $filePath, string $uri, string $name, string $mimeType, ?string $description, int $size, ?array $meta): 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)); + } + + $builder->addResource( + static fn (): \SplFileInfo => new \SplFileInfo($absolute), + $uri, + name: $name, + description: $description, + mimeType: $mimeType, + size: $size, + 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); + } + + 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/src/Server/Skill/SkillRegistry.php b/src/Server/Skill/SkillRegistry.php new file mode 100644 index 000000000..8fd254b79 --- /dev/null +++ b/src/Server/Skill/SkillRegistry.php @@ -0,0 +1,50 @@ + + */ +final class SkillRegistry +{ + /** + * @var array + */ + private array $skills = []; + + public function add(Skill $skill): void + { + $this->skills[$skill->uri] = $skill; + } + + /** + * @return list in registration order + */ + public function all(): array + { + return array_values($this->skills); + } + + public function get(string $uri): ?Skill + { + return $this->skills[$uri] ?? null; + } +} diff --git a/src/Server/Wire/Rev2026Codec.php b/src/Server/Wire/Rev2026Codec.php index d3b21efff..d8df038e5 100644 --- a/src/Server/Wire/Rev2026Codec.php +++ b/src/Server/Wire/Rev2026Codec.php @@ -42,6 +42,8 @@ final class Rev2026Codec implements WireCodecInterface 'resources/list', 'resources/templates/list', 'resources/read', + 'skills/list', + 'skills/get', ]; private readonly CachePolicy $cachePolicy; diff --git a/tests/Interop/Server/Stdio/StdioSkillsTest.php b/tests/Interop/Server/Stdio/StdioSkillsTest.php new file mode 100644 index 000000000..40b993594 --- /dev/null +++ b/tests/Interop/Server/Stdio/StdioSkillsTest.php @@ -0,0 +1,41 @@ + [ + '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', + ], + ]; + } + + protected function getServerScript(): string + { + return \dirname(__DIR__, 4).'/examples/server/skills/server.php'; + } +} diff --git a/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-prompts_list.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-prompts_list.json new file mode 100644 index 000000000..911451f22 --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-prompts_list.json @@ -0,0 +1,4 @@ +{ + "prompts": [] +} + diff --git a/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_list.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_list.json new file mode 100644 index 000000000..181234592 --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_list.json @@ -0,0 +1,39 @@ +{ + "resources": [ + { + "name": "refunds", + "uri": "skill://acme/billing/refunds/SKILL.md", + "description": "Process a customer refund following Acme's billing policy and approval thresholds.", + "mimeType": "text/markdown", + "size": 702, + "_meta": { + "io.modelcontextprotocol.skills/version": "1.0.0", + "io.modelcontextprotocol.skills/tags": [ + "billing", + "support" + ] + } + }, + { + "name": "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", + "size": 1158, + "_meta": { + "io.modelcontextprotocol.skills/version": "1.0.0", + "io.modelcontextprotocol.skills/tags": [ + "review", + "quality" + ] + } + }, + { + "name": "SECURITY.md", + "uri": "skill://code-review/references/SECURITY.md", + "mimeType": "text/markdown", + "size": 647 + } + ] +} + diff --git a/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json new file mode 100644 index 000000000..292d05429 --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_skill_md.json @@ -0,0 +1,17 @@ +{ + "contents": [ + { + "uri": "skill://code-review/SKILL.md", + "mimeType": "text/markdown", + "_meta": { + "io.modelcontextprotocol.skills/version": "1.0.0", + "io.modelcontextprotocol.skills/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/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json new file mode 100644 index 000000000..66bf40975 --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_read-read_supporting_file.json @@ -0,0 +1,10 @@ +{ + "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/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json new file mode 100644 index 000000000..b921e802f --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-resources_templates_list.json @@ -0,0 +1,4 @@ +{ + "resourceTemplates": [] +} + diff --git a/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-tools_list.json b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-tools_list.json new file mode 100644 index 000000000..87b6cf124 --- /dev/null +++ b/tests/Interop/Server/Stdio/snapshots/StdioSkillsTest-tools_list.json @@ -0,0 +1,4 @@ +{ + "tools": [] +} + diff --git a/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php new file mode 100644 index 000000000..f4a81bc01 --- /dev/null +++ b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php @@ -0,0 +1,61 @@ +assertSame('io.modelcontextprotocol/skills', (string) $extension->getId()); + $this->assertSame([], $extension->getCapabilities()); + } + + public function testCapabilitiesSerializeAsEmptyObject(): void + { + $capabilities = new ServerCapabilities(extensions: [McpSkills::EXTENSION_ID => (new McpSkills(new SkillRegistry()))->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 testDeclaresListAndGetMessages(): void + { + $extension = new McpSkills(new SkillRegistry()); + + $this->assertSame([ListSkillsRequest::class, GetSkillRequest::class], $extension->getMessages()); + } + + public function testServesListAndGetHandlers(): void + { + $extension = new McpSkills(new SkillRegistry()); + + $handlers = [...$extension->getRequestHandlers()]; + + $this->assertCount(2, $handlers); + $this->assertInstanceOf(ListSkillsHandler::class, $handlers[0]); + $this->assertInstanceOf(GetSkillHandler::class, $handlers[1]); + } +} diff --git a/tests/Unit/Schema/Extension/Skills/SkillMetadataTest.php b/tests/Unit/Schema/Extension/Skills/SkillMetadataTest.php new file mode 100644 index 000000000..03d7dd161 --- /dev/null +++ b/tests/Unit/Schema/Extension/Skills/SkillMetadataTest.php @@ -0,0 +1,58 @@ + '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 testFromArrayRequiresName(): void + { + $this->expectException(InvalidArgumentException::class); + + SkillMetadata::fromArray(['description' => 'no name here']); + } + + public function testFromArrayRequiresDescription(): void + { + $this->expectException(InvalidArgumentException::class); + + SkillMetadata::fromArray(['name' => 'refunds']); + } + + public function testSerializationMergesExtra(): 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/Schema/Extension/Skills/SkillResourceTest.php b/tests/Unit/Schema/Extension/Skills/SkillResourceTest.php new file mode 100644 index 000000000..964243bd6 --- /dev/null +++ b/tests/Unit/Schema/Extension/Skills/SkillResourceTest.php @@ -0,0 +1,66 @@ +assertSame([ + 'uri' => 'skill://code-review/SKILL.md', + 'digest' => 'sha256:'.hash('sha256', 'x'), + 'size' => 1, + ], $resource->jsonSerialize()); + } + + public function testFromArrayRoundTrip(): void + { + $digest = 'sha256:'.hash('sha256', 'x'); + + $resource = SkillResource::fromArray([ + 'uri' => 'skill://code-review/SKILL.md', + 'digest' => $digest, + 'size' => 1, + ]); + + $this->assertSame('skill://code-review/SKILL.md', $resource->uri); + $this->assertSame($digest, $resource->digest); + $this->assertSame(1, $resource->size); + } + + public function testRejectsMalformedDigest(): void + { + $this->expectException(InvalidArgumentException::class); + + new SkillResource('skill://code-review/SKILL.md', 'not-a-digest', 1); + } + + public function testRejectsUppercaseHexDigest(): void + { + $this->expectException(InvalidArgumentException::class); + + new SkillResource('skill://code-review/SKILL.md', 'sha256:'.strtoupper(hash('sha256', 'x')), 1); + } + + public function testRejectsNegativeSize(): void + { + $this->expectException(InvalidArgumentException::class); + + new SkillResource('skill://code-review/SKILL.md', 'sha256:'.hash('sha256', 'x'), -1); + } +} diff --git a/tests/Unit/Schema/Extension/Skills/SkillTest.php b/tests/Unit/Schema/Extension/Skills/SkillTest.php new file mode 100644 index 000000000..ef2ddc14f --- /dev/null +++ b/tests/Unit/Schema/Extension/Skills/SkillTest.php @@ -0,0 +1,97 @@ +jsonSerialize(); + + $this->assertSame('skill://code-review/SKILL.md', $serialized['uri']); + $this->assertInstanceOf(SkillMetadata::class, $serialized['frontmatter']); + $this->assertCount(1, $serialized['resources']); + $this->assertInstanceOf(SkillResource::class, $serialized['resources'][0]); + } + + public function testSerializationWithDynamicResources(): void + { + $skill = new Skill( + 'skill://reports/daily/SKILL.md', + new SkillMetadata('daily', 'Assemble a report.'), + 'dynamic', + ); + + $this->assertSame('dynamic', $skill->jsonSerialize()['resources']); + } + + public function testRejectsNonListResourcesArray(): void + { + $this->expectException(InvalidArgumentException::class); + + new Skill( + 'skill://code-review/SKILL.md', + new SkillMetadata('code-review', 'Review a pull request.'), + ['not-a-list' => new SkillResource('skill://code-review/SKILL.md', 'sha256:'.hash('sha256', 'x'), 1)], + ); + } + + public function testFromArrayRoundTrip(): void + { + $skill = Skill::fromArray([ + 'uri' => 'skill://acme/billing/refunds/SKILL.md', + 'frontmatter' => ['name' => 'refunds', 'description' => 'Process refunds.'], + 'resources' => [ + ['uri' => 'skill://acme/billing/refunds/SKILL.md', 'digest' => 'sha256:'.hash('sha256', 'x'), 'size' => 1], + ], + ]); + + $this->assertSame('refunds', $skill->frontmatter->name); + $this->assertIsArray($skill->resources); + $this->assertCount(1, $skill->resources); + } + + public function testFromArrayAcceptsDynamicResources(): void + { + $skill = Skill::fromArray([ + 'uri' => 'skill://reports/daily/SKILL.md', + 'frontmatter' => ['name' => 'daily', 'description' => 'Assemble a report.'], + 'resources' => 'dynamic', + ]); + + $this->assertSame('dynamic', $skill->resources); + } + + public function testFromArrayRejectsInvalidResources(): void + { + $this->expectException(InvalidArgumentException::class); + + /* @phpstan-ignore argument.type (deliberately invalid: neither an array nor "dynamic" must be rejected) */ + Skill::fromArray([ + 'uri' => 'skill://reports/daily/SKILL.md', + 'frontmatter' => ['name' => 'daily', 'description' => 'Assemble a report.'], + 'resources' => 'not-dynamic', + ]); + } +} diff --git a/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php b/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php new file mode 100644 index 000000000..d7a92c689 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php @@ -0,0 +1,71 @@ +registry = new SkillRegistry(); + $this->session = new Session(new InMemorySessionStore()); + } + + public function testSupportsGetSkillRequest(): void + { + $handler = new GetSkillHandler($this->registry); + + $this->assertTrue($handler->supports($this->request('skill://code-review/SKILL.md'))); + } + + public function testReturnsTheMatchingSkill(): void + { + $skill = new Skill('skill://code-review/SKILL.md', new SkillMetadata('code-review', 'Review a PR.'), 'dynamic'); + $this->registry->add($skill); + $handler = new GetSkillHandler($this->registry); + + $response = $handler->handle($this->request('skill://code-review/SKILL.md'), $this->session); + + /** @var GetSkillResult $result */ + $result = $response->result; + $this->assertSame($skill, $result->skill); + } + + public function testThrowsInvalidArgumentForUnknownSkill(): void + { + $handler = new GetSkillHandler($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No skill is served at skill://unknown/SKILL.md'); + + $handler->handle($this->request('skill://unknown/SKILL.md'), $this->session); + } + + private function request(string $uri): GetSkillRequest + { + return (new GetSkillRequest($uri))->withId('test-request-id'); + } +} diff --git a/tests/Unit/Server/Handler/Request/Skills/ListSkillsHandlerTest.php b/tests/Unit/Server/Handler/Request/Skills/ListSkillsHandlerTest.php new file mode 100644 index 000000000..43fe3be74 --- /dev/null +++ b/tests/Unit/Server/Handler/Request/Skills/ListSkillsHandlerTest.php @@ -0,0 +1,122 @@ +registry = new SkillRegistry(); + $this->session = new Session(new InMemorySessionStore()); + } + + public function testSupportsListSkillsRequest(): void + { + $handler = new ListSkillsHandler($this->registry); + + $this->assertTrue($handler->supports($this->request())); + } + + public function testReturnsFirstPage(): void + { + $this->addSkills(5); + $handler = new ListSkillsHandler($this->registry, pageSize: 3); + + $response = $handler->handle($this->request(), $this->session); + + /** @var ListSkillsResult $result */ + $result = $response->result; + $this->assertCount(3, $result->skills); + $this->assertNotNull($result->nextCursor); + $this->assertSame('skill://skill-0/SKILL.md', $result->skills[0]->uri); + $this->assertSame('skill://skill-2/SKILL.md', $result->skills[2]->uri); + } + + public function testReturnsSecondPageWithCursor(): void + { + $this->addSkills(5); + $handler = new ListSkillsHandler($this->registry, pageSize: 3); + + $firstPage = $handler->handle($this->request(), $this->session)->result; + \assert($firstPage instanceof ListSkillsResult); + + $secondPage = $handler->handle($this->request($firstPage->nextCursor), $this->session)->result; + \assert($secondPage instanceof ListSkillsResult); + + $this->assertCount(2, $secondPage->skills); + $this->assertNull($secondPage->nextCursor); + $this->assertSame('skill://skill-3/SKILL.md', $secondPage->skills[0]->uri); + $this->assertSame('skill://skill-4/SKILL.md', $secondPage->skills[1]->uri); + } + + public function testHandlesEmptyRegistry(): void + { + $handler = new ListSkillsHandler($this->registry); + + $result = $handler->handle($this->request(), $this->session)->result; + \assert($result instanceof ListSkillsResult); + + $this->assertSame([], $result->skills); + $this->assertNull($result->nextCursor); + } + + public function testThrowsForInvalidCursor(): void + { + $this->addSkills(5); + $handler = new ListSkillsHandler($this->registry); + + $this->expectException(InvalidCursorException::class); + + $handler->handle($this->request('not-base64!!'), $this->session); + } + + public function testThrowsForCursorBeyondBounds(): void + { + $this->addSkills(5); + $handler = new ListSkillsHandler($this->registry); + + $this->expectException(InvalidCursorException::class); + + $handler->handle($this->request(base64_encode('100')), $this->session); + } + + private function request(?string $cursor = null): ListSkillsRequest + { + return (new ListSkillsRequest($cursor))->withId('test-request-id'); + } + + private function addSkills(int $count): void + { + for ($i = 0; $i < $count; ++$i) { + $this->registry->add(new Skill( + "skill://skill-$i/SKILL.md", + new SkillMetadata("skill-$i", "Skill number $i."), + 'dynamic', + )); + } + } +} 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 000000000..a325c28e9 --- /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 000000000..629f21c0d --- /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 000000000..210a9927d --- /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 000000000..305f56acc --- /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 000000000..54cbbab75 --- /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 000000000..0f20874a9 --- /dev/null +++ b/tests/Unit/Server/Skill/SkillProviderTest.php @@ -0,0 +1,256 @@ +registerInto($builder, new SkillRegistry(), self::FIXTURES); + + $uris = array_column($this->registeredResources($builder), '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); + } + + public function testSkillManifestResourceUsesFrontmatterNameAndDescription(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, new SkillRegistry(), self::FIXTURES); + + $resource = $this->resourceByUri($builder, 'skill://code-review/SKILL.md'); + + $this->assertSame(McpSkills::MIME_TYPE, $resource['mimeType']); + $this->assertSame('code-review', $resource['name']); + $this->assertSame('Review a pull request.', $resource['description']); + $this->assertSame( + ['io.modelcontextprotocol.skills/version' => '1.0.0', 'io.modelcontextprotocol.skills/tags' => ['review']], + $resource['meta'], + ); + } + + public function testSupportingFileHasNoExtraMeta(): void + { + $builder = Server::builder(); + + (new SkillProvider())->registerInto($builder, new SkillRegistry(), self::FIXTURES); + + $resource = $this->resourceByUri($builder, 'skill://code-review/references/SECURITY.md'); + + $this->assertSame('text/markdown', $resource['mimeType']); + $this->assertNull($resource['meta']); + } + + public function testReturnsSkillsWithCompleteResourceManifest(): void + { + $builder = Server::builder(); + + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), self::FIXTURES); + + $this->assertCount(2, $skills); + + $codeReview = current(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name)); + $this->assertNotFalse($codeReview); + $this->assertIsArray($codeReview->resources); + $this->assertCount(2, $codeReview->resources); // SKILL.md + references/SECURITY.md + + $manifestEntry = current(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri)); + $this->assertNotFalse($manifestEntry); + $this->assertMatchesRegularExpression('/^sha256:[0-9a-f]{64}$/', $manifestEntry->digest); + $this->assertGreaterThan(0, $manifestEntry->size); + } + + public function testDigestMatchesServedManifestBytes(): void + { + $builder = Server::builder(); + + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), self::FIXTURES); + $codeReview = current(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name)); + + $resource = $this->resourceByUri($builder, $codeReview->uri); + /** @var \SplFileInfo $file */ + $file = ($resource['handler'])(); + $served = (string) file_get_contents($file->getPathname()); + + $manifestEntry = current(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri)); + $this->assertSame('sha256:'.hash('sha256', $served), $manifestEntry->digest); + $this->assertSame(\strlen($served), $manifestEntry->size); + } + + public function testRegistersEntriesIntoRegistry(): void + { + $builder = Server::builder(); + $registry = new SkillRegistry(); + + (new SkillProvider())->registerInto($builder, $registry, self::FIXTURES); + + $this->assertNotNull($registry->get('skill://code-review/SKILL.md')); + $this->assertNotNull($registry->get('skill://acme/billing/refunds/SKILL.md')); + } + + public function testThrowsWhenFrontmatterNameDoesNotMatchFolder(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must match the final path segment'); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), __DIR__.'/Fixtures/mismatch'); + } + + public function testThrowsWhenDirectoryDoesNotExist(): void + { + $this->expectException(InvalidArgumentException::class); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), __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); + } + + public function testBuilderHelperAccumulatesAcrossCalls(): void + { + $dir1 = $this->makeTempDir(); + $dir2 = $this->makeTempDir(); + mkdir($dir1.'/skill-a', 0777, true); + file_put_contents($dir1.'/skill-a/SKILL.md', "---\nname: skill-a\ndescription: First skill.\n---\nbody"); + mkdir($dir2.'/skill-b', 0777, true); + file_put_contents($dir2.'/skill-b/SKILL.md', "---\nname: skill-b\ndescription: Second skill.\n---\nbody"); + + try { + $builder = Server::builder() + ->addSkillsFromDirectory($dir1) + ->addSkillsFromDirectory($dir2); + + $registry = $this->readPrivate($builder, 'skillRegistry'); + $this->assertNotNull($registry->get('skill://skill-a/SKILL.md')); + $this->assertNotNull($registry->get('skill://skill-b/SKILL.md')); + + $extensions = $this->readPrivate($builder, 'extensions'); + $this->assertCount(1, $extensions); + } finally { + $this->removeDir($dir1); + $this->removeDir($dir2); + } + } + + public function testThrowsWhenSkillExceedsResourceCountLimit(): void + { + $dir = $this->makeTempDir(); + $skillDir = $dir.'/many/many'; + mkdir($skillDir, 0777, true); + file_put_contents($skillDir.'/SKILL.md', "---\nname: many\ndescription: Too many files.\n---\nbody"); + for ($i = 0; $i < 512; ++$i) { + file_put_contents($skillDir.\sprintf('/file-%03d.txt', $i), 'x'); + } + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exceeding the 512'); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + } finally { + $this->removeDir($dir); + } + } + + public function testThrowsWhenSkillExceedsTotalSizeLimit(): void + { + $dir = $this->makeTempDir(); + $skillDir = $dir.'/big/big'; + mkdir($skillDir, 0777, true); + file_put_contents($skillDir.'/SKILL.md', "---\nname: big\ndescription: Too big.\n---\nbody"); + + $handle = fopen($skillDir.'/blob.bin', 'w'); + \assert(false !== $handle); + fseek($handle, 17_000_000 - 1); + fwrite($handle, "\0"); + fclose($handle); + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exceeding the 16777216 bytes'); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + } finally { + $this->removeDir($dir); + } + } + + /** + * @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); + } + + private function makeTempDir(): string + { + $dir = sys_get_temp_dir().'/mcp-skill-provider-test-'.bin2hex(random_bytes(8)); + mkdir($dir, 0777, true); + + return $dir; + } + + private function removeDir(string $dir): void + { + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($items as $item) { + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + rmdir($dir); + } +} diff --git a/tests/Unit/Server/Skill/SkillRegistryTest.php b/tests/Unit/Server/Skill/SkillRegistryTest.php new file mode 100644 index 000000000..a41da5ba4 --- /dev/null +++ b/tests/Unit/Server/Skill/SkillRegistryTest.php @@ -0,0 +1,62 @@ +add($skill); + + $this->assertSame($skill, $registry->get('skill://code-review/SKILL.md')); + } + + public function testGetReturnsNullForUnknownUri(): void + { + $registry = new SkillRegistry(); + + $this->assertNull($registry->get('skill://unknown/SKILL.md')); + } + + public function testAllReturnsInRegistrationOrder(): void + { + $registry = new SkillRegistry(); + $first = new Skill('skill://a/SKILL.md', new SkillMetadata('a', 'A.'), 'dynamic'); + $second = new Skill('skill://b/SKILL.md', new SkillMetadata('b', 'B.'), 'dynamic'); + + $registry->add($first); + $registry->add($second); + + $this->assertSame([$first, $second], $registry->all()); + } + + public function testAddOverwritesSameUri(): void + { + $registry = new SkillRegistry(); + $original = new Skill('skill://a/SKILL.md', new SkillMetadata('a', 'Original.'), 'dynamic'); + $updated = new Skill('skill://a/SKILL.md', new SkillMetadata('a', 'Updated.'), 'dynamic'); + + $registry->add($original); + $registry->add($updated); + + $this->assertCount(1, $registry->all()); + $this->assertSame($updated, $registry->get('skill://a/SKILL.md')); + } +} From e2bbed6494cf7a7a483c96f80bc6f1d354269769 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sun, 13 Sep 2026 21:11:17 +0200 Subject: [PATCH 2/5] [Schema][Server] Close review gaps in the Skills extension: full-dispatch coverage, deterministic nested-skill registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skills/list and skills/get were only tested by calling the handlers directly, so nothing exercised the actual MessageFactory/StatelessProtocol/Rev2026Codec dispatch path or asserted the mandatory resultType/ttlMs/cacheScope envelope. Added a dispatch test that builds a real server and sends JSON-RPC requests over StatelessProtocol, covering both methods, the -32602 case, and the capability advertisement. SkillProvider also registered a nested skill's own SKILL.md twice as a servable resource: once as an anonymous supporting file of the enclosing skill (name from basename, no description) and once under its own frontmatter as its own top-level skill. Since the registry keeps the last registration for a given URI, which metadata won depended on filesystem sort order between the two directory names. The nested manifest is now registered exactly once, by its own entry, while still being listed in the enclosing skill's resources manifest as the spec requires. Added a nested-skill fixture pinning this. Clarified the McpSkills docblock: addSkillsFromDirectory() and manually enabling the extension are alternatives, not composable — combining them throws because the convenience method always tries to enable its own instance. --- src/Schema/Extension/Skills/McpSkills.php | 8 +- src/Server/Skill/SkillProvider.php | 29 +++-- .../Schema/Extension/Skills/McpSkillsTest.php | 2 +- .../Schema/Extension/Skills/SkillTest.php | 6 +- .../Request/Skills/GetSkillHandlerTest.php | 3 + .../Fixtures/nested/parent-skill/SKILL.md | 5 + .../nested/parent-skill/nested-skill/SKILL.md | 5 + tests/Unit/Server/Skill/SkillProviderTest.php | 47 +++++++- .../Unit/Server/Skill/SkillsDispatchTest.php | 109 ++++++++++++++++++ 9 files changed, 194 insertions(+), 20 deletions(-) create mode 100644 tests/Unit/Server/Skill/Fixtures/nested/parent-skill/SKILL.md create mode 100644 tests/Unit/Server/Skill/Fixtures/nested/parent-skill/nested-skill/SKILL.md create mode 100644 tests/Unit/Server/Skill/SkillsDispatchTest.php diff --git a/src/Schema/Extension/Skills/McpSkills.php b/src/Schema/Extension/Skills/McpSkills.php index adc7c4eb8..cf413d43f 100644 --- a/src/Schema/Extension/Skills/McpSkills.php +++ b/src/Schema/Extension/Skills/McpSkills.php @@ -27,8 +27,12 @@ * manifest of a skill's files, so a host can build its registry, present a skill for approval, and * verify every later read without first fetching the files. * - * 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. + * Enable on the server either via {@see \Mcp\Server\Builder::addSkillsFromDirectory()}, which + * builds and owns the {@see SkillRegistry} for you, or by constructing a `SkillRegistry` yourself, + * registering it with {@see \Mcp\Server\Builder::enableExtension()}, and feeding it via + * {@see \Mcp\Server\Skill\SkillProvider::registerInto()} for full control. Pick one: calling + * `addSkillsFromDirectory()` after the extension is already enabled throws, since it always tries + * to enable its own instance. * * `resources/directory/read`, gated behind the `directoryRead` capability setting, is not yet * implemented and is not declared. diff --git a/src/Server/Skill/SkillProvider.php b/src/Server/Skill/SkillProvider.php index 80e079036..ce510a1c6 100644 --- a/src/Server/Skill/SkillProvider.php +++ b/src/Server/Skill/SkillProvider.php @@ -111,17 +111,24 @@ private function registerSkill(Builder $builder, string $base, string $manifestP $size = (int) filesize($filePath); $digest = 'sha256:'.hash_file('sha256', $filePath); - $this->registerFile( - $builder, - $base, - $filePath, - $uri, - name: basename($filePath), - mimeType: $this->guessMimeType($filePath), - description: null, - size: $size, - meta: null, - ); + // A nested skill's own SKILL.md is a supporting file of this skill's manifest too + // (the spec allows the same file in both entries), but it must be registered as a + // resource exactly once, by its own registerSkill() call below with its own + // frontmatter — registering it again here, generically, would make which metadata + // wins depend on filesystem walk order instead of always being the nested skill's own. + if (McpSkills::ENTRY_POINT !== basename($filePath)) { + $this->registerFile( + $builder, + $base, + $filePath, + $uri, + name: basename($filePath), + mimeType: $this->guessMimeType($filePath), + description: null, + size: $size, + meta: null, + ); + } $resources[] = new SkillResource($uri, $digest, $size); } diff --git a/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php index f4a81bc01..440b6992d 100644 --- a/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php +++ b/tests/Unit/Schema/Extension/Skills/McpSkillsTest.php @@ -34,7 +34,7 @@ public function testCapabilitiesSerializeAsEmptyObject(): void { $capabilities = new ServerCapabilities(extensions: [McpSkills::EXTENSION_ID => (new McpSkills(new SkillRegistry()))->getCapabilities()]); - $json = json_encode($capabilities, \JSON_UNESCAPED_SLASHES); + $json = json_encode($capabilities, \JSON_UNESCAPED_SLASHES | \JSON_THROW_ON_ERROR); // The empty extension payload MUST serialize to `{}`, not `[]`. $this->assertStringContainsString('"io.modelcontextprotocol/skills":{}', $json); diff --git a/tests/Unit/Schema/Extension/Skills/SkillTest.php b/tests/Unit/Schema/Extension/Skills/SkillTest.php index ef2ddc14f..d19b81550 100644 --- a/tests/Unit/Schema/Extension/Skills/SkillTest.php +++ b/tests/Unit/Schema/Extension/Skills/SkillTest.php @@ -31,8 +31,10 @@ public function testSerializationWithResourceList(): void $this->assertSame('skill://code-review/SKILL.md', $serialized['uri']); $this->assertInstanceOf(SkillMetadata::class, $serialized['frontmatter']); - $this->assertCount(1, $serialized['resources']); - $this->assertInstanceOf(SkillResource::class, $serialized['resources'][0]); + $resources = $serialized['resources']; + $this->assertIsArray($resources); + $this->assertCount(1, $resources); + $this->assertInstanceOf(SkillResource::class, $resources[0]); } public function testSerializationWithDynamicResources(): void diff --git a/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php b/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php index d7a92c689..3a5d8d3cd 100644 --- a/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php +++ b/tests/Unit/Server/Handler/Request/Skills/GetSkillHandlerTest.php @@ -64,6 +64,9 @@ public function testThrowsInvalidArgumentForUnknownSkill(): void $handler->handle($this->request('skill://unknown/SKILL.md'), $this->session); } + /** + * @param non-empty-string $uri + */ private function request(string $uri): GetSkillRequest { return (new GetSkillRequest($uri))->withId('test-request-id'); diff --git a/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/SKILL.md b/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/SKILL.md new file mode 100644 index 000000000..dc4e5f80b --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: parent-skill +description: A skill that has a nested skill inside it. +--- +Do the outer thing. diff --git a/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/nested-skill/SKILL.md b/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/nested-skill/SKILL.md new file mode 100644 index 000000000..f8cf9488c --- /dev/null +++ b/tests/Unit/Server/Skill/Fixtures/nested/parent-skill/nested-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: nested-skill +description: A skill nested inside another skill's directory. +--- +Do the inner thing. diff --git a/tests/Unit/Server/Skill/SkillProviderTest.php b/tests/Unit/Server/Skill/SkillProviderTest.php index 0f20874a9..fc9ba5aa4 100644 --- a/tests/Unit/Server/Skill/SkillProviderTest.php +++ b/tests/Unit/Server/Skill/SkillProviderTest.php @@ -74,12 +74,13 @@ public function testReturnsSkillsWithCompleteResourceManifest(): void $this->assertCount(2, $skills); - $codeReview = current(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name)); + $codeReview = array_values(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name))[0]; + $this->assertIsArray($codeReview->resources); $this->assertNotFalse($codeReview); $this->assertIsArray($codeReview->resources); $this->assertCount(2, $codeReview->resources); // SKILL.md + references/SECURITY.md - $manifestEntry = current(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri)); + $manifestEntry = array_values(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri))[0]; $this->assertNotFalse($manifestEntry); $this->assertMatchesRegularExpression('/^sha256:[0-9a-f]{64}$/', $manifestEntry->digest); $this->assertGreaterThan(0, $manifestEntry->size); @@ -90,14 +91,15 @@ public function testDigestMatchesServedManifestBytes(): void $builder = Server::builder(); $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), self::FIXTURES); - $codeReview = current(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name)); + $codeReview = array_values(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name))[0]; + $this->assertIsArray($codeReview->resources); $resource = $this->resourceByUri($builder, $codeReview->uri); /** @var \SplFileInfo $file */ $file = ($resource['handler'])(); $served = (string) file_get_contents($file->getPathname()); - $manifestEntry = current(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri)); + $manifestEntry = array_values(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri))[0]; $this->assertSame('sha256:'.hash('sha256', $served), $manifestEntry->digest); $this->assertSame(\strlen($served), $manifestEntry->size); } @@ -113,6 +115,43 @@ public function testRegistersEntriesIntoRegistry(): void $this->assertNotNull($registry->get('skill://acme/billing/refunds/SKILL.md')); } + public function testNestedSkillIsRegisteredOnceWithItsOwnMetadata(): void + { + $builder = Server::builder(); + + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), __DIR__.'/Fixtures/nested'); + + $this->assertCount(2, $skills); + + // Registered as a resource exactly once, under its own frontmatter — not the generic + // "supporting file" metadata a naive walk of the parent skill's directory would produce. + $resource = $this->resourceByUri($builder, 'skill://parent-skill/nested-skill/SKILL.md'); + $this->assertSame('nested-skill', $resource['name']); + $this->assertSame('A skill nested inside another skill\'s directory.', $resource['description']); + + $uris = array_column($this->registeredResources($builder), 'uri'); + $this->assertCount(1, array_filter($uris, static fn ($uri) => 'skill://parent-skill/nested-skill/SKILL.md' === $uri)); + } + + public function testNestedSkillManifestListsItInBothEntries(): void + { + $builder = Server::builder(); + + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), __DIR__.'/Fixtures/nested'); + + $parent = array_values(array_filter($skills, static fn ($s) => 'parent-skill' === $s->frontmatter->name))[0]; + $nested = array_values(array_filter($skills, static fn ($s) => 'nested-skill' === $s->frontmatter->name))[0]; + $this->assertNotFalse($parent); + $this->assertNotFalse($nested); + + $this->assertIsArray($parent->resources); + $this->assertCount(2, $parent->resources); // its own SKILL.md + the nested one + + $this->assertIsArray($nested->resources); + $this->assertCount(1, $nested->resources); // just its own SKILL.md + $this->assertSame('skill://parent-skill/nested-skill/SKILL.md', $nested->resources[0]->uri); + } + public function testThrowsWhenFrontmatterNameDoesNotMatchFolder(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Unit/Server/Skill/SkillsDispatchTest.php b/tests/Unit/Server/Skill/SkillsDispatchTest.php new file mode 100644 index 000000000..faf6344f1 --- /dev/null +++ b/tests/Unit/Server/Skill/SkillsDispatchTest.php @@ -0,0 +1,109 @@ + MessageFactory -> StatelessProtocol -> Rev2026Codec), rather + * than calling the handlers directly, so a wiring mistake in + * {@see \Mcp\Schema\Extension\Skills\McpSkills::getMessages()} or in + * {@see Server\Wire\Rev2026Codec::CACHEABLE_METHODS} would fail a test. + */ +class SkillsDispatchTest extends TestCase +{ + private const FIXTURES = __DIR__.'/Fixtures/skills'; + + #[TestDox('skills/list is served with a cacheable envelope')] + public function testSkillsListIsServed(): void + { + $answer = self::call(self::protocol(), 'skills/list'); + + $this->assertSame(200, $answer['status']); + $this->assertSame('complete', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayHasKey('cacheScope', $answer['body']['result']); + $uris = array_column($answer['body']['result']['skills'], 'uri'); + $this->assertContains('skill://code-review/SKILL.md', $uris); + $this->assertContains('skill://acme/billing/refunds/SKILL.md', $uris); + } + + #[TestDox('skills/get returns the matching skill with a cacheable envelope')] + public function testSkillsGetIsServed(): void + { + $answer = self::call(self::protocol(), 'skills/get', ['uri' => 'skill://code-review/SKILL.md']); + + $this->assertSame(200, $answer['status']); + $this->assertSame('complete', $answer['body']['result']['resultType']); + $this->assertArrayHasKey('ttlMs', $answer['body']['result']); + $this->assertArrayHasKey('cacheScope', $answer['body']['result']); + $this->assertSame('skill://code-review/SKILL.md', $answer['body']['result']['skill']['uri']); + $this->assertSame('code-review', $answer['body']['result']['skill']['frontmatter']['name']); + } + + #[TestDox('skills/get on an unknown uri answers Invalid params')] + public function testSkillsGetUnknownUriIsInvalidParams(): void + { + $answer = self::call(self::protocol(), 'skills/get', ['uri' => 'skill://unknown/SKILL.md']); + + $this->assertSame(400, $answer['status']); + $this->assertSame(-32602, $answer['body']['error']['code']); + } + + #[TestDox('the extension is advertised under capabilities.extensions')] + public function testSkillsExtensionIsAdvertised(): void + { + $answer = self::call(self::protocol(), 'server/discover'); + + $this->assertArrayHasKey('io.modelcontextprotocol/skills', (array) $answer['body']['result']['capabilities']['extensions']); + } + + private static function protocol(): StatelessProtocol + { + return Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addSkillsFromDirectory(self::FIXTURES) + ->buildStateless([ProtocolVersion::V2026_07_28]); + } + + /** + * @param array $params + * + * @return array{status: int, body: array} + */ + private static function call(StatelessProtocol $protocol, string $method, array $params = []): array + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + $result = $protocol->handle( + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR), + [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => $method, + ], + ); + + return [ + 'status' => $result->httpStatus, + 'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR), + ]; + } +} From 48bb1b000d9ed4d0badf06eb7bc119c50b3926c9 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Tue, 15 Sep 2026 19:40:04 +0200 Subject: [PATCH 3/5] [Docs] Add Skills extension entry to CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b51010568..29d83489e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -95,6 +95,7 @@ All notable changes to `mcp/sdk` will be documented in this file. * Fix variadic tool parameters (e.g. `int ...$scores`) failing with a cast error: each element of the array a client sends is now cast on its own. * Fix `Builder::build()` with a registry passed to `setRegistry()` publishing one `list_changed` notification per loaded element. Adds `Registry::loadFrom()`. +* Add the Skills extension (`io.modelcontextprotocol/skills`, SEP-2640). `Builder::addSkillsFromDirectory()` serves a directory of `SKILL.md` skills as `skill://` resources, and `skills/list`/`skills/get` answer with a complete, digest-and-size manifest per skill. 0.8.0 ----- From 99745f41ce51b46d7f631ab3c742016fb3eb9ca4 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sat, 17 Oct 2026 20:20:35 +0200 Subject: [PATCH 4/5] [Server] Align skills registration with the MUST rules of SEP-2640 The frontmatter sent in a skill entry now keeps empty mappings as objects, so a host comparing it with the file sees identical content. Names must follow the Agent Skills rules and descriptions are limited to 1024 characters. The resources capability is always declared with the extension, and explicit capabilities without it are rejected. File discovery no longer depends on symfony/finder. Hidden files and folders are skipped, so `.env` and `.git/` are never listed or served. File and folder names are percent-encoded in skill URIs. --- docs/advanced/extensions.md | 17 +- src/Server/Builder.php | 10 +- src/Server/Skill/FrontmatterParser.php | 8 +- src/Server/Skill/SkillProvider.php | 67 +++++--- .../Server/Skill/FrontmatterParserTest.php | 26 +++ tests/Unit/Server/Skill/SkillProviderTest.php | 160 +++++++++++++++++- .../Unit/Server/Skill/SkillsDispatchTest.php | 94 ++++++++++ 7 files changed, 350 insertions(+), 32 deletions(-) diff --git a/docs/advanced/extensions.md b/docs/advanced/extensions.md index fc04921a6..2a3f7e36a 100644 --- a/docs/advanced/extensions.md +++ b/docs/advanced/extensions.md @@ -254,8 +254,21 @@ tags: [review, quality] ``` > The frontmatter `name` **must** equal the final segment of the skill's directory -> path (`code-review/` → `name: code-review`), and `description` is required; a -> violation throws an `InvalidArgumentException`. +> path (`code-review/` → `name: code-review`), and `description` is required. The +> name follows the Agent Skills rules: 1 to 64 characters of lowercase letters, +> digits and single hyphens, with no hyphen at the start or end. The description +> has at most 1024 characters. A violation throws an `InvalidArgumentException`. + +Hidden files and folders (`.env`, `.git/`) are neither listed nor served. File +and folder names are percent-encoded in the URIs, so `my docs/a b.md` becomes +`skill://my-skill/my%20docs/a%20b.md`. + +The server declares the `resources` capability whenever the extension is enabled. +`setCapabilities()` therefore has to keep `resources` on. + +The frontmatter is sent as parsed, so a host that compares it with the file sees +the same content. One limit: YAML turns an unquoted date like `2025-01-02` into a +timestamp. Quote such values (`"2025-01-02"`) to keep them as strings. The extension fixes two per-skill limits so every conforming host knows what it must accept: 512 resources and 16 MiB total content. `addSkillsFromDirectory()` diff --git a/src/Server/Builder.php b/src/Server/Builder.php index 6d509d4e3..6d9504be6 100644 --- a/src/Server/Builder.php +++ b/src/Server/Builder.php @@ -1129,6 +1129,12 @@ private function resolve(): array $messageFactory = MessageFactory::make(additional: $this->extensionMessages); + // SEP-2640: a server declaring the skills extension MUST also declare `resources`, because + // every skill file is served through resources/read. + if (null !== $this->serverCapabilities && null !== $this->skillRegistry && true !== $this->serverCapabilities->resources) { + throw new LogicException('The skills extension serves skills as resources, so the capabilities passed to setCapabilities() must declare "resources".'); + } + $capabilities = $this->serverCapabilities ?? $this->detectCapabilities($registry, $eagerlyLoaded); // Extensions enabled via enableExtension() are folded into caller-supplied @@ -1192,7 +1198,7 @@ private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLo $listChanged = null !== $this->notificationBus && !$this->hasCustomRegistry; if ($eagerlyLoaded) { - $hasResources = $registry->hasResources() || $registry->hasResourceTemplates(); + $hasResources = $registry->hasResources() || $registry->hasResourceTemplates() || null !== $this->skillRegistry; return new ServerCapabilities( tools: $registry->hasTools(), @@ -1209,7 +1215,7 @@ private function detectCapabilities(RegistryInterface $registry, bool $eagerlyLo } $hasOpaqueSources = [] !== $this->loaders || null !== $this->discoveryBasePath; - $hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources; + $hasResources = [] !== $this->resources || [] !== $this->explicitResources || [] !== $this->resourceTemplates || [] !== $this->explicitResourceTemplates || $hasOpaqueSources || null !== $this->skillRegistry; return new ServerCapabilities( tools: [] !== $this->tools || [] !== $this->explicitTools || $hasOpaqueSources, diff --git a/src/Server/Skill/FrontmatterParser.php b/src/Server/Skill/FrontmatterParser.php index a3b499dfe..6dd3ee265 100644 --- a/src/Server/Skill/FrontmatterParser.php +++ b/src/Server/Skill/FrontmatterParser.php @@ -43,8 +43,12 @@ public function parse(string $content): array 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))) { + // Nested mappings stay objects, so an empty `metadata: {}` is sent as `{}` and not as `[]`. + // The spec requires the frontmatter in a skill entry to be identical in content to the file's. + $data = Yaml::parse($matches[1], Yaml::PARSE_OBJECT_FOR_MAP) ?? []; + if ($data instanceof \stdClass) { + $data = get_object_vars($data); + } elseif (!\is_array($data) || ([] !== $data && array_is_list($data))) { throw new InvalidArgumentException('SKILL.md frontmatter must be a YAML mapping.'); } diff --git a/src/Server/Skill/SkillProvider.php b/src/Server/Skill/SkillProvider.php index ce510a1c6..a9b709f5b 100644 --- a/src/Server/Skill/SkillProvider.php +++ b/src/Server/Skill/SkillProvider.php @@ -17,7 +17,6 @@ use Mcp\Schema\Extension\Skills\SkillMetadata; use Mcp\Schema\Extension\Skills\SkillResource; use Mcp\Server\Builder; -use Symfony\Component\Finder\Finder; /** * Exposes a directory of skills as `skill://` resources on a {@see Builder}, and registers each @@ -42,6 +41,12 @@ final class SkillProvider /** Total file size per skill this extension fixes as the limit every conforming host must accept. */ private const MAX_TOTAL_SIZE_PER_SKILL = 16_777_216; + /** Longest skill name the Agent Skills specification allows. */ + private const MAX_NAME_LENGTH = 64; + + /** Longest skill description the Agent Skills specification allows. */ + private const MAX_DESCRIPTION_LENGTH = 1024; + public function __construct( private readonly FrontmatterParser $frontmatter = new FrontmatterParser(), ) { @@ -87,7 +92,9 @@ private function registerSkill(Builder $builder, string $base, string $manifestP throw new InvalidArgumentException(\sprintf('Skill at "%s": frontmatter name "%s" must match the final path segment "%s".', $skillPath, $metadata->name, $lastSegment)); } - $entryUri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, McpSkills::ENTRY_POINT); + $this->validateMetadata($skillPath, $metadata); + + $entryUri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $this->encodePath($skillPath), McpSkills::ENTRY_POINT); $entrySize = \strlen($content); $entryDigest = 'sha256:'.hash('sha256', $content); @@ -107,7 +114,7 @@ private function registerSkill(Builder $builder, string $base, string $manifestP foreach ($this->findSupportingFiles($skillDir, $manifestPath) as $filePath) { $relative = $this->relativePath($skillDir, $filePath); - $uri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $skillPath, $relative); + $uri = \sprintf('%s://%s/%s', McpSkills::URI_SCHEME, $this->encodePath($skillPath), $this->encodePath($relative)); $size = (int) filesize($filePath); $digest = 'sha256:'.hash_file('sha256', $filePath); @@ -138,6 +145,31 @@ private function registerSkill(Builder $builder, string $base, string $manifestP return new Skill($entryUri, $metadata, $resources); } + /** + * Enforces the Agent Skills naming rules the extension requires of the skill name, and the + * description length limit. + */ + private function validateMetadata(string $skillPath, SkillMetadata $metadata): void + { + if (\strlen($metadata->name) > self::MAX_NAME_LENGTH || 1 !== preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $metadata->name)) { + throw new InvalidArgumentException(\sprintf('Skill at "%s": name "%s" must be 1-%d characters of lowercase letters, digits and single hyphens, and must not start or end with a hyphen.', $skillPath, $metadata->name, self::MAX_NAME_LENGTH)); + } + + $descriptionLength = \function_exists('mb_strlen') ? mb_strlen($metadata->description) : \strlen($metadata->description); + if ($descriptionLength > self::MAX_DESCRIPTION_LENGTH) { + throw new InvalidArgumentException(\sprintf('Skill at "%s": description has %d characters, exceeding the limit of %d.', $skillPath, $descriptionLength, self::MAX_DESCRIPTION_LENGTH)); + } + } + + /** + * Percent-encodes each segment of a slash separated path, so a file name like `my file.md` or + * `a#b.md` still yields a valid URI. + */ + private function encodePath(string $path): string + { + return implode('/', array_map(rawurlencode(...), explode('/', $path))); + } + /** * @param SkillResource[] $resources */ @@ -197,15 +229,6 @@ private function registerFile(Builder $builder, string $base, string $filePath, */ 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)); } @@ -214,21 +237,14 @@ private function findSkillManifests(string $base): iterable */ 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); } /** + * Lists the files below $directory in a stable order, skipping hidden files and folders (`.env`, + * `.git/`). The same walk runs whichever optional packages are installed, so a skill's manifest + * never depends on the environment it was built in. + * * @param callable(string): bool $accept * * @return iterable @@ -236,7 +252,10 @@ private function findSupportingFiles(string $skillDir, string $manifestPath): it private function iterateFiles(string $directory, callable $accept): iterable { $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), + static fn (\SplFileInfo $file): bool => !str_starts_with($file->getFilename(), '.'), + ), ); $paths = []; diff --git a/tests/Unit/Server/Skill/FrontmatterParserTest.php b/tests/Unit/Server/Skill/FrontmatterParserTest.php index 54cbbab75..4a51f41c5 100644 --- a/tests/Unit/Server/Skill/FrontmatterParserTest.php +++ b/tests/Unit/Server/Skill/FrontmatterParserTest.php @@ -86,4 +86,30 @@ public function testParseMetadataThrowsWhenNameMissing(): void (new FrontmatterParser())->parseMetadata("---\ndescription: no name\n---\nbody"); } + + public function testKeepsEmptyAndNestedMappingsAsObjects(): void + { + $content = "---\nname: a\ndescription: b\nmetadata: {}\nnested:\n inner:\n key: value\n---\nbody"; + + [$frontmatter] = (new FrontmatterParser())->parse($content); + + $this->assertSame( + '{"name":"a","description":"b","metadata":{},"nested":{"inner":{"key":"value"}}}', + json_encode($frontmatter, \JSON_THROW_ON_ERROR), + ); + } + + public function testKeepsListsAsLists(): void + { + [$frontmatter] = (new FrontmatterParser())->parse("---\nname: a\ntags: [x, y]\nempty: []\n---\n"); + + $this->assertSame('{"name":"a","tags":["x","y"],"empty":[]}', json_encode($frontmatter, \JSON_THROW_ON_ERROR)); + } + + public function testMetadataSerializesEmptyMappingsAsObjects(): void + { + $metadata = (new FrontmatterParser())->parseMetadata("---\nname: a\ndescription: b\nmetadata: {}\n---\n"); + + $this->assertSame('{"name":"a","description":"b","metadata":{}}', json_encode($metadata, \JSON_THROW_ON_ERROR)); + } } diff --git a/tests/Unit/Server/Skill/SkillProviderTest.php b/tests/Unit/Server/Skill/SkillProviderTest.php index fc9ba5aa4..27f49f00d 100644 --- a/tests/Unit/Server/Skill/SkillProviderTest.php +++ b/tests/Unit/Server/Skill/SkillProviderTest.php @@ -18,6 +18,7 @@ use Mcp\Server\Builder; use Mcp\Server\Skill\SkillProvider; use Mcp\Server\Skill\SkillRegistry; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class SkillProviderTest extends TestCase @@ -76,8 +77,6 @@ public function testReturnsSkillsWithCompleteResourceManifest(): void $codeReview = array_values(array_filter($skills, static fn ($s) => 'code-review' === $s->frontmatter->name))[0]; $this->assertIsArray($codeReview->resources); - $this->assertNotFalse($codeReview); - $this->assertIsArray($codeReview->resources); $this->assertCount(2, $codeReview->resources); // SKILL.md + references/SECURITY.md $manifestEntry = array_values(array_filter($codeReview->resources, static fn (SkillResource $r) => $r->uri === $codeReview->uri))[0]; @@ -244,6 +243,163 @@ public function testThrowsWhenSkillExceedsTotalSizeLimit(): void } } + /** + * @return iterable + */ + public static function provideInvalidNames(): iterable + { + yield 'uppercase' => ['Code-Review']; + yield 'underscore' => ['code_review']; + yield 'leading hyphen' => ['-review']; + yield 'trailing hyphen' => ['review-']; + yield 'double hyphen' => ['code--review']; + yield 'too long' => [str_repeat('a', 65)]; + } + + #[DataProvider('provideInvalidNames')] + public function testRejectsNamesBreakingTheAgentSkillsNamingRules(string $name): void + { + $dir = $this->makeTempDir(); + $this->writeSkill($dir, $name, "name: {$name}\ndescription: Some skill."); + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must be 1-64 characters'); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + } finally { + $this->removeDir($dir); + } + } + + public function testAcceptsTheLongestValidName(): void + { + $dir = $this->makeTempDir(); + $name = str_repeat('a', 64); + $this->writeSkill($dir, $name, "name: {$name}\ndescription: Some skill."); + + try { + $skills = (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + + $this->assertCount(1, $skills); + } finally { + $this->removeDir($dir); + } + } + + public function testRejectsDescriptionsLongerThan1024Characters(): void + { + $dir = $this->makeTempDir(); + $this->writeSkill($dir, 'long-description', 'name: long-description'."\n".'description: '.str_repeat('x', 1025)); + + try { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('exceeding the limit of 1024'); + + (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + } finally { + $this->removeDir($dir); + } + } + + public function testAcceptsA1024CharacterDescription(): void + { + $dir = $this->makeTempDir(); + $this->writeSkill($dir, 'max-description', "name: max-description\ndescription: ".str_repeat('x', 1024)); + + try { + $skills = (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + + $this->assertCount(1, $skills); + } finally { + $this->removeDir($dir); + } + } + + public function testSkipsHiddenFilesAndFoldersWhateverIsInstalled(): void + { + $dir = $this->makeTempDir(); + $skillDir = $this->writeSkill($dir, 'visible', "name: visible\ndescription: Has hidden files."); + file_put_contents($skillDir.'/guide.md', 'guide'); + file_put_contents($skillDir.'/.env', 'SECRET=1'); + mkdir($skillDir.'/.git', 0777, true); + file_put_contents($skillDir.'/.git/config', 'repo'); + mkdir($skillDir.'/refs/.cache', 0777, true); + file_put_contents($skillDir.'/refs/.cache/blob', 'cache'); + file_put_contents($skillDir.'/refs/note.md', 'note'); + + try { + $builder = Server::builder(); + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), $dir); + + $this->assertIsArray($skills[0]->resources); + $listed = array_map(static fn (SkillResource $r): string => $r->uri, $skills[0]->resources); + sort($listed); + + $this->assertSame(['skill://visible/SKILL.md', 'skill://visible/guide.md', 'skill://visible/refs/note.md'], $listed); + $served = array_column($this->registeredResources($builder), 'uri'); + sort($served); + $this->assertSame($listed, $served, 'Everything served is listed, and the other way round.'); + } finally { + $this->removeDir($dir); + } + } + + public function testPercentEncodesUrisAndKeepsThemServable(): void + { + $dir = $this->makeTempDir(); + $skillDir = $this->writeSkill($dir, 'encoded', "name: encoded\ndescription: Odd file names."); + mkdir($skillDir.'/my docs', 0777, true); + file_put_contents($skillDir.'/my docs/a b.md', 'one'); + file_put_contents($skillDir.'/a#b.md', 'two'); + file_put_contents($skillDir.'/x%y.md', 'three'); + + try { + $builder = Server::builder(); + $skills = (new SkillProvider())->registerInto($builder, new SkillRegistry(), $dir); + + $this->assertIsArray($skills[0]->resources); + $listed = array_map(static fn (SkillResource $r): string => $r->uri, $skills[0]->resources); + + $this->assertContains('skill://encoded/my%20docs/a%20b.md', $listed); + $this->assertContains('skill://encoded/a%23b.md', $listed); + $this->assertContains('skill://encoded/x%25y.md', $listed); + foreach ($listed as $uri) { + $this->assertDoesNotMatchRegularExpression('/[ #]/', $uri); + $resource = $this->resourceByUri($builder, $uri); + $file = ($resource['handler'])(); + $this->assertInstanceOf(\SplFileInfo::class, $file); + $this->assertFileExists($file->getPathname()); + } + } finally { + $this->removeDir($dir); + } + } + + public function testEncodesPrefixSegmentsToo(): void + { + $dir = $this->makeTempDir(); + mkdir($dir.'/team a', 0777, true); + $this->writeSkill($dir.'/team a', 'tool', "name: tool\ndescription: Prefixed skill."); + + try { + $skills = (new SkillProvider())->registerInto(Server::builder(), new SkillRegistry(), $dir); + + $this->assertSame('skill://team%20a/tool/SKILL.md', $skills[0]->uri); + } finally { + $this->removeDir($dir); + } + } + + private function writeSkill(string $baseDir, string $name, string $frontmatter): string + { + $skillDir = $baseDir.'/'.$name; + mkdir($skillDir, 0777, true); + file_put_contents($skillDir.'/SKILL.md', "---\n{$frontmatter}\n---\nbody"); + + return $skillDir; + } + /** * @return array> */ diff --git a/tests/Unit/Server/Skill/SkillsDispatchTest.php b/tests/Unit/Server/Skill/SkillsDispatchTest.php index faf6344f1..11a23bff1 100644 --- a/tests/Unit/Server/Skill/SkillsDispatchTest.php +++ b/tests/Unit/Server/Skill/SkillsDispatchTest.php @@ -11,7 +11,9 @@ namespace Mcp\Tests\Unit\Server\Skill; +use Mcp\Exception\LogicException; use Mcp\Schema\Enum\ProtocolVersion; +use Mcp\Schema\ServerCapabilities; use Mcp\Server; use Mcp\Server\Stateless\RequestMeta; use Mcp\Server\Stateless\StatelessProtocol; @@ -73,6 +75,79 @@ public function testSkillsExtensionIsAdvertised(): void $this->assertArrayHasKey('io.modelcontextprotocol/skills', (array) $answer['body']['result']['capabilities']['extensions']); } + #[TestDox('the resources capability is declared even when the skills directory holds no skill')] + public function testResourcesCapabilityIsDeclaredWithoutSkills(): void + { + $dir = sys_get_temp_dir().'/mcp-skill-dispatch-test-'.bin2hex(random_bytes(8)); + mkdir($dir, 0777, true); + + try { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addSkillsFromDirectory($dir) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::call($protocol, 'server/discover'); + } finally { + rmdir($dir); + } + + $capabilities = $answer['body']['result']['capabilities']; + $this->assertArrayHasKey('resources', $capabilities); + $this->assertArrayHasKey('io.modelcontextprotocol/skills', (array) $capabilities['extensions']); + } + + #[TestDox('the resources capability is declared when the capabilities are set explicitly')] + public function testResourcesCapabilityIsDeclaredWithExplicitCapabilities(): void + { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(tools: false, prompts: false)) + ->addSkillsFromDirectory(self::FIXTURES) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $answer = self::call($protocol, 'server/discover'); + + $this->assertArrayHasKey('resources', $answer['body']['result']['capabilities']); + } + + #[TestDox('explicit capabilities without resources are rejected, since skills are served as resources')] + public function testExplicitCapabilitiesWithoutResourcesAreRejected(): void + { + $builder = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->setCapabilities(new ServerCapabilities(resources: false)) + ->addSkillsFromDirectory(self::FIXTURES); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must declare "resources"'); + + $builder->buildStateless([ProtocolVersion::V2026_07_28]); + } + + #[TestDox('an empty frontmatter mapping reaches the wire as an object, not as a list')] + public function testEmptyFrontmatterMappingIsSentAsObject(): void + { + $dir = sys_get_temp_dir().'/mcp-skill-dispatch-test-'.bin2hex(random_bytes(8)); + mkdir($dir.'/mapped', 0777, true); + file_put_contents($dir.'/mapped/SKILL.md', "---\nname: mapped\ndescription: Has an empty mapping.\nmetadata: {}\n---\nbody"); + + try { + $protocol = Server::builder() + ->setServerInfo('test-server', '1.0.0') + ->addSkillsFromDirectory($dir) + ->buildStateless([ProtocolVersion::V2026_07_28]); + + $json = self::callRaw($protocol, 'skills/get', ['uri' => 'skill://mapped/SKILL.md']); + } finally { + unlink($dir.'/mapped/SKILL.md'); + rmdir($dir.'/mapped'); + rmdir($dir); + } + + $this->assertStringContainsString('"metadata":{}', $json); + } + private static function protocol(): StatelessProtocol { return Server::builder() @@ -106,4 +181,23 @@ private static function call(StatelessProtocol $protocol, string $method, array 'body' => json_decode($result->toJson(), true, flags: \JSON_THROW_ON_ERROR), ]; } + + /** + * @param array $params + */ + private static function callRaw(StatelessProtocol $protocol, string $method, array $params = []): string + { + $params['_meta'] = [ + RequestMeta::PROTOCOL_VERSION => ProtocolVersion::V2026_07_28->value, + RequestMeta::CLIENT_CAPABILITIES => new \stdClass(), + ]; + + return $protocol->handle( + json_encode(['jsonrpc' => '2.0', 'id' => 1, 'method' => $method, 'params' => $params], \JSON_THROW_ON_ERROR), + [ + 'MCP-Protocol-Version' => ProtocolVersion::V2026_07_28->value, + 'Mcp-Method' => $method, + ], + )->toJson(); + } } From 616c4ddf3fc72f3e594111bc591c56095f05d635 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Sat, 17 Oct 2026 20:21:46 +0200 Subject: [PATCH 5/5] [CI] Test against Symfony 8.1 instead of 8.0 Every release of symfony/yaml 8.0 is blocked by security advisories and Symfony 8.0 is no longer maintained, so the 8.0.* job cannot install the dependency the skills extension needs. The 8.1 line resolves. --- .github/workflows/pipeline.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pipeline.yaml b/.github/workflows/pipeline.yaml index 58b6ba960..510e41983 100644 --- a/.github/workflows/pipeline.yaml +++ b/.github/workflows/pipeline.yaml @@ -27,7 +27,7 @@ jobs: - symfony-version: '7.4.*' dependencies: 'highest' php: '8.4' - - symfony-version: '8.0.*' + - symfony-version: '8.1.*' dependencies: 'highest' php: '8.4'