diff --git a/.github/scripts/prepare_markdown_links.py b/.github/scripts/prepare_markdown_links.py new file mode 100644 index 000000000..6bda0c192 --- /dev/null +++ b/.github/scripts/prepare_markdown_links.py @@ -0,0 +1,41 @@ +import argparse +from pathlib import Path + + +class RepositoryLinks: + def __init__(self, root): + self.root = Path(root).resolve() + + def prepare(self): + docs = self.root / "docs" + if not docs.is_dir(): + raise ValueError(f"Missing documentation directory: {docs}") + if docs.is_symlink(): + raise ValueError(f"Refusing to rewrite a documentation symlink: {docs}") + + changed = 0 + prefixes = ( + "https://github.com/modelcontextprotocol/csharp-sdk/tree/main/", + "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/", + ) + for path in docs.rglob("*.md"): + if path.is_symlink(): + raise ValueError(f"Refusing to rewrite a documentation symlink: {path}") + text = path.read_text(encoding="utf-8") + updated = text + for prefix in prefixes: + updated = updated.replace(prefix, "/") + if updated != text: + path.write_text(updated, encoding="utf-8") + changed += 1 + return changed + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Resolve this repository's main-branch links against the proposed checkout for mlc." + ) + parser.add_argument("root") + arguments = parser.parse_args() + count = RepositoryLinks(arguments.root).prepare() + print(f"Prepared repository-local links in {count} Markdown files; mlc still validates each target.") diff --git a/.github/scripts/test_prepare_markdown_links.py b/.github/scripts/test_prepare_markdown_links.py new file mode 100644 index 000000000..edfc1ee33 --- /dev/null +++ b/.github/scripts/test_prepare_markdown_links.py @@ -0,0 +1,47 @@ +from pathlib import Path +import tempfile +import unittest + +from prepare_markdown_links import RepositoryLinks + + +class RepositoryLinksTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + (self.root / "docs").mkdir() + + def test_main_links_become_checkout_paths_without_ignoring_missing_targets(self): + path = self.root / "docs" / "example.md" + path.write_text( + "[sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer)\n" + "[source](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/missing.cs)\n", + encoding="utf-8", + ) + + self.assertEqual(RepositoryLinks(self.root).prepare(), 1) + self.assertEqual( + path.read_text(encoding="utf-8"), + "[sample](/samples/SkillsServer)\n[source](/src/missing.cs)\n", + ) + + def test_other_repositories_and_pinned_revisions_remain_web_links(self): + path = self.root / "docs" / "example.md" + original = ( + "[other](https://github.com/other/project/tree/main/sample)\n" + "[pinned](https://github.com/modelcontextprotocol/csharp-sdk/tree/1234567/sample)\n" + "[relative](other.md)\n" + ) + path.write_text(original, encoding="utf-8") + + self.assertEqual(RepositoryLinks(self.root).prepare(), 0) + self.assertEqual(path.read_text(encoding="utf-8"), original) + + def test_missing_documentation_is_not_a_success(self): + with self.assertRaises(ValueError): + RepositoryLinks(self.root / "absent").prepare() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 1dbf83b0e..73e01738d 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -3,10 +3,18 @@ name: Check Markdown links on: push: branches: [ "main" ] - paths: "**.md" + paths: + - "**.md" + - ".github/scripts/prepare_markdown_links.py" + - ".github/scripts/test_prepare_markdown_links.py" + - ".github/workflows/markdown-link-check.yml" pull_request: branches: [ "main" ] - paths: "**.md" + paths: + - "**.md" + - ".github/scripts/prepare_markdown_links.py" + - ".github/scripts/test_prepare_markdown_links.py" + - ".github/workflows/markdown-link-check.yml" permissions: contents: read @@ -18,6 +26,11 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve main-branch links against the proposed checkout + run: | + python3 -m unittest discover -s .github/scripts -p test_prepare_markdown_links.py + python3 .github/scripts/prepare_markdown_links.py . + - name: Markup Link Checker (mlc) uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 with: @@ -26,6 +39,7 @@ jobs: # Do not warn for learn.microsoft.com locale redirects. # Do not warn for modelcontextprotocol.io homepage/spec redirects. args: >- + --root-dir . --ignore-links "xref:*" --ignore-links "*samples/*?name=snippet_*" --do-not-warn-for-redirect-to "https://learn.microsoft.com/en-us/*" diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..97e90457c 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -49,6 +49,8 @@ + + @@ -69,6 +71,7 @@ + diff --git a/README.md b/README.md index 71902e4e8..7ef8a27ca 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Skills](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Skills.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills) - MCP Skills extension for serving and consuming Agent Skills with verifiable file manifests. + - **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/docs/concepts/index.md b/docs/concepts/index.md index b3185056a..a01d0a80d 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -53,4 +53,5 @@ To install the SDK and build your first MCP client and server, see [Getting star | - | - | | [MCP Apps](apps/apps.md) | Learn how to use the MCP Apps extension to deliver interactive UIs from MCP servers. | | [Tasks](tasks/tasks.md) | Learn how to use task-based execution for long-running operations that can be polled for status and results. | +| [Skills](skills/skills.md) | Learn how to serve and consume Agent Skills over MCP, with verifiable file manifests. | | [Identity and Roles](identity/identity.md) | Learn how to access caller identity and roles in MCP tool, prompt, and resource handlers. | diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md new file mode 100644 index 000000000..be4e09d37 --- /dev/null +++ b/docs/concepts/skills/skills.md @@ -0,0 +1,296 @@ +--- +title: Skills +description: Serve and consume Agent Skills over MCP with the Skills extension. +uid: skills +--- + +## Skills + +The Skills extension lets an MCP server publish [Agent Skills](https://agentskills.io/) alongside its tools, +resources, and prompts. A skill is a directory of files, minimally a `SKILL.md` with YAML frontmatter, that gives +an agent structured workflow instructions. Over MCP, each file is an ordinary resource, and the extension adds two +methods for discovering skills and obtaining a verifiable manifest of their files: + +- `skills/list` enumerates the skills a server serves, with pagination. +- `skills/get` returns the entry for a single skill by the URI of its `SKILL.md`. + +Skills are provided by the `ModelContextProtocol.Extensions.Skills` package. The implementation follows the +[Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx) +(SEP-2640, extension id `io.modelcontextprotocol/skills`). + +### Overview + +A skill entry () is a complete, point-in-time snapshot of a skill: + +- `uri`: the resource URI of its `SKILL.md`, conventionally `skill:///SKILL.md`. The final segment + of `` is the skill's name. +- `frontmatter`: the `SKILL.md` YAML frontmatter, rendered verbatim as a JSON object. `name` and `description` + are always present; every other authored field passes through. +- `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size + (), or the string `"dynamic"` for generated content + that cannot be digested. + +A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each +one against the manifest. Before loading a skill, it also parses the fetched `SKILL.md` and compares its +frontmatter with the entry's; digest verification does not cover that. That verification, and the rule that an +unlisted file is a change to the skill, is what lets a user's approval bind to specific content. + +Keeping the entry and the file in agreement is the server's job, and checking that they agree is the host's: + +| Who | Responsibility | +|---|---| +| Server, using `McpServerSkill` | Frontmatter is read from `SKILL.md` and the manifest computed from the served bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. | +| Server, using a custom catalog | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. | +| Host | Verifies each file against the manifest, then parses the fetched `SKILL.md` and compares its frontmatter with the entry before loading. | + +### Serving skills + +The simplest way to serve skills is to keep each one in its own directory, as the Agent Skills specification +lays them out, and point `WithSkillsFromDirectory` at the parent: + +```csharp +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); +``` + +Every included immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each +`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, +and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix` +such as `skill://acme/billing/` to place the skills under an organizational path. + +Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents +or counting them toward the published manifest's limits. The `includeHiddenAndSystemFiles: true` overloads of +`WithSkillsFromDirectory` and `McpServerSkill.CreateFromDirectory` provide an explicit opt-in. For a narrower +selection, use `McpServerSkill.Create` with explicit files; that API keeps the files the caller selected. +Included symbolic links remain errors rather than being followed. + +For finer control, build skills individually with and +register them with `WithSkills`: + +```csharp +var gitWorkflow = McpServerSkill.CreateFromDirectory(Path.Combine(skillsRoot, "git-workflow")); + +var refunds = McpServerSkill.Create( + "skill://acme/billing/refunds/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", skillMarkdown), + McpServerSkillFile.FromText("examples/approved.md", approvedTemplate), + new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes }, + ]); + +builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]); +``` + +All of these validate structural entry and file constraints and throw with a +specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the +Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or +the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`, +`compatibility`, and the string-to-string `metadata` map pass through verbatim, as the specification requires; hosts compare +them against the file and ignore fields such as `allowed-tools` for MCP-origin skills. File contents are copied when the skill is created, so +later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not +follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File +names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs. + +#### Frontmatter + + reads the YAML frontmatter of a `SKILL.md` into a + without a YAML library. It supports block mappings (nested up to 32 +levels), block and flow sequences, plain, quoted, and block scalars, and comments. Unquoted scalars are resolved per +the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml` +package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by +parsing the fetched `SKILL.md` itself and comparing field by field against the published entry; a value that one +side types as a number and the other as a string is a verification failure. Quote values such as version numbers +that are meant to be strings. + +Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are not +supported; the reader rejects them with a naming the construct rather than guess. +For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the +frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader +detects, and when the reader can parse the whole block, the supplied object must match it exactly. + +The reader stops at the first unsupported construct, though, so for such a file it cannot check the rest of the +block or compare it with the supplied object. The application must parse the entire frontmatter with a full YAML +parser and supply the identical JSON; the SDK accepting it is not proof that it matches. + +#### Custom catalogs + +When skills come from a database, a file share, or a large or generated catalog, implement + and pass it to `WithSkills`: + +```csharp +public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog +{ + public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + string tenant = GetTenant(context.User); + var (skills, nextCursor) = await repository.GetPageAsync(tenant, cursor, pageSize: 50, cancellationToken); + return new McpSkillPage { Skills = skills, NextCursor = nextCursor }; + } + + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + repository.FindAsync(GetTenant(context.User), uri, cancellationToken); +} +``` + +Both methods receive an with the JSON-RPC request, +the caller's when the transport supplies one (the ASP.NET Core +transport does), and any items that incoming-message filters attached to the request. + +A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with +`resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not +read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as +`GetAsync` answers for every skill the server serves to the caller. Throw + with for a +cursor the catalog cannot interpret. is the +built-in implementation over a fixed set of entries and can be composed into a custom one. + +#### Dynamic skills + +A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to + and serve it through a catalog. Such a skill +offers no content integrity, and hosts may decline to load it. A host that does load one still verifies its +frontmatter. + +#### Protocol versions and caching hints + +`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, +configured through . Those attributes, and +`resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, +where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in +list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use +the same configured hints, so use `public` only when the listing and every entry are identical for every caller. + +### Consuming skills + +The client extension methods in cover the +host's side of the protocol: + +```csharp +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; + +if (!client.SupportsSkills()) +{ + return; // Clients issue skills/list and skills/get only after observing the declaration. +} + +// Enumerate, following pagination. The listing may be empty or partial. +IList skills = await client.ListSkillsAsync(); + +// Retrieve one skill by URI, for example one referenced from server instructions. +Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); + +// Read a file and verify it against the held entry's digest and size. +ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); + +// This example loads text SKILL.md resources and rejects other content forms. +if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text) +{ + throw new SkillVerificationException("A text SKILL.md is required for this example."); +} + +// A parse failure, including unsupported YAML, rejects this load. Do not fall back to skill.Frontmatter. +var fetchedFrontmatter = SkillFrontmatter.Parse(text.Text); +if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) +{ + throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); +} +``` + +`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural +requirements and throw for an entry a host +must not load, such as one whose manifest omits its own `SKILL.md`, carries a malformed digest, or lists a file +outside the skill. `ReadSkillResourceAsync` throws the same exception when the +content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the +content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest; +because the manifest changed, any approval bound to the previous one is revoked and must be obtained again. + +The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`, +return one page or the raw result and expose the caching hints. + exposes the verification and digest helpers for hosts +that read resources through other means. + +#### Listing limits and rejected entries + +The aggregating `ListSkillsAsync()` overload stops at 100 pages or 1,000 entries by default. Repeated cursors +and exceeded limits throw `McpException`; no silently truncated list is returned. Empty cursors remain opaque +values, not an end marker. Use explicit positive limits when appropriate: + +```csharp +var skills = await client.ListSkillsAsync(maxPages: 200, maxSkills: 5000); +``` + +Use the paged overload for application-controlled iteration. Strict listing still rejects a page containing +an invalid entry. To retain valid entries while reporting the others, opt into +`ListSkillsWithDiagnosticsAsync`: + +```csharp +var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams()); +foreach (var rejected in listing.RejectedEntries) +{ + Console.Error.WriteLine($"Skill entry {rejected.Index} was rejected: {rejected.Error.Message}"); +} +foreach (var listedSkill in listing.Page.Skills) +{ + Console.WriteLine(listedSkill.Uri); +} +``` + +`Page` preserves the original cursor and cache hints. `RejectedEntries` identifies each invalid entry by its +position in the original page and retains the parsing/validation error. An empty `Page.Skills` can mean every +entry was rejected, so callers must inspect the errors. A malformed page envelope or missing `skills` array +still fails the request. This API does not load rejected entries or replace file/frontmatter verification. + +### Security considerations + +Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The +specification places most of the burden on hosts. In particular: + +- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request + filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing + therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some + callers must not see some skills, implement and + decide from the request context's user, and guard the corresponding resources the same way. The built-in + in-memory catalog serves the same entries to every caller. Use one shared application policy for both + metadata and file access; the [HTTP integration example](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs) + exercises both allowed and denied callers. Do not derive authorization from `resources/list` membership: + listings may intentionally be partial while an authorized direct read remains valid. +- Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters + the model's context. +- Digests are unsigned and come from the same server as the content. A match proves consistency between the entry + and what was fetched, not that either is trustworthy. +- Bind any persisted per-skill approval to the entry's manifest. A later entry with a different manifest revokes it. +- Do not honor frontmatter fields that widen the model's permissions, such as `allowed-tools`, for MCP-origin skills + without explicit user approval. +- Skill names are labels, not identifiers, and are not unique across servers. Identify a skill by the pair of server + identity and URI. + +The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against +the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows: + on the fetched text and + +against the entry's frontmatter, treating a parse failure or a difference as a verification failure. A host that +must accept YAML the reader does not support can parse the file with a full YAML library instead, but never uses +the entry's frontmatter in place of parsing the file. + +### Not implemented + +The optional `resources/directory/read` method and its `directoryRead` capability setting are not implemented. +Servers built with this package do not declare `directoryRead`, and hosts must not call the method against them. + +### Samples + +- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer): a Streamable + HTTP server serving two skills from directories on disk. +- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient): a client that + connects to it and discovers, retrieves, and verifies them. diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml index 276415e13..58570780e 100644 --- a/docs/concepts/toc.yml +++ b/docs/concepts/toc.yml @@ -55,6 +55,8 @@ items: uid: apps - name: Tasks uid: tasks + - name: Skills + uid: skills - name: Identity and Roles uid: identity - name: API Reference diff --git a/package-lock.json b/package-lock.json index 774becc10..bf045e389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,13 +23,15 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.9", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz", - "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==", + "version": "0.2.0-alpha.12", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.12.tgz", + "integrity": "sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "commander": "^14.0.2", "eventsource-parser": "^3.0.8", "express": "^5.1.0", @@ -134,6 +136,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -303,9 +306,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -628,6 +631,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -848,6 +852,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1537,6 +1542,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 577d3e1f8..abe1fc863 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs new file mode 100644 index 000000000..464d58044 --- /dev/null +++ b/samples/SkillsClient/Program.cs @@ -0,0 +1,111 @@ +// Demonstrates consuming Agent Skills over MCP with the Skills extension (SEP-2640), from the host's side: +// +// 1. Connect over Streamable HTTP and check that the server declares the extension. +// 2. Enumerate the skills with skills/list (the client extension follows pagination for you). +// 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions. +// 4. Verify fetched bytes against the manifest, then independently parse and compare SKILL.md frontmatter. +// 5. Show that a file the manifest does not list is refused before any request is sent. +// +// Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as +// the first argument to run against another server. + +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; + +var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001"); + +await using McpClient client = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Name = "Skills Server", + Endpoint = endpoint, +})); + +Console.WriteLine($"Connected to {client.ServerInfo.Name} {client.ServerInfo.Version} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); +Console.WriteLine(); + +// 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration. +if (!client.SupportsSkills()) +{ + Console.WriteLine("The server does not declare the io.modelcontextprotocol/skills extension."); + return; +} + +// 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI. +Console.WriteLine("=== skills/list ==="); +ListSkillsResult firstPage = await client.ListSkillsAsync(new ListSkillsRequestParams()); +Console.WriteLine($" caching hints: ttlMs={(firstPage.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={firstPage.CacheScope?.ToString() ?? "(none)"}"); + +IList skills = await client.ListSkillsAsync(); +foreach (Skill listed in skills) +{ + string manifest = listed.Resources.IsDynamic + ? "dynamic (no digests)" + : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes"; + Console.WriteLine($" {listed.Uri}"); + Console.WriteLine($" name: {listed.Name}"); + Console.WriteLine($" description: {listed.Description}"); + Console.WriteLine($" manifest: {manifest}"); +} + +Console.WriteLine(); + +// 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly. +Console.WriteLine("=== skills/get ==="); +Console.WriteLine($" server instructions: {client.ServerInstructions}"); +const string SkillUri = "skill://git-workflow/SKILL.md"; +Skill skill = await client.GetSkillAsync(SkillUri); +Console.WriteLine($" {skill.Uri} ({skill.Name})"); +foreach (SkillResource file in skill.Resources.Resources!) +{ + Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}"); +} + +Console.WriteLine(); + +// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter. +// The host must independently verify frontmatter before loading, regardless of the server's authoring path. +Console.WriteLine("=== resources/read (verified) ==="); +ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); +if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText) +{ + throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification."); +} + +try +{ + var fetchedFrontmatter = SkillFrontmatter.Parse(skillText.Text); + if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) + { + throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry."); + } +} +catch (FormatException exception) +{ + // This sample rejects unsupported YAML. A full host may instead use a suitable full parser, + // but must never treat the server-provided JSON as verification of the fetched file. + throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception); +} + +Console.WriteLine($" {SkillUri} manifest and frontmatter verified:"); +Console.WriteLine(Indent(skillText.Text)); + +string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri; +ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri); +Console.WriteLine($" {supportingFileUri} verified ({((TextResourceContents)supportingFile.Contents[0]).Text.Length} chars)"); +Console.WriteLine(); + +// 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it. +Console.WriteLine("=== unlisted file ==="); +try +{ + await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/UNLISTED.md"); +} +catch (SkillVerificationException e) +{ + Console.WriteLine($" refused: {e.Message}"); +} + +static string Indent(string text) => + string.Join(Environment.NewLine, text.TrimEnd().Split('\n').Select(line => " | " + line.TrimEnd('\r'))); diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md new file mode 100644 index 000000000..63d6269b3 --- /dev/null +++ b/samples/SkillsClient/README.md @@ -0,0 +1,75 @@ +# Skills Client Sample + +A console client that consumes [Agent Skills](https://agentskills.io/) over MCP through the Skills extension +([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)), +doing what a host's skill-loading path does: + +1. Connects over Streamable HTTP and checks that the server declares `io.modelcontextprotocol/skills` + (`client.SupportsSkills()`). +2. Enumerates the catalog with `skills/list` (`client.ListSkillsAsync()` follows up to 100 pages and 1,000 + entries by default; the per-page overload exposes the `ttlMs` and `cacheScope` hints). +3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it + found in server instructions. +4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size + (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, independently parses its + frontmatter and compares every field with the held entry. +5. Shows that a file the manifest does not list is refused before any request is sent. + +## Run + +Start the [SkillsServer](../SkillsServer) sample in one terminal: + +```bash +dotnet run --project samples/SkillsServer/SkillsServer.csproj +``` + +Then run the client in another: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj +``` + +To run against another Streamable HTTP server, pass its endpoint: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp +``` + +Expected output (abridged): + +``` +=== skills/list === + caching hints: ttlMs=300000 cacheScope=Public + skill://git-workflow/SKILL.md + name: git-workflow + manifest: 3 file(s), 1523 bytes + skill://refunds/SKILL.md + name: refunds + ... + +=== skills/get === + skill://git-workflow/SKILL.md (git-workflow) + skill://git-workflow/SKILL.md 724 bytes sha256:… + ... + +=== resources/read (verified) === + skill://git-workflow/SKILL.md manifest and frontmatter verified: + | --- + | name: git-workflow + ... + +=== unlisted file === + refused: 'skill://git-workflow/references/UNLISTED.md' is not listed in the manifest of skill ... +``` + +## Notes + +- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come + from the same server. Treat skill content as untrusted model input and tag it with its originating server. +- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` with + `SkillFrontmatter.Parse` and compares it to the entry, rejecting the skill on a difference or on YAML the reader + does not support. A host that must accept such YAML can use a full YAML parser instead, but never the entry's + own frontmatter. +- This is not a complete host: approval binding, origin isolation, and permission decisions are left out. +- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative + for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable. diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj new file mode 100644 index 000000000..01f44dcb0 --- /dev/null +++ b/samples/SkillsClient/SkillsClient.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + + diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs new file mode 100644 index 000000000..f16c7c0c1 --- /dev/null +++ b/samples/SkillsServer/Program.cs @@ -0,0 +1,40 @@ +// Demonstrates serving Agent Skills over MCP with the Skills extension (SEP-2640) from a Streamable HTTP server. +// +// Each skill is a directory under ./Skills containing a SKILL.md and, optionally, supporting files. +// WithSkillsFromDirectory reads every skill directory, parses the YAML frontmatter of each SKILL.md, computes the +// SHA-256 digest and size of every file, and registers both the skills' entries (what skills/list and skills/get +// return) and the resources that serve their files (what resources/read returns). + +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); + +// Every immediate subdirectory of ./Skills that contains a SKILL.md becomes a skill. The frontmatter is read from +// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkill.CreateFromDirectory or +// McpServerSkill.Create with WithSkills for finer control, for example an organizational URI prefix per skill. +string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills"); + +builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "SkillsServer", Version = "1.0.0" }; + + // A server may point the agent at a skill directly from its instructions. A host confirms the URI with + // skills/get and reads it with resources/read; no listing is required. + options.ServerInstructions = + "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md."; + }) + .WithHttpTransport() + .WithSkillsFromDirectory(skillsRoot, configure: options => + { + // Every caller sees the same catalog, so the listing may be shared by intermediaries for a while. + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); + +var app = builder.Build(); + +app.MapMcp(); + +app.Run(); diff --git a/samples/SkillsServer/Properties/launchSettings.json b/samples/SkillsServer/Properties/launchSettings.json new file mode 100644 index 000000000..bd31121a4 --- /dev/null +++ b/samples/SkillsServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md new file mode 100644 index 000000000..48e247d9f --- /dev/null +++ b/samples/SkillsServer/README.md @@ -0,0 +1,45 @@ +# Skills Server Sample + +A Streamable HTTP MCP server that serves two [Agent Skills](https://agentskills.io/) through the MCP Skills +extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)). + +Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and supporting files: + +| Skill URI | Files | +| ---------------------------------------- | -------------------------------------------------------------- | +| `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` | +| `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | + +`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the +SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the +resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from +its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`. +Directory discovery excludes dot-prefixed, hidden and system entries by default. The explicit +`includeHiddenAndSystemFiles: true` overload opts in; included links are still rejected. + +## Run + +```bash +dotnet run --project samples/SkillsServer/SkillsServer.csproj +``` + +The server listens on `http://localhost:3001` (see `Properties/launchSettings.json`). Then, in another terminal, +run the [SkillsClient](../SkillsClient) sample, which connects to it and walks through discovery, retrieval, and +verified reads: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj +``` + +Any MCP host that supports Streamable HTTP can also be pointed at `http://localhost:3001`. + +## Notes + +- Skill URIs are scoped to the server that serves them. A URI may carry an organizational prefix, as in + `skill://acme/billing/refunds/SKILL.md`; only the final segment must equal the skill's `name`. +- The server's instructions point the agent at `skill://git-workflow/SKILL.md` directly. A host can confirm a + URI with `skills/get` and read it with `resources/read` without ever listing the catalog. +- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`. + A catalog that varies by principal must not do that. +- See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough, + including how to implement a custom `IMcpSkillCatalog`. diff --git a/samples/SkillsServer/Skills/git-workflow/SKILL.md b/samples/SkillsServer/Skills/git-workflow/SKILL.md new file mode 100644 index 000000000..663f1a173 --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/SKILL.md @@ -0,0 +1,25 @@ +--- +name: git-workflow +description: Follow this team's Git conventions for branching, commit messages, and pull requests. +license: MIT +--- + +# Git workflow + +Use this skill whenever you create branches, commits, or pull requests in this repository. + +## Branches + +- Branch from `main`. +- Name branches `/`, where `` is one of `feat`, `fix`, `docs`, or `chore`. + +## Commits + +Write commit messages in the imperative mood, with a subject line of at most 72 characters. See +`references/COMMIT_STYLE.md` for the full style guide and examples. + +## Pull requests + +- Open pull requests against `main`. +- Fill in the template in `templates/PULL_REQUEST.md`. +- Request review from at least one code owner. diff --git a/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md new file mode 100644 index 000000000..f4505cf9b --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md @@ -0,0 +1,24 @@ +# Commit message style + +``` +: + + +``` + +- `` is one of `feat`, `fix`, `docs`, `refactor`, `test`, or `chore`. +- `` is imperative ("Add", not "Added" or "Adds") and does not end with a period. +- The body explains what changed and why, wrapped at 72 columns. Reference issues as `Fixes #123`. + +## Examples + +``` +feat: Add keyset pagination to the skills catalog + +Offset cursors skipped entries when a skill was removed between pages. +Encode the last URI of each page instead. Fixes #42. +``` + +``` +docs: Clarify that frontmatter must match SKILL.md verbatim +``` diff --git a/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md new file mode 100644 index 000000000..c0947267b --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md @@ -0,0 +1,16 @@ +## Summary + + + +## Changes + +- + +## Testing + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Documentation updated diff --git a/samples/SkillsServer/Skills/refunds/SKILL.md b/samples/SkillsServer/Skills/refunds/SKILL.md new file mode 100644 index 000000000..50853ef59 --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/SKILL.md @@ -0,0 +1,18 @@ +--- +name: refunds +description: Process customer refund requests per company policy. +metadata: + owner: billing-team + version: 2.1.0 +--- + +# Refunds + +Use this skill when a customer asks for a refund. + +1. Confirm the order is within the 30-day refund window. +2. Check whether the item is refundable under `policy/REFUND_POLICY.md`. +3. If it is, issue the refund and reply using `examples/approved.md`. +4. If it is not, reply using `examples/declined.md` and offer store credit where the policy allows it. + +Never issue a refund above 500 USD without a second approval. diff --git a/samples/SkillsServer/Skills/refunds/examples/approved.md b/samples/SkillsServer/Skills/refunds/examples/approved.md new file mode 100644 index 000000000..a7b966133 --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/examples/approved.md @@ -0,0 +1,9 @@ +Subject: Your refund has been issued + +Hi {customer_name}, + +We've issued a refund of {amount} for order {order_id}. It should appear on your original payment method within +5 to 10 business days. + +Thanks for your patience, +{agent_name} diff --git a/samples/SkillsServer/Skills/refunds/examples/declined.md b/samples/SkillsServer/Skills/refunds/examples/declined.md new file mode 100644 index 000000000..a8b53f7cc --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/examples/declined.md @@ -0,0 +1,9 @@ +Subject: About your refund request + +Hi {customer_name}, + +Unfortunately order {order_id} isn't eligible for a refund because {reason}. As an alternative, we'd be glad to +offer you store credit of {credit_amount}. + +Let us know how you'd like to proceed, +{agent_name} diff --git a/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md new file mode 100644 index 000000000..f88ee1afa --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md @@ -0,0 +1,8 @@ +# Refund policy + +| Category | Refundable | Window | Notes | +| ------------------- | ---------- | ------- | -------------------------------------- | +| Physical goods | Yes | 30 days | Must be unused and in original packaging | +| Digital downloads | No | | Store credit may be offered | +| Subscriptions | Prorated | Anytime | Refund the unused portion | +| Gift cards | No | | | diff --git a/samples/SkillsServer/SkillsServer.csproj b/samples/SkillsServer/SkillsServer.csproj new file mode 100644 index 000000000..2833a3dd7 --- /dev/null +++ b/samples/SkillsServer/SkillsServer.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + + + + + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 0c3c50923..daa2f43b3 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -5,6 +5,7 @@ AfterTargets="_GetProjectReferenceVersions" Condition="'$(MSBuildProjectName)' == 'ModelContextProtocol' Or '$(MSBuildProjectName)' == 'ModelContextProtocol.AspNetCore' + Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Skills' Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Tasks'"> <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)"> diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 785e3cc2e..d5e5aa213 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -191,14 +191,20 @@ public ClientOAuthProvider( }); } - internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) + internal override async Task SendAsync( + HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null) { bool attemptedRefresh = false; + discoverProbe ??= message?.Context?.DiscoverProbe; - if (request.Headers.Authorization is null && request.RequestUri is not null) + if (request.Headers.Authorization is null && + request.RequestUri is not null) { string? accessToken; - (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + using (discoverProbe?.Suspend()) + { + (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + } if (!string.IsNullOrEmpty(accessToken)) { @@ -213,7 +219,7 @@ internal override async Task SendAsync(HttpRequestMessage r // Capture the token that produced this challenge so the retry path can detect whether // another concurrent caller already replaced it in the cache. var usedAccessToken = request.Headers.Authorization?.Parameter; - return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, discoverProbe, cancellationToken).ConfigureAwait(false); } return response; @@ -296,6 +302,7 @@ private async Task HandleUnauthorizedResponseAsync( HttpResponseMessage response, bool attemptedRefresh, string? usedAccessToken, + DiscoverProbe? discoverProbe, CancellationToken cancellationToken) { if (response.Headers.WwwAuthenticate.Count == 0) @@ -308,7 +315,11 @@ private async Task HandleUnauthorizedResponseAsync( throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}]."); } - var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + string accessToken; + using (discoverProbe?.Suspend()) + { + accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + } using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); @@ -514,7 +525,7 @@ private async Task GetAuthServerMetadataAsync(Uri a AuthorizationServerMetadata? metadata; try { - var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); + using var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { continue; @@ -568,14 +579,16 @@ private async Task GetAuthServerMetadataAsync(Uri a // below, its existence disqualifies the legacy fallback that would otherwise synthesize S256. metadataDocumentFound = true; } - catch (McpException) + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - // Metadata validation failures are security signals and must not fall back to - // another well-known endpoint. - throw; + // HttpClient's per-request timeout does not cancel the caller's token. + // Another metadata endpoint may still respond within the overall budget. + LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); + continue; } - catch (Exception ex) + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException) { + cancellationToken.ThrowIfCancellationRequested(); LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); continue; } @@ -602,10 +615,10 @@ private async Task GetAuthServerMetadataAsync(Uri a $"Authorization server metadata from '{wellKnownEndpoint}' does not advertise required PKCE method 'S256' in 'code_challenge_methods_supported'. Advertised methods: {advertisedMethods}."); } } - catch (Exception ex) + catch (McpException ex) { LogAuthServerMetadataMissingPkceSupport(ex, wellKnownEndpoint); - pkceValidationFailure = ex as McpException ?? new McpException(ex.Message, ex); + pkceValidationFailure = ex; continue; } diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 7563acd10..ae69392b0 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -161,7 +161,7 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque try { LogAttemptingSSE(_name); - await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + await sseTransport.ConnectAsync(cancellationToken, message.Context?.DiscoverProbe).ConfigureAwait(false); await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); LogUsingSSE(_name); diff --git a/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs new file mode 100644 index 000000000..6ea4b8390 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; + +namespace ModelContextProtocol.Client; + +/// Tracks server-wait time without charging OAuth work to the discovery probe. +internal sealed class DiscoverProbe : IDisposable +{ + private static readonly TimeSpan s_maxTimerInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private readonly object _sync = new(); + private readonly CancellationTokenSource _cancellation; + private readonly Stopwatch _elapsed = Stopwatch.StartNew(); + private readonly TimeSpan _timeout; + private readonly Timer _timer; + private int _suspensions; + private bool _disposed; + + public DiscoverProbe(TimeSpan timeout, CancellationToken cancellationToken) + { + _timeout = timeout; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _timer = new Timer(_ => OnTimeout(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + ScheduleTimer(); + } + + public CancellationToken Token => _cancellation.Token; + + public IDisposable Suspend() + { + lock (_sync) + { + Token.ThrowIfCancellationRequested(); + if (_suspensions++ == 0) + { + _elapsed.Stop(); + _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + } + return new Suspension(this); + } + + private void Resume() + { + lock (_sync) + { + if (_disposed || + --_suspensions != 0) + { + return; + } + + _elapsed.Start(); + ScheduleTimer(); + } + } + + private void ScheduleTimer() + { + if (_timeout == Timeout.InfiniteTimeSpan) + { + return; + } + + var remaining = _timeout - _elapsed.Elapsed; + var interval = remaining > s_maxTimerInterval ? s_maxTimerInterval : remaining; + _timer.Change(interval > TimeSpan.Zero ? interval : TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } + + private void OnTimeout() + { + // Serialize expiry with suspension and disposal, including a timer callback that was + // already queued when OAuth suspended the budget. + lock (_sync) + { + if (_disposed || + _suspensions != 0 || + _cancellation.IsCancellationRequested) + { + return; + } + + var remaining = _timeout - _elapsed.Elapsed; + if (remaining > TimeSpan.Zero) + { + ScheduleTimer(); + return; + } + + _cancellation.Cancel(); + } + } + + public void Dispose() + { + lock (_sync) + { + _disposed = true; + _timer.Dispose(); + _cancellation.Dispose(); + } + } + + private sealed class Suspension(DiscoverProbe probe) : IDisposable + { + private DiscoverProbe? _probe = probe; + + public void Dispose() => Interlocked.Exchange(ref _probe, null)?.Resume(); + } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d1f2a9d7a..f293f43b6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -309,18 +309,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Apply a probe timeout so dual-path clients don't block forever waiting for an // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). - // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is - // always bounded by InitializationTimeout (only applied when it is the tighter bound). + // OAuth temporarily suspends only the server-wait budget. InitializationTimeout + // and caller cancellation still bound the entire connection, including authorization. var probeTimeout = _options.DiscoverProbeTimeout; - using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); - if (_options.InitializationTimeout > probeTimeout) - { - probeCts.CancelAfter(probeTimeout); - } + bool initializationBoundsProbe = + _options.InitializationTimeout != Timeout.InfiniteTimeSpan && + (probeTimeout == Timeout.InfiniteTimeSpan || + probeTimeout >= _options.InitializationTimeout); + using var probe = new DiscoverProbe( + initializationBoundsProbe ? Timeout.InfiniteTimeSpan : probeTimeout, + initializationCts.Token); try { - discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { @@ -346,7 +348,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } discoverVersion = retryVersion; - discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false); } else { @@ -391,7 +393,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // server, so fall back. Other statuses stay uncaught and surface to the caller. fallbackToInitialize = true; } - catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) + catch (OperationCanceledException) when (probe.Token.IsCancellationRequested && + !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no // response within a reasonable timeout means the server requires initialize. Fall back. @@ -453,19 +456,22 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _serverInstructions = discoverResult.Instructions; } - async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + async Task SendDiscoverAsync(string protocolVersion) { // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. _negotiatedProtocolVersion = protocolVersion; _sessionHandler.NegotiatedProtocolVersion = protocolVersion; - return await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + var request = new JsonRpcRequest + { + Method = RequestMethods.ServerDiscover, + Params = JsonSerializer.SerializeToNode(new DiscoverRequestParams(), McpJsonUtilities.JsonContext.Default.DiscoverRequestParams), + Context = new() { DiscoverProbe = probe }, + }; + var response = await SendRequestAsync(request, probe.Token).ConfigureAwait(false); + return JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.DiscoverResult) + ?? throw new JsonException("Unexpected JSON result in discovery response."); } } else diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 61a0613df..eb09c13dc 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -121,6 +121,11 @@ public sealed class McpClientOptions /// greater than or equal to , the probe is effectively bounded by /// alone. /// + /// + /// Time spent acquiring OAuth tokens, including authorization metadata discovery and interactive + /// authorization, is excluded from the probe timeout but remains subject to + /// and caller cancellation. + /// /// /// /// The value is not positive and is not . diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs index f4df789c7..d2d830084 100644 --- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs @@ -14,7 +14,8 @@ internal class McpHttpClient(HttpClient httpClient) { internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json"); - internal virtual async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) + internal virtual async Task SendAsync( + HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null) { Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage"); Debug.Assert(message is null || request.Method == HttpMethod.Post, "All messages should be sent in POST requests."); diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index 99bdc1eb9..a08cfd033 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -49,13 +49,13 @@ public SseClientSessionTransport( } /// - public async Task ConnectAsync(CancellationToken cancellationToken = default) + public async Task ConnectAsync(CancellationToken cancellationToken = default, DiscoverProbe? discoverProbe = null) { Debug.Assert(!IsConnected); try { // Start message receiving loop - _receiveTask = ReceiveMessagesAsync(_connectionCts.Token); + _receiveTask = ReceiveMessagesAsync(_connectionCts.Token, discoverProbe); await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false); } @@ -149,7 +149,7 @@ public override async ValueTask DisposeAsync() } } - private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) + private async Task ReceiveMessagesAsync(CancellationToken cancellationToken, DiscoverProbe? discoverProbe) { HttpStatusCode? failureStatusCode = null; try @@ -158,7 +158,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null); - using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false); + using var response = await _httpClient.SendAsync(request, message: null, cancellationToken, discoverProbe).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs index ef61df263..0a108ff0a 100644 --- a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -16,12 +16,14 @@ namespace ModelContextProtocol.Protocol; /// . /// /// -/// This converter is applied per-property on the cacheable result types. The -/// enum itself retains a standard string converter for any standalone serialization. +/// This converter is applied per-property on the cacheable result types, and is public so that extension +/// packages defining their own types can apply the same read-side leniency. The +/// enum itself retains a standard string converter for any standalone serialization. /// /// -internal sealed class CacheScopeConverter : JsonConverter +public sealed class CacheScopeConverter : JsonConverter { + /// public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.String) @@ -52,6 +54,7 @@ internal sealed class CacheScopeConverter : JsonConverter return null; } + /// public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options) { if (value is null) diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 0b56caa29..478fab0ea 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -15,6 +15,8 @@ namespace ModelContextProtocol.Protocol; /// public sealed class JsonRpcMessageContext { + internal Client.DiscoverProbe? DiscoverProbe { get; set; } + /// /// Gets or sets the transport the was received on or should be sent over. /// diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs index cc7e33f24..0713feb84 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs @@ -10,8 +10,14 @@ namespace ModelContextProtocol.Protocol; /// public abstract class PaginatedRequestParams : RequestParams { - /// Prevent external derivations. - private protected PaginatedRequestParams() + /// + /// Initializes a new instance of the class. + /// + /// + /// This constructor is rather than private protected so that extension + /// packages implementing paginated methods defined outside the core specification can derive from it. + /// + protected PaginatedRequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs index df9dc4475..e496e0921 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs @@ -17,7 +17,14 @@ namespace ModelContextProtocol.Protocol; /// public abstract class PaginatedResult : Result { - private protected PaginatedResult() + /// + /// Initializes a new instance of the class. + /// + /// + /// This constructor is rather than private protected so that extension + /// packages implementing paginated methods defined outside the core specification can derive from it. + /// + protected PaginatedResult() { } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index c755cd9e5..5ded4dac4 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -67,46 +67,59 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt } private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( - MethodInfo method, McpServerPromptCreateOptions? options) => - new() + MethodInfo method, McpServerPromptCreateOptions? options) + { + var schemaOptions = options?.SchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; + return new() { Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? AIFunctionMcpServerTool.DeriveName(method), Description = options?.Description, MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - JsonSchemaCreateOptions = options?.SchemaCreateOptions, - ConfigureParameterBinding = pi => + JsonSchemaCreateOptions = schemaOptions with { - if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || - (options?.Services?.GetService() is { } ispis && - ispis.IsService(pi.ParameterType))) - { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - args.Services?.GetService(pi.ParameterType) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } - - if (pi.GetCustomAttribute() is { } keyedAttr) - { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - (args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } - - return default; + // Match injected parameters by their binding rules, not ParameterInfo identity. + IncludeParameter = parameter => + !ConfigureParameterBinding(parameter).ExcludeFromSchema && + (schemaOptions.IncludeParameter?.Invoke(parameter)).GetValueOrDefault(true), }, + ConfigureParameterBinding = ConfigureParameterBinding, }; + AIFunctionFactoryOptions.ParameterBindingOptions ConfigureParameterBinding(ParameterInfo pi) + { + if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || + (options?.Services?.GetService() is { } ispis && + ispis.IsService(pi.ParameterType))) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + args.Services?.GetService(pi.ParameterType) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } + + if (pi.GetCustomAttribute() is { } keyedAttr) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + (args.Services is IKeyedServiceProvider keyedServices + ? keyedServices.GetKeyedService(pi.ParameterType, keyedAttr.Key) + : null) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } + + return default; + } + } + /// Creates an that wraps the specified . public static new AIFunctionMcpServerPrompt Create(AIFunction function, McpServerPromptCreateOptions? options) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 82b6ceb9d..d4447dc12 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -69,45 +69,59 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool } private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( - MethodInfo method, McpServerToolCreateOptions? options) => - new() + MethodInfo method, McpServerToolCreateOptions? options) + { + var schemaOptions = options?.SchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; + return new() { Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? DeriveName(method), Description = options?.Description, MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - JsonSchemaCreateOptions = options?.SchemaCreateOptions, - ConfigureParameterBinding = pi => + JsonSchemaCreateOptions = schemaOptions with + { + // Schema generation can enumerate distinct ParameterInfo instances, including + // when the runtime's cold parameter cache is initialized concurrently. + IncludeParameter = parameter => + !ConfigureParameterBinding(parameter).ExcludeFromSchema && + (schemaOptions.IncludeParameter?.Invoke(parameter)).GetValueOrDefault(true), + }, + ConfigureParameterBinding = ConfigureParameterBinding, + }; + + AIFunctionFactoryOptions.ParameterBindingOptions ConfigureParameterBinding(ParameterInfo pi) + { + if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || + (options?.Services?.GetService() is { } ispis && + ispis.IsService(pi.ParameterType))) { - if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || - (options?.Services?.GetService() is { } ispis && - ispis.IsService(pi.ParameterType))) + return new() { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - args.Services?.GetService(pi.ParameterType) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } + ExcludeFromSchema = true, + BindParameter = (pi, args) => + args.Services?.GetService(pi.ParameterType) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } - if (pi.GetCustomAttribute() is { } keyedAttr) + if (pi.GetCustomAttribute() is { } keyedAttr) + { + return new() { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - (args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } + ExcludeFromSchema = true, + BindParameter = (pi, args) => + (args.Services is IKeyedServiceProvider keyedServices + ? keyedServices.GetKeyedService(pi.ParameterType, keyedAttr.Key) + : null) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } - return default; - }, - }; + return default; + } + } /// Creates an that wraps the specified . public static new AIFunctionMcpServerTool Create(AIFunction function, McpServerToolCreateOptions? options) diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs new file mode 100644 index 000000000..2baa603fb --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -0,0 +1,397 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to discover skills and verify their file manifests under the +/// MCP Skills extension (SEP-2640). +/// +/// +/// These methods validate entries and verify file content against manifests. They do not verify a skill's +/// frontmatter, which a host must still do before loading it; see . +/// +public static class McpSkillsClientExtensions +{ + /// + /// Gets whether the server declared the MCP Skills extension in its capabilities. + /// + /// The client. + /// if the server serves skills/list and skills/get; otherwise, . + /// is . + /// + /// Clients must issue skills/list and skills/get only after observing the server's declaration. + /// The other methods in this class throw when it is absent. + /// + public static bool SupportsSkills(this McpClient client) + { +#if NET + ArgumentNullException.ThrowIfNull(client); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); +#endif + + return client.ServerCapabilities?.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true; + } + + /// + /// Retrieves the listed skills, following at most 100 pages and collecting at most 1,000 entries. + /// + /// The client. + /// The to monitor for cancellation requests. The default is . + /// The listed skills. + /// is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification (for example, a name that does not match its + /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries. + /// + /// + /// The request failed, a pagination cursor repeated, or the listing exceeded the page or entry limit. + /// + /// + /// + /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than + /// it serves. Do not treat an empty listing as proof that a server has no skills; a skill's entry can always be + /// retrieved by URI with . + /// + /// + /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use + /// , which returns one page at a time. + /// Use to choose different limits. + /// Exceeding a limit fails the operation rather than returning a silently truncated listing. + /// + /// + public static ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) => + ListSkillsAsync(client, maxPages: 100, maxSkills: 1000, cancellationToken); + + /// + /// Retrieves the listed skills with explicit page and entry limits. + /// + /// The client. + /// The maximum number of pages to request. Must be positive. + /// The maximum number of entries to collect. Must be positive. + /// The token used to cancel the operation. + /// The complete listing within the supplied limits. + /// is . + /// A limit is not positive. + /// The server did not declare the Skills extension. + /// An entry is invalid. + /// The request failed, a cursor repeated, or a limit was exceeded. + /// + /// Cursors are opaque, including an empty string. Only a null cursor ends the listing. + /// Use the paged overload when the application needs to stream a larger listing. + /// + public static async ValueTask> ListSkillsAsync( + this McpClient client, + int maxPages, + int maxSkills, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); +#endif + if (maxPages <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxPages), "The page limit must be positive."); + } + if (maxSkills <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxSkills), "The skill limit must be positive."); + } + + var skills = new List(); + var requestParams = new ListSkillsRequestParams(); + var cursors = new HashSet(StringComparer.Ordinal); + for (int pageNumber = 0; pageNumber < maxPages; pageNumber++) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); + if (page.Skills.Count > maxSkills - skills.Count) + { + throw new McpException($"The skill listing exceeded the limit of {maxSkills} entries. Use the paged overload or a larger explicit limit."); + } + skills.AddRange(page.Skills); + if (page.NextCursor is null) + { + return skills; + } + if (!cursors.Add(page.NextCursor)) + { + throw new McpException("The server repeated a skills/list pagination cursor."); + } + requestParams.Cursor = page.NextCursor; + } + + throw new McpException($"The skill listing exceeded the limit of {maxPages} pages. Use the paged overload or a larger explicit limit."); + } + + /// + /// Retrieves one page of the skills the server lists. + /// + /// The client. + /// The request parameters, including the cursor of the page to retrieve. + /// The to monitor for cancellation requests. The default is . + /// The page with every entry structurally validated, but not compared with the skill's files. + /// or is . + /// The server did not declare the MCP Skills extension. + /// The server returned an entry that violates the specification. + /// The request failed or the server returned an error response. + public static async ValueTask ListSkillsAsync( + this McpClient client, + ListSkillsRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(ListSkillsAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsList, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + if (result.Skills is null) + { + throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); + } + + foreach (var skill in result.Skills) + { + SkillValidation.ValidateReceived(skill, SkillsProtocol.MethodSkillsList); + } + + return result; + } + + /// + /// Retrieves a page's valid entries and reports each rejected entry without discarding the rest of the page. + /// + /// The client. + /// The request parameters, including the page cursor. + /// The token used to cancel the operation. + /// The valid entries, original pagination/cache hints, and explicit per-entry failures. + /// An argument is . + /// The server did not declare the Skills extension. + /// The page envelope is malformed or lacks a skills array. + /// The request failed or the server returned an error. + /// + /// This is an explicit partial-results API. The ordinary list overloads remain strict. + /// Callers must inspect ; an empty valid-entry list can + /// mean that every entry was rejected. Valid entries still require normal file/frontmatter verification. + /// + public static async ValueTask ListSkillsWithDiagnosticsAsync( + this McpClient client, + ListSkillsRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + ThrowIfSkillsNotSupported(client, nameof(ListSkillsWithDiagnosticsAsync)); + + var request = new JsonRpcRequest + { + Method = SkillsProtocol.MethodSkillsList, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), + }; + var response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + if (response.Result is not JsonObject rawResult || + rawResult["skills"] is not JsonArray entries) + { + throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); + } + + var envelope = new JsonObject(); + foreach (var property in rawResult) + { + envelope[property.Key] = property.Key == "skills" ? new JsonArray() : property.Value?.DeepClone(); + } + var page = envelope.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + var rejected = new List(); + for (int index = 0; index < entries.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var skill = entries[index]?.Deserialize(McpSkillsJsonContext.Default.Skill); + SkillValidation.ValidateReceived(skill!, SkillsProtocol.MethodSkillsList); + page.Skills.Add(skill!); + } + catch (Exception error) when (error is JsonException or SkillVerificationException) + { + rejected.Add(new SkillEntryRejection(index, error)); + } + } + + return new SkillListingResult(page, rejected.AsReadOnly()); + } + + /// + /// Retrieves the entry for a single skill by the URI of its SKILL.md. + /// + /// The client. + /// The URI of the skill's SKILL.md. + /// The to monitor for cancellation requests. The default is . + /// The structurally validated skill entry, not a verification of the file's frontmatter. + /// or is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// + /// + /// The request failed or the server returned an error response, including + /// when the server serves no skill at . + /// + /// + /// A server answers for every skill it serves, whether or not the skill appears in its listing. Use this to + /// confirm a URI referenced from server instructions or another skill, and to refresh one skill's manifest + /// without re-enumerating the catalog. + /// + public static async ValueTask GetSkillAsync(this McpClient client, string uri, CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri }, cancellationToken).ConfigureAwait(false); + return result.Skill; + } + + /// + /// Retrieves the entry for a single skill using explicit request parameters. + /// + /// The client. + /// The request parameters. + /// The to monitor for cancellation requests. The default is . + /// The result with its entry structurally validated, but not compared with the skill's files. + /// or is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// + /// The request failed or the server returned an error response. + public static async ValueTask GetSkillAsync( + this McpClient client, + GetSkillRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(GetSkillAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsGet, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.GetSkillRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); + + SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); + if (!string.Equals(result.Skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestParams.Uri}' with the entry for '{result.Skill.Uri}'."); + } + + return result; + } + + /// + /// Reads one of a skill's files through resources/read and verifies its URI, digest and size against the manifest. + /// + /// The client. + /// The skill entry being acted on. + /// The URI of the file to read. It must be listed in 's manifest. + /// The to monitor for cancellation requests. The default is . + /// The manifest-verified contents. Frontmatter verification remains the host's responsibility. + /// An argument is . + /// + /// 's manifest is . Such a skill offers no content + /// integrity; read its files with + /// only if the host permits content without digest verification. Frontmatter verification is still required. + /// + /// + /// is not listed in the manifest, the server's response does not contain contents for + /// , or any returned content's size or digest does not match its manifest entry. + /// + /// The request failed or the server returned an error response. + /// + /// + /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is + /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with + /// and proceed from the new manifest. + /// + /// + /// For SKILL.md, the host must also parse the fetched frontmatter and compare it with + /// before loading. A digest match does not perform or replace that check. + /// + /// + public static async ValueTask ReadSkillResourceAsync( + this McpClient client, + Skill skill, + string uri, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + SkillVerifier.ThrowIfDynamic(skill); + if (SkillVerifier.FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + SkillVerifier.Verify(skill, uri, result); + return result; + } + + private static void ThrowIfSkillsNotSupported(McpClient client, string operationName) + { + if (!SupportsSkills(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs new file mode 100644 index 000000000..2598282ad --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs @@ -0,0 +1,23 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Describes one entry rejected while reading a diagnostic listing page. +/// +public sealed class SkillEntryRejection +{ + internal SkillEntryRejection(int index, Exception error) + { + Index = index; + Error = error; + } + + /// + /// Gets the zero-based position in the server's original page. + /// + public int Index { get; } + + /// + /// Gets the parsing or structural-validation failure. No rejected entry is returned as a valid skill. + /// + public Exception Error { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs new file mode 100644 index 000000000..7aeb969dc --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs @@ -0,0 +1,23 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// A client-side listing page with valid entries and explicit per-entry failures. +/// +public sealed class SkillListingResult +{ + internal SkillListingResult(ListSkillsResult page, IReadOnlyList rejectedEntries) + { + Page = page; + RejectedEntries = rejectedEntries; + } + + /// + /// Gets the valid entries and the server's pagination and cache hints. + /// + public ListSkillsResult Page { get; } + + /// + /// Gets the rejected entries. Callers must inspect these even when contains no skills. + /// + public IReadOnlyList RejectedEntries { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs new file mode 100644 index 000000000..16653ce52 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs @@ -0,0 +1,39 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// The exception thrown when a skill entry or its file content fails validation, including a manifest mismatch +/// or an unlisted file. Hosts can also use it for a failed frontmatter comparison. +/// +/// +/// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale +/// because the skill was updated after the entry was fetched. In all cases the content must not be used. To +/// recover from staleness, fetch a fresh entry with skills/get and proceed from its manifest; because the +/// manifest changed, any approval bound to the previous one is revoked. +/// +public sealed class SkillVerificationException : McpException +{ + /// + /// Initializes a new instance of the class. + /// + public SkillVerificationException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public SkillVerificationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public SkillVerificationException(string message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs new file mode 100644 index 000000000..927c44e71 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -0,0 +1,277 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Security.Cryptography; +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Verifies skill file content against a skill's manifest, and computes manifest digests. +/// +/// +/// +/// When a host retrieves a file listed in a skill's manifest, it must verify the content against that entry's +/// digest and size, and must treat a read of a file the manifest does not list as a verification failure. These +/// helpers implement those checks. +/// +/// +/// They do not verify frontmatter. Before loading a skill, the host must also parse the fetched SKILL.md +/// itself and compare its frontmatter field by field with the held entry, treating any difference as a +/// verification failure. and +/// do this for the YAML the reader supports. For anything else, the host rejects the skill or uses a full YAML +/// parser; the entry's own frontmatter is never a substitute for parsing the file. +/// +/// +/// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest +/// and the content are consistent, not that either is trustworthy. +/// +/// +public static class SkillVerifier +{ + /// + /// Computes the manifest digest of content: sha256: followed by the lowercase hexadecimal SHA-256 of the bytes. + /// + /// The raw bytes. + /// The digest. + public static string ComputeDigest(ReadOnlySpan content) + { +#if NET + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(content, hash); +#else + byte[] hash; + using (var sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(content.ToArray()); + } +#endif + + var builder = new StringBuilder(SkillsProtocol.DigestPrefix, SkillsProtocol.DigestPrefix.Length + (hash.Length * 2)); + foreach (byte b in hash) + { + builder.Append(ToHexChar(b >> 4)).Append(ToHexChar(b & 0xF)); + } + + return builder.ToString(); + + static char ToHexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + } + + /// + /// Verifies raw content against a manifest entry. + /// + /// The manifest entry. + /// The bytes that were read. + /// is . + /// The size or digest of does not match . + public static void Verify(SkillResource expected, ReadOnlySpan content) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); +#endif + + if (content.Length != expected.Size) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' is {content.Length} bytes, but its manifest entry declares {expected.Size} bytes."); + } + + string actualDigest = ComputeDigest(content); + if (!string.Equals(actualDigest, expected.Digest, StringComparison.OrdinalIgnoreCase)) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' has digest '{actualDigest}', but its manifest entry declares '{expected.Digest}'."); + } + } + + /// + /// Verifies the contents returned by resources/read against a manifest entry. + /// + /// The manifest entry. + /// The contents that were read. + /// An argument is . + /// + /// The contents are not for 's URI, are neither text nor a blob, or their size or + /// digest does not match . + /// + /// + /// Text contents are hashed as their UTF-8 encoding; blob contents are hashed as their decoded bytes. + /// + public static void Verify(SkillResource expected, ResourceContents contents) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(contents); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); + if (contents is null) throw new ArgumentNullException(nameof(contents)); +#endif + + if (!string.Equals(contents.Uri, expected.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The contents are for '{contents.Uri}', but verification was requested against the manifest entry for '{expected.Uri}'."); + } + + switch (contents) + { + case TextResourceContents text: + if (text.Text is null) + { + throw new SkillVerificationException($"The text contents of '{contents.Uri}' carry no text and cannot be verified."); + } + + Verify(expected, Encoding.UTF8.GetBytes(text.Text)); + break; + + case BlobResourceContents blob: + ReadOnlyMemory decoded; + try + { + decoded = blob.DecodedData; + } + catch (FormatException e) + { + throw new SkillVerificationException($"The blob contents of '{contents.Uri}' are not valid Base64.", e); + } + + Verify(expected, decoded.Span); + break; + + default: + throw new SkillVerificationException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified."); + } + } + + /// + /// Verifies the result of a resources/read for one of a skill's files against the skill's manifest. + /// + /// The skill entry being acted on. + /// The result of reading a file of the skill. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// The result is empty, contains contents for a URI the manifest does not list, or contains contents whose + /// size or digest does not match the manifest. + /// + public static void Verify(Skill skill, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + ThrowIfDynamic(skill); + + if (result.Contents is not { Count: > 0 }) + { + throw new SkillVerificationException($"The read returned no contents to verify against skill '{skill.Uri}'."); + } + + foreach (var contents in result.Contents) + { + var expected = FindResource(skill, contents.Uri) ?? + throw new SkillVerificationException( + $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + + Verify(expected, contents); + } + } + + /// + /// Verifies the result of reading a specific file of a skill through resources/read against the skill's + /// manifest, additionally requiring that the result actually contains the requested file. + /// + /// The skill entry being acted on. + /// The URI that was requested. It must be listed in 's manifest. + /// The result of the read. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// is not listed in the manifest, the result does not contain contents for + /// , or any contents in the result fail verification per . + /// + /// + /// Checking every returned content against the manifest is not enough on its own: a server could answer a read + /// of one file with another, correctly digested, file of the same skill. Binding the result to the requested + /// URI closes that gap. + /// + public static void Verify(Skill skill, string uri, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + ThrowIfDynamic(skill); + if (FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + Verify(skill, result); + + bool found = false; + foreach (var contents in result.Contents) + { + found |= string.Equals(contents.Uri, uri, StringComparison.Ordinal); + } + + if (!found) + { + throw new SkillVerificationException( + $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'."); + } + } + + /// + /// Rejects a skill whose manifest is missing (the property is required but can still be assigned + /// ) or dynamic, neither of which has a manifest that can be digest-verified. + /// + internal static void ThrowIfDynamic(Skill skill) + { + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest.", nameof(skill)); + } + + if (skill.Resources.IsDynamic) + { + throw new InvalidOperationException( + $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified. " + + $"Use {nameof(McpClient.ReadResourceAsync)} directly if unverifiable content is acceptable."); + } + } + + internal static SkillResource? FindResource(Skill skill, string uri) + { + var resources = skill.Resources?.Resources; + if (resources is null) + { + return null; + } + + foreach (var resource in resources) + { + if (string.Equals(resource.Uri, uri, StringComparison.Ordinal)) + { + return resource; + } + } + + return null; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs new file mode 100644 index 000000000..10e451bc2 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Provides source-generated JSON serialization metadata for the MCP Skills extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(Skill))] +[JsonSerializable(typeof(SkillResource))] +[JsonSerializable(typeof(SkillResources))] +[JsonSerializable(typeof(ListSkillsRequestParams))] +[JsonSerializable(typeof(ListSkillsResult))] +[JsonSerializable(typeof(GetSkillRequestParams))] +[JsonSerializable(typeof(GetSkillResult))] +public sealed partial class McpSkillsJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj new file mode 100644 index 000000000..00a0bf57f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -0,0 +1,55 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Skills + MCP Skills extension (SEP-2640) for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs new file mode 100644 index 000000000..4ba7ff040 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs @@ -0,0 +1,24 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the parameters for a skills/get request retrieving a single skill's entry by URI. +/// +/// +/// See the Skills extension specification +/// for details. +/// +public sealed class GetSkillRequestParams : RequestParams +{ + /// + /// Gets or sets the URI of the skill's SKILL.md. + /// + /// + /// If the URI does not identify a skill the server serves, the server returns error -32602 + /// (Invalid params), the same code resources/read uses for unknown resources. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs new file mode 100644 index 000000000..805ac0a9f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs @@ -0,0 +1,36 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a server's response to a skills/get request, containing one skill's entry. +/// +/// +/// +/// A server answers for every skill it serves, whether or not that skill appears in its skills/list +/// result. The result carries no pagination cursor. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class GetSkillResult : Result, ICacheableResult +{ + /// + /// Gets or sets the skill's entry, identical in shape and meaning to an entry of skills/list. + /// + [JsonPropertyName("skill")] + public required Skill Skill { get; set; } + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs new file mode 100644 index 000000000..41bd87a95 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs @@ -0,0 +1,12 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the parameters for a skills/list request enumerating the skills a server serves. +/// +/// +/// See the Skills extension specification +/// for details. +/// +public sealed class ListSkillsRequestParams : PaginatedRequestParams; diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs new file mode 100644 index 000000000..5ed0973da --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a server's response to a skills/list request, containing the skills it serves. +/// +/// +/// +/// The result may be empty or partial. A server whose skill catalog is large, generated on demand, or +/// otherwise unenumerable may return fewer skills than it serves, and hosts must not treat an empty listing as +/// proof that a server has no skills. Skills absent from a listing remain retrievable through skills/get. +/// +/// +/// An entry is atomic: a skill's manifest is never split across pages. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class ListSkillsResult : PaginatedResult, ICacheableResult +{ + /// + /// Gets or sets the skill entries. + /// + [JsonPropertyName("skills")] + [JsonRequired] + public IList Skills { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs new file mode 100644 index 000000000..d3716da3c --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the entry for a single skill, as returned by skills/list and skills/get. +/// +/// +/// +/// An entry is a complete, point-in-time snapshot of a skill: the URI of its SKILL.md, the verbatim +/// frontmatter of that file, and a manifest of the skill's files with their digests and sizes. A host that +/// pages through a listing has everything it needs to build its registry, present a skill for approval, and +/// verify every file it later reads, without a second round-trip per skill. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class Skill +{ + /// + /// Gets or sets the resource URI of the skill's SKILL.md, readable via resources/read. + /// + /// + /// The path segment preceding /SKILL.md must equal the name field of , + /// so that a skill's name is recoverable from its URI alone. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + /// + /// Gets or sets the skill's SKILL.md YAML frontmatter, rendered verbatim as a JSON object. + /// + /// + /// Every field the author wrote is passed through, not a curated subset. name and description + /// are always present. Hosts re-parse the fetched SKILL.md and compare its frontmatter against this + /// object field by field, treating any discrepancy as a verification failure, so this must reproduce the + /// authored frontmatter exactly. Structural validation of the entry does not read the file to check that. + /// + [JsonPropertyName("frontmatter")] + public required JsonObject Frontmatter { get; set; } + + /// + /// Gets or sets the skill's file manifest: an enumeration of every file with its digest and size, or + /// when the skill's content is generated and cannot be digested. + /// + [JsonPropertyName("resources")] + public required SkillResources Resources { get; set; } + + /// + /// Gets the skill's name from , or if it is absent or not a string. + /// + /// + /// A skill's name is a label, not an identifier. Skills are identified by within a server, + /// and by the pair of server identity and across servers. + /// + [JsonIgnore] + public string? Name => GetFrontmatterString("name"); + + /// + /// Gets the skill's description from , or if it is absent or not a string. + /// + [JsonIgnore] + public string? Description => GetFrontmatterString("description"); + + private string? GetFrontmatterString(string key) => + Frontmatter.TryGetPropertyValue(key, out var node) && + node is JsonValue value && + value.TryGetValue(out string? text) + ? text + : null; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs new file mode 100644 index 000000000..d58590a1b --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a single file belonging to a skill, as listed in a manifest. +/// +/// +/// +/// This is distinct from , the base protocol's resource +/// metadata type. A carries only the integrity information a host needs to verify +/// a skill's file: its URI, digest, and size. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class SkillResource +{ + /// + /// Gets or sets the resource URI of the file, readable via resources/read. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + /// + /// Gets or sets the SHA-256 digest of the file's raw bytes, formatted as sha256:{hex} where + /// {hex} is 64 lowercase hexadecimal characters. + /// + /// + /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the + /// manifest and the content are consistent; it is not a security boundary and must not be treated as one. + /// + [JsonPropertyName("digest")] + public required string Digest { get; set; } + + /// + /// Gets or sets the length in bytes of the file's raw content, being the same bytes covers. + /// + /// + /// A read whose byte length differs from this value is a verification failure equivalent to a digest mismatch. + /// + [JsonPropertyName("size")] + public required long Size { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs new file mode 100644 index 000000000..68aa5b2dd --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a skill's file manifest: either a complete enumeration of the skill's files, or the +/// "dynamic" marker indicating that the skill's content is generated and cannot be digested. +/// +/// +/// +/// The specification requires this value on every skill entry and admits exactly two forms. An entry with no +/// manifest at all, or with any value other than an array or the string "dynamic", is invalid and hosts +/// must not load it. Use or to construct one; there is +/// deliberately no public constructor, so an invalid manifest cannot be produced by accident. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +[JsonConverter(typeof(SkillResourcesConverter))] +public sealed class SkillResources +{ + private SkillResources(IReadOnlyList? resources) => Resources = resources; + + /// + /// Gets a manifest representing a skill whose content is generated dynamically. + /// + /// + /// A skill declared this way offers no content integrity and cannot be content-bound. Hosts may decline + /// to load it, and server authors should expect that some will. + /// + public static SkillResources Dynamic { get; } = new(null); + + /// + /// Creates a manifest enumerating every file of a skill. + /// + /// + /// The skill's complete file list. It must include an entry whose URI equals the skill's own + /// , carrying the digest and size of the SKILL.md itself. + /// + /// A manifest wrapping a copy of . + /// is . + public static SkillResources FromResources(IEnumerable resources) + { +#if NET + ArgumentNullException.ThrowIfNull(resources); +#else + if (resources is null) throw new ArgumentNullException(nameof(resources)); +#endif + + return new SkillResources(resources.ToArray()); + } + + /// + /// Gets a value indicating whether this manifest is the "dynamic" marker. + /// + public bool IsDynamic => Resources is null; + + /// + /// Gets the enumerated files, or when is . + /// + public IReadOnlyList? Resources { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs new file mode 100644 index 000000000..8c8aea26e --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Serializes as either a JSON array of or the +/// literal string "dynamic", and rejects every other shape. +/// +internal sealed class SkillResourcesConverter : JsonConverter +{ + /// + /// Gets a value indicating that this converter is invoked for null tokens. + /// + /// + /// Without this, System.Text.Json assigns directly for a reference type and never + /// calls , so "resources": null would be silently accepted. The specification + /// requires a manifest on every entry and admits only an array or the "dynamic" string, so a null + /// must be rejected like any other invalid value. + /// + public override bool HandleNull => true; + + public override SkillResources Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found null."); + + case JsonTokenType.String: + string? sentinel = reader.GetString(); + if (!string.Equals(sentinel, SkillsProtocol.DynamicResourcesSentinel, StringComparison.Ordinal)) + { + throw new JsonException( + $"Invalid skill manifest: expected the string '{SkillsProtocol.DynamicResourcesSentinel}' but found '{sentinel}'."); + } + + return SkillResources.Dynamic; + + case JsonTokenType.StartArray: + var resources = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + return SkillResources.FromResources(resources); + } + + var resource = JsonSerializer.Deserialize(ref reader, McpSkillsJsonContext.Default.SkillResource) ?? + throw new JsonException("Invalid skill manifest: a resource entry was null."); + resources.Add(resource); + } + + throw new JsonException("Invalid skill manifest: unterminated array."); + + default: + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found {reader.TokenType}."); + } + } + + public override void Write(Utf8JsonWriter writer, SkillResources value, JsonSerializerOptions options) + { + if (value.IsDynamic) + { + writer.WriteStringValue(SkillsProtocol.DynamicResourcesSentinel); + return; + } + + writer.WriteStartArray(); + foreach (var resource in value.Resources!) + { + JsonSerializer.Serialize(writer, resource, McpSkillsJsonContext.Default.SkillResource); + } + + writer.WriteEndArray(); + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs new file mode 100644 index 000000000..1afb87aad --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -0,0 +1,67 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Supplies the skills a server serves. +/// +/// +/// +/// A catalog is the source of truth behind a server's skills/list and skills/get methods. +/// serves a fixed set of entries; implement this interface directly when +/// skills come from a database, a file share, or another source that should not be loaded up front. +/// +/// +/// The two methods are deliberately independent. A server may enumerate only part of its catalog, or none of +/// it, while still answering for every skill it serves by URI. +/// +/// +/// A catalog is responsible only for the entries. The skills' files must additionally be served as ordinary +/// resources through resources/read, since that is how hosts fetch skill content. +/// +/// +/// Each entry's must be identical to the frontmatter of the SKILL.md it +/// describes. The server validates an entry's structure but does not read the files to check this; building +/// entries with is one way to keep the two in agreement. +/// +/// +/// The skills methods do not pass through the request filters that guard the built-in resource methods, so a +/// catalog whose skills are not visible to every caller must decide what to return from the +/// it receives, and the corresponding resources must be guarded the same way. +/// +/// +public interface IMcpSkillCatalog +{ + /// + /// Lists a page of the skills this catalog publishes. + /// + /// + /// An opaque cursor returned by a previous call, or to start at the first page. + /// + /// The request being answered, including the caller's identity when the transport supplies one. + /// The to monitor for cancellation requests. + /// + /// A page of entries, and the cursor for the following page when more entries remain. A skill's manifest is + /// never split across pages. + /// + /// + /// Returning an empty page is valid. Hosts must not treat an empty listing as proof that a server has no skills. + /// Throw with for a cursor this + /// catalog cannot interpret. A cursor is an opaque position, not a capability: a catalog may resume from a + /// well-formed cursor it does not recognize, as does. + /// + ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken); + + /// + /// Gets the entry for a single skill by the URI of its SKILL.md. + /// + /// The URI of the skill's SKILL.md. + /// The request being answered, including the caller's identity when the transport supplies one. + /// The to monitor for cancellation requests. + /// + /// The skill's entry, or if this catalog does not serve a skill at + /// to this caller. + /// + /// + /// This must answer for every skill the server serves to the caller, including skills omitted from . + /// + ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken); +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs new file mode 100644 index 000000000..ceb5387a1 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -0,0 +1,142 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// An over a fixed set of skill entries held in memory. +/// +/// +/// +/// Every entry is validated against the specification's structural requirements when the catalog is constructed. +/// It does not read the skills' files, so each entry's frontmatter must already match its SKILL.md. +/// +/// +/// The catalog keeps its own copy of every entry and hands out copies, so neither later changes to the objects +/// passed to the constructor nor changes to a returned entry affect what is served. Entries are ordered by URI so that pagination is stable across calls. Cursors are +/// keyset cursors over that order rather than offsets. +/// +/// +/// Every caller sees the same entries. For a catalog whose contents depend on the caller, implement +/// directly and consult . +/// +/// +public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog +{ + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + private readonly Skill[] _ordered; + private readonly string[] _orderedUris; + private readonly Dictionary _byUri; + private readonly int _pageSize; + + /// + /// Initializes a new instance of the class. + /// + /// The skills this catalog serves. + /// The maximum number of entries returned per call. Defaults to 50. + /// is . + /// is less than 1. + /// + /// Two skills share the same URI, or an entry violates the specification's structural requirements (for example, + /// its name does not match its URI, its manifest omits its own SKILL.md, or a digest is malformed). + /// + public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) + { +#if NET + ArgumentNullException.ThrowIfNull(skills); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1); +#else + if (skills is null) throw new ArgumentNullException(nameof(skills)); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); +#endif + + _pageSize = pageSize; + _byUri = new Dictionary(StringComparer.Ordinal); + foreach (var skill in skills) + { + SkillValidation.Validate(skill, nameof(skills)); + var snapshot = SkillValidation.Snapshot(skill); + SkillValidation.Validate(snapshot, nameof(skills)); + + if (_byUri.ContainsKey(snapshot.Uri)) + { + throw new ArgumentException($"Duplicate skill URI '{snapshot.Uri}'.", nameof(skills)); + } + + _byUri.Add(snapshot.Uri, snapshot); + } + + _ordered = [.. _byUri.Values]; + Array.Sort(_ordered, static (left, right) => string.CompareOrdinal(left.Uri, right.Uri)); + _orderedUris = Array.ConvertAll(_ordered, static skill => skill.Uri); + } + + /// + /// Gets the number of skills in this catalog. + /// + public int Count => _ordered.Length; + + /// + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + int start = 0; + if (DecodeCursor(cursor) is { } afterUri) + { + int index = Array.BinarySearch(_orderedUris, afterUri, StringComparer.Ordinal); + start = index >= 0 ? index + 1 : ~index; + } + + int count = Math.Min(_pageSize, _ordered.Length - start); + if (count <= 0) + { + return new ValueTask(McpSkillPage.Empty); + } + + var page = new Skill[count]; + for (int i = 0; i < count; i++) + { + page[i] = SkillValidation.Snapshot(_ordered[start + i]); + } + + bool hasMore = start + count < _ordered.Length; + return new ValueTask(new McpSkillPage + { + Skills = page, + NextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null, + }); + } + + /// + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + cancellationToken.ThrowIfCancellationRequested(); + + return new ValueTask(_byUri.TryGetValue(uri, out var skill) ? SkillValidation.Snapshot(skill) : null); + } + + private static string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri)); + + private static string? DecodeCursor(string? cursor) + { + if (string.IsNullOrEmpty(cursor)) + { + return null; + } + + try + { + return s_strictUtf8.GetString(Convert.FromBase64String(cursor!)); + } + catch (Exception exception) when (exception is FormatException or DecoderFallbackException) + { + throw new McpProtocolException($"Invalid cursor '{cursor}'.", McpErrorCode.InvalidParams); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs new file mode 100644 index 000000000..c3f2e6526 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -0,0 +1,720 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a skill served by an MCP server: its entry together with the +/// instances that serve the skill's files. +/// +/// +/// +/// The specification requires a skill's manifest to carry the SHA-256 digest and size of every file, and a host +/// refuses content whose bytes do not match. Building a skill through +/// or (or their overloads) computes the manifest from the same bytes the +/// resources serve, so the two cannot disagree. +/// +/// +/// Overloads without explicit metadata read the frontmatter with and reject +/// unsupported or malformed input rather than publish guessed metadata. Overloads accepting a +/// exist for YAML the reader does not support; see +/// for what they check and what remains +/// the caller's responsibility. +/// +/// +/// Register skills with , +/// which registers both the catalog entries and the file resources, or point +/// +/// at a directory of skills. +/// +/// +public sealed class McpServerSkill +{ + private readonly Skill _entry; + + private McpServerSkill(Skill entry, IReadOnlyList resources) + { + // The entry may hold the caller's frontmatter object; keep a private copy so later changes to it do not + // reach the skill. + _entry = SkillValidation.Snapshot(entry); + Resources = resources; + } + + /// + /// Gets a copy of the skill's entry, as returned by skills/list and skills/get. + /// + /// + /// Each access returns a new copy. The manifest was computed from the same bytes serve; + /// frontmatter was read or supplied at creation. Changes to a returned copy affect neither the skill nor + /// what WithSkills registers. + /// + public Skill ProtocolSkill => SkillValidation.Snapshot(_entry); + + /// + /// Gets the resources serving the skill's files, one per file, each addressable at the URI its manifest entry names. + /// + public IReadOnlyList Resources { get; } + + /// + /// Creates a skill from its files, reading the frontmatter from SKILL.md and deriving the skill's URI + /// from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// is . + /// + /// omits SKILL.md, contains a duplicate or unsafe path, or exceeds the + /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (see + /// ) or is missing a required field. + /// + public static McpServerSkill Create(IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(files); +#else + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files, reading the frontmatter from SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md; omits SKILL.md, + /// contains a duplicate or unsafe path, or exceeds the specification's per-skill limits; or the SKILL.md + /// frontmatter cannot be read (see ), is missing a required field, or has a + /// name that does not match . + /// + public static McpServerSkill Create(string uri, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files with explicitly supplied frontmatter. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md; is missing a required + /// field, has a name that does not match , or differs from frontmatter the reader can + /// read completely; or omits SKILL.md, contains a duplicate or unsafe path, + /// exceeds the specification's per-skill limits, or has invalid UTF-8, missing frontmatter boundaries, + /// or a format error detected by the reader. + /// + /// + /// + /// Prefer , which reads the frontmatter from the + /// file. This overload still checks UTF-8, the frontmatter boundaries, and the entry's structure, computes the + /// manifest from the served bytes, and rejects malformed YAML the reader detects. When + /// reads the complete block, the supplied object must match it exactly. + /// + /// + /// When the reader stops at an unsupported construct, it does not examine the rest of the block, so acceptance + /// here proves neither that the YAML is valid nor that it matches . The caller + /// must have parsed the entire frontmatter with a full YAML parser and supplied the identical JSON. + /// + /// + public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter, files, nameof(files)); + } + + /// + /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md + /// and deriving the skill's URI from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// + /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the + /// manifest or the served content. + /// + /// + /// Dot-prefixed, hidden, and system files and directories are excluded before reading or descending into them. + /// Use to include them explicitly, or + /// with an explicit file selection. + /// + /// + /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file + /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a + /// regular file or directory, or build the skill with and + /// explicit files. + /// + /// + public static McpServerSkill CreateFromDirectory(string directoryPath) => + CreateFromDirectory(directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill from a directory with an explicit hidden/system-file policy. + /// + /// The skill's root directory, containing SKILL.md. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill with its manifest computed from the selected files. + /// Included symbolic links remain errors. See . + public static McpServerSkill CreateFromDirectory(string directoryPath, bool includeHiddenAndSystemFiles) + { +#if NET + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); + } + + /// + /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// See for how files and links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) => + CreateFromDirectory(uri, directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill with an explicit URI and hidden/system-file policy. + /// + /// The resource URI of the skill's SKILL.md. + /// The skill's root directory. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill. + /// Included symbolic links remain errors. See . + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath, bool includeHiddenAndSystemFiles) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); + } + + /// + /// Creates a skill from the visible, non-system files in a directory, recursively, with explicitly supplied frontmatter. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// Prefer . The supplied frontmatter is checked, and remains + /// the caller's responsibility, as described for . + /// See for how files and links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) => + CreateFromDirectory(uri, frontmatter, directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill with explicit frontmatter and a hidden/system-file policy. + /// + /// The resource URI of the skill's SKILL.md. + /// The fully validated JSON frontmatter, matching the file. + /// The skill's root directory. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill. + /// + /// See for caller responsibilities. + /// Included symbolic links remain errors. + /// + public static McpServerSkill CreateFromDirectory( + string uri, JsonObject frontmatter, string directoryPath, bool includeHiddenAndSystemFiles) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); + } + + private const string DefaultUriPrefix = "skill://"; + + /// + /// Creates a skill from a directory, deriving its URI as {uriPrefix}{name}/SKILL.md. Used by + /// WithSkillsFromDirectory. + /// + internal static McpServerSkill CreateFromDirectory( + string directoryPath, string uriPrefix, string paramName, bool includeHiddenAndSystemFiles = false) => + CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath, includeHiddenAndSystemFiles), paramName); + + private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName) + { + // A violation found in the entry is reported against the frontmatter argument when the caller supplied + // one, and against the files (or directory) it was read from otherwise. + string entryParamName = frontmatter is null ? filesParamName : nameof(frontmatter); + + // Normalize and order the files: SKILL.md first, then the rest by path, so the manifest is deterministic. + var normalized = new List<(string Path, McpServerSkillFile File)>(); + var seenPaths = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + if (file is null) + { + throw new ArgumentException("The skill's files must not contain null entries.", filesParamName); + } + + string path = NormalizePath(file.Path); + if (!seenPaths.Add(path)) + { + throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", filesParamName); + } + + normalized.Add((path, file)); + } + + if (!seenPaths.Contains(SkillsProtocol.SkillFileName)) + { + throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", filesParamName); + } + + normalized.Sort(static (left, right) => + { + bool leftIsSkillFile = left.Path == SkillsProtocol.SkillFileName; + bool rightIsSkillFile = right.Path == SkillsProtocol.SkillFileName; + if (leftIsSkillFile != rightIsSkillFile) + { + return leftIsSkillFile ? -1 : 1; + } + + return string.CompareOrdinal(left.Path, right.Path); + }); + + // Snapshot every file's bytes once. The caller's ReadOnlyMemory may alias an array the caller goes + // on to mutate, and the digest published in the manifest must describe exactly the bytes served. + var contents = new byte[normalized.Count][]; + for (int i = 0; i < normalized.Count; i++) + { + contents[i] = normalized[i].File.Content.ToArray(); + } + + // Read frontmatter from SKILL.md (always first after sorting). UTF-8, boundaries, detected format errors, + // and equality for completely parsed blocks are checked even when metadata was supplied. An unsupported + // construct can stop the reader before later content is examined. In that case the caller owns parsing + // and validating the entire frontmatter and supplying matching JSON; this is not full YAML validation. + if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) + { + throw new ArgumentException($"{SkillsProtocol.SkillFileName} is not valid UTF-8.", filesParamName); + } + + JsonObject? fileFrontmatter = null; + try + { + fileFrontmatter = SkillFrontmatter.Parse(skillMarkdown!); + } + catch (SkillFrontmatter.UnsupportedYamlException e) when (frontmatter is null) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} uses YAML that {nameof(SkillFrontmatter)} does not support: {e.Message} " + + "Supply the frontmatter explicitly with the overload that takes a JsonObject.", + filesParamName); + } + catch (SkillFrontmatter.UnsupportedYamlException) + { + // The application must already have validated the entire frontmatter and supplied matching JSON. + // Reaching an unsupported construct establishes neither whole-document validity nor equality. + } + catch (FormatException e) + { + throw new ArgumentException($"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {e.Message}", filesParamName); + } + + if (frontmatter is null) + { + frontmatter = fileFrontmatter!; + } + else if (fileFrontmatter is not null && !JsonNode.DeepEquals(fileFrontmatter, frontmatter)) + { + throw new ArgumentException( + $"The supplied frontmatter does not match the frontmatter in {SkillsProtocol.SkillFileName}. Hosts compare the two field by field " + + "and refuse the skill on any difference. Fix the mismatch, or omit the frontmatter argument to have it read from the file. " + + $"From the file: {fileFrontmatter.ToJsonString()} Supplied: {frontmatter.ToJsonString()}", + nameof(frontmatter)); + } + + if (uri is null) + { + string? name = new Skill { Uri = string.Empty, Frontmatter = frontmatter, Resources = SkillResources.Dynamic }.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} must declare a string 'name' for the skill's URI to be derived from it.", + filesParamName); + } + + uri = uriPrefix + name + "/" + SkillsProtocol.SkillFileName; + } + + string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); + + // Build and validate the entry before creating any resources, so an invalid skill fails fast with a + // message about the entry rather than about a resource. + var manifest = new List(normalized.Count); + for (int i = 0; i < normalized.Count; i++) + { + manifest.Add(new SkillResource + { + Uri = root + "/" + EscapePath(normalized[i].Path), + Digest = SkillVerifier.ComputeDigest(contents[i]), + Size = contents[i].Length, + }); + } + + var skill = new Skill + { + Uri = uri, + Frontmatter = frontmatter, + Resources = SkillResources.FromResources(manifest), + }; + + SkillValidation.Validate(skill, entryParamName); + + var resources = new McpServerResource[normalized.Count]; + for (int i = 0; i < normalized.Count; i++) + { + var (path, file) = normalized[i]; + bool isSkillFile = path == SkillsProtocol.SkillFileName; + resources[i] = CreateResource( + manifest[i].Uri, + name: isSkillFile ? skill.Name! : path, + description: isSkillFile ? skill.Description : null, + mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, contents[i])), + contents[i]); + } + + return new McpServerSkill(skill, resources); + } + + private static List ReadDirectory(string directoryPath, bool includeHiddenAndSystemFiles) + { + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skill directory '{fullDirectory}' does not exist."); + } + + if ((File.GetAttributes(fullDirectory) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{fullDirectory}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + "because a link can point outside the intended location. Pass the target directory instead.", + nameof(directoryPath)); + } + + if (!fullDirectory.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + fullDirectory += Path.DirectorySeparatorChar; + } + + var files = new List(); + long totalSize = 0; + CollectFiles(fullDirectory, fullDirectory, files, ref totalSize, includeHiddenAndSystemFiles); + return files; + } + + /// + /// Walks a skill directory without following links. A symbolic link (or any other reparse point) can point + /// outside the skill directory, and a file reached through one would be published under a URI that looks like + /// it lives inside the skill. Rather than try to decide which link targets are acceptable, links are rejected. + /// + private static void CollectFiles( + string root, string directory, List files, ref long totalSize, bool includeHiddenAndSystemFiles) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + if (!includeHiddenAndSystemFiles && + Path.GetFileName(entry).StartsWith(".", StringComparison.Ordinal)) + { + continue; + } + FileAttributes attributes = File.GetAttributes(entry); + if (!includeHiddenAndSystemFiles && + (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) + { + continue; + } + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{entry}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + $"because a link can point outside the skill. Replace it with a regular file or directory, or build the skill " + + $"with {nameof(McpServerSkill)}.{nameof(Create)} and explicit files.", + "directoryPath"); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + CollectFiles(root, entry, files, ref totalSize, includeHiddenAndSystemFiles); + continue; + } + + // Apply the specification's per-skill limits before reading, so an oversized or overly broad directory + // fails with the limit named rather than after allocating everything under it. The manifest built from + // the bytes actually read is validated against the same limits afterwards. + if (files.Count >= SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"The skill directory '{root}' contains more than {SkillsProtocol.MaxResourcesPerSkill} files, the limit per skill.", + "directoryPath"); + } + + long length = new FileInfo(entry).Length; + if (length > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"The files under '{root}' total more than {SkillsProtocol.MaxTotalSizeBytes} bytes, the limit per skill.", + "directoryPath"); + } + + totalSize += length; + + string relativePath = entry.Substring(root.Length).Replace(Path.DirectorySeparatorChar, '/'); + if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) + { + relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, '/'); + } + + files.Add(new McpServerSkillFile + { + Path = relativePath, + Content = ReadExactly(entry, length), + }); + } + } + + /// + /// Reads a file whose length was checked against the per-skill limits a moment ago, allocating only that + /// length, and fails if the file turns out to be a different size, so a file that grows or shrinks between the + /// check and the read can neither exceed the limit nor be served with a manifest that does not describe it. + /// + private static byte[] ReadExactly(string path, long expectedLength) + { + var buffer = new byte[expectedLength]; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + int total = 0; + while (total < buffer.Length) + { + int read = stream.Read(buffer, total, buffer.Length - total); + if (read == 0) + { + break; + } + + total += read; + } + + if (total != buffer.Length || stream.ReadByte() != -1) + { + throw new ArgumentException($"'{path}' changed size while it was being read.", "directoryPath"); + } + + return buffer; + } + + /// + /// Percent-encodes each segment of a normalized relative path so that characters with URI syntax (such as + /// {, ?, #, or a space) in a file name stay literal. Without this, a file named + /// {name}.md would register as a resource template rather than a concrete resource. + /// + private static string EscapePath(string normalizedPath) + { + string[] segments = normalizedPath.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + segments[i] = Uri.EscapeDataString(segments[i]); + } + + return string.Join("/", segments); + } + + private static string NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("A skill file must have a non-empty path.", nameof(McpServerSkillFile.Path)); + } + + string normalized = path!.Replace('\\', '/'); + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized.Substring(2); + } + + if (normalized.Length == 0 || normalized[0] == '/' || normalized[normalized.Length - 1] == '/') + { + throw new ArgumentException($"The skill file path '{path}' must be relative to the skill's root and must not end in a separator.", nameof(McpServerSkillFile.Path)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == "..") + { + throw new ArgumentException($"The skill file path '{path}' must not contain empty, '.', or '..' segments.", nameof(McpServerSkillFile.Path)); + } + } + + return normalized; + } + + private static McpServerResource CreateResource(string uri, string name, string? description, string mimeType, ReadOnlyMemory content) + { + // Text is served as TextResourceContents only when the bytes round-trip through UTF-8 exactly, because the + // host hashes the UTF-8 encoding of the text it receives and compares it against the manifest digest, which + // was computed over the raw bytes. Anything else is served as a blob, which round-trips by construction. + ResourceContents Read() => + IsTextualMimeType(mimeType) && TryDecodeUtf8(content.Span, out string? text) + ? new TextResourceContents { Uri = uri, MimeType = mimeType, Text = text! } + : BlobResourceContents.FromBytes(content, uri, mimeType); + + return McpServerResource.Create(Read, new McpServerResourceCreateOptions + { + UriTemplate = uri, + Name = name, + Description = description, + MimeType = mimeType, + }); + } + + private static bool TryDecodeUtf8(ReadOnlySpan bytes, out string? text) + { + try + { +#if NET + text = s_strictUtf8.GetString(bytes); +#else + text = s_strictUtf8.GetString(bytes.ToArray()); +#endif + return true; + } + catch (DecoderFallbackException) + { + text = null; + return false; + } + } + + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private static bool IsTextualMimeType(string mimeType) => + mimeType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/yaml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/toml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/javascript", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/x-sh", StringComparison.OrdinalIgnoreCase); + + private static string GuessMimeType(string path, ReadOnlySpan content) + { + int dot = path.LastIndexOf('.'); + string extension = dot < 0 || dot < path.LastIndexOf('/') ? string.Empty : path.Substring(dot + 1).ToLowerInvariant(); + + return extension switch + { + "md" or "markdown" => "text/markdown", + "txt" => "text/plain", + "csv" => "text/csv", + "html" or "htm" => "text/html", + "css" => "text/css", + "js" or "mjs" => "text/javascript", + "ts" => "text/typescript", + "py" => "text/x-python", + "cs" => "text/x-csharp", + "sh" or "bash" => "application/x-sh", + "json" => "application/json", + "yaml" or "yml" => "application/yaml", + "toml" => "application/toml", + "xml" => "application/xml", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" or "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + _ => TryDecodeUtf8(content, out _) ? "text/plain" : "application/octet-stream", + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs new file mode 100644 index 000000000..a4f9f0040 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs @@ -0,0 +1,52 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one file of a skill authored through . +/// +public sealed class McpServerSkillFile +{ + /// + /// Gets the file's path relative to the skill's root directory, using / as the separator + /// (for example, SKILL.md or references/GUIDE.md). + /// + public required string Path { get; init; } + + /// + /// Gets the file's raw content. Its digest and size in the skill's manifest are computed from exactly these bytes. + /// + public required ReadOnlyMemory Content { get; init; } + + /// + /// Gets the MIME type to advertise for the file's resource, or to infer one from the + /// file's extension. + /// + public string? MimeType { get; init; } + + /// + /// Creates a file from UTF-8 encoded text. + /// + /// The file's path relative to the skill's root directory. + /// The file's content. + /// The MIME type to advertise, or to infer one from . + /// The file. + /// or is . + public static McpServerSkillFile FromText(string path, string text, string? mimeType = null) + { +#if NET + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(text); +#else + if (path is null) throw new ArgumentNullException(nameof(path)); + if (text is null) throw new ArgumentNullException(nameof(text)); +#endif + + return new McpServerSkillFile + { + Path = path, + Content = Encoding.UTF8.GetBytes(text), + MimeType = mimeType, + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs new file mode 100644 index 000000000..ed1f9a55a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs @@ -0,0 +1,22 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one page of skill entries returned by . +/// +public sealed class McpSkillPage +{ + /// + /// Gets an empty page with no following page. + /// + public static McpSkillPage Empty { get; } = new() { Skills = [] }; + + /// + /// Gets the entries in this page. + /// + public required IReadOnlyList Skills { get; init; } + + /// + /// Gets the cursor to pass back for the following page, or when no entries remain. + /// + public string? NextCursor { get; init; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs new file mode 100644 index 000000000..8233642d8 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs @@ -0,0 +1,58 @@ +using ModelContextProtocol.Protocol; +using System.Security.Claims; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Describes the request an is answering. +/// +/// +/// +/// The skills methods are registered as raw request handlers, so they do not pass through the typed request +/// filter pipeline that guards the built-in resource methods (for example, the ASP.NET Core authorization +/// filters). A catalog that must not disclose every skill to every caller makes that decision itself, from the +/// and any that incoming-message filters attached to the request. +/// +/// +/// is populated by the ASP.NET Core transport from the HTTP request's principal. For other +/// transports, or when no authentication is configured, it is . +/// +/// +public sealed class McpSkillRequestContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The JSON-RPC request being answered. + /// is . + public McpSkillRequestContext(JsonRpcRequest jsonRpcRequest) + { +#if NET + ArgumentNullException.ThrowIfNull(jsonRpcRequest); +#else + if (jsonRpcRequest is null) throw new ArgumentNullException(nameof(jsonRpcRequest)); +#endif + + JsonRpcRequest = jsonRpcRequest; + } + + /// + /// Gets the JSON-RPC request being answered. + /// + public JsonRpcRequest JsonRpcRequest { get; } + + /// + /// Gets the authenticated user making the request, or if none is associated with it. + /// + public ClaimsPrincipal? User => JsonRpcRequest.Context?.User; + + /// + /// Gets the per-request items that incoming-message filters attached to the request, if any. + /// + public IDictionary? Items => JsonRpcRequest.Context?.Items; + + /// + /// Gets the protocol version the request was made under, when the transport or the request carried one. + /// + public string? ProtocolVersion => JsonRpcRequest.Context?.ProtocolVersion; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs new file mode 100644 index 000000000..b691d5fe3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -0,0 +1,443 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to enable MCP Skills (SEP-2640) support. +/// +public static class McpSkillsBuilderExtensions +{ + /// + /// Enables MCP Skills support for a fixed set of skills, serving both their entries and their files. + /// + /// The server builder. + /// The skills to serve. + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// or is . + /// + /// Two skills share a URI, or two skills list the same file URI with different content. + /// + /// + /// + /// This registers skills/list and skills/get backed by an + /// over the skills' entries, and registers each skill's so the files + /// are served through resources/list and resources/read. + /// + /// + /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, + /// provided every skill lists it with the same digest and the identical URI text. URIs that differ only in the + /// case of their scheme or authority are rejected, because the server's resource collection treats them as one. + /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IEnumerable skills, + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(skills); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (skills is null) throw new ArgumentNullException(nameof(skills)); +#endif + + var entries = new List(); + + // The server's resource collection keys concrete resources by System.Uri equality, under which scheme and + // authority are case-insensitive, and it registers silently. Detect shared files under those same + // semantics, so two URIs the collection would merge are caught here rather than served as one another. + var registeredFiles = new Dictionary(); + foreach (var skill in skills) + { + if (skill is null) + { + throw new ArgumentException("The skills must not contain null entries.", nameof(skills)); + } + + var protocolSkill = skill.ProtocolSkill; + entries.Add(protocolSkill); + + var manifest = protocolSkill.Resources.Resources!; + for (int i = 0; i < manifest.Count; i++) + { + var entry = manifest[i]; + var key = new Uri(entry.Uri, UriKind.Absolute); + if (registeredFiles.TryGetValue(key, out var existing)) + { + if (!string.Equals(existing.Uri, entry.Uri, StringComparison.Ordinal)) + { + // Even with identical content the alias cannot be served: the collection registers one + // resource, whose contents carry the first URI, so a verified read of the second fails. + throw new ArgumentException( + $"The file '{entry.Uri}' is equivalent to '{existing.Uri}', listed by another skill. Resource URIs are compared " + + "case-insensitively in their scheme and authority, so the two would be served as one. Use identical URIs for a shared file.", + nameof(skills)); + } + + if (!string.Equals(existing.Digest, entry.Digest, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The file '{entry.Uri}' is listed by more than one skill with different content.", + nameof(skills)); + } + + continue; + } + + registeredFiles.Add(key, (entry.Uri, entry.Digest)); + builder.Services.AddSingleton(skill.Resources[i]); + } + } + + return WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); + } + + /// + /// Enables MCP Skills support for every skill directory found directly under , + /// serving both their entries and their files, excluding dot-prefixed, hidden, and system entries. + /// + /// The server builder. + /// + /// A directory whose immediate subdirectories are skills. Each included subdirectory containing a SKILL.md becomes + /// one skill; subdirectories without one are ignored. + /// + /// + /// The prefix of each skill's URI. A skill's SKILL.md is published at {uriPrefix}{name}/SKILL.md, + /// where name is the skill's frontmatter name. Defaults to skill://; use a longer prefix such as + /// skill://acme/billing/ to place the skills under an organizational path. + /// + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// An argument is . + /// does not exist. + /// + /// No subdirectory contains a SKILL.md, a skill directory is invalid (see + /// ), or two skills declare the same name. + /// + /// + /// + /// Each skill is built with : files are read once, + /// digests are computed from the bytes served, symbolic links are rejected, and frontmatter + /// cannot read is an error. Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one + /// of that skill's files. + /// + /// + /// A subdirectory without a SKILL.md is not a skill and is ignored, so shared assets or documentation can + /// live alongside skills. A subdirectory with a SKILL.md is a skill, and a skill that fails validation is + /// an error rather than a skipped entry, so that a broken skill is noticed at startup instead of being silently + /// absent from the catalog. + /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// + /// + public static IMcpServerBuilder WithSkillsFromDirectory( + this IMcpServerBuilder builder, + string directoryPath, + string uriPrefix = "skill://", + Action? configure = null) => + WithSkillsFromDirectory(builder, directoryPath, includeHiddenAndSystemFiles: false, uriPrefix, configure); + + /// + /// Enables skills from immediate subdirectories with an explicit hidden/system-file policy. + /// + /// The server builder. + /// The directory containing skill directories. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The prefix used to derive skill URIs. + /// An optional callback configuring the extension. + /// The supplied builder. + /// + /// The same selection applies to skill directories and their contents. Included symbolic links remain errors. + /// See . + /// + public static IMcpServerBuilder WithSkillsFromDirectory( + this IMcpServerBuilder builder, + string directoryPath, + bool includeHiddenAndSystemFiles, + string uriPrefix = "skill://", + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(directoryPath); + ArgumentNullException.ThrowIfNull(uriPrefix); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); + if (uriPrefix is null) throw new ArgumentNullException(nameof(uriPrefix)); +#endif + + if (uriPrefix.Length == 0) + { + throw new ArgumentException("The URI prefix must not be empty.", nameof(uriPrefix)); + } + + if (!uriPrefix.EndsWith("/", StringComparison.Ordinal)) + { + uriPrefix += "/"; + } + + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skills directory '{fullDirectory}' does not exist."); + } + + var skillDirectories = new List(Directory.EnumerateDirectories(fullDirectory)); + skillDirectories.Sort(StringComparer.Ordinal); + + var skills = new List(); + foreach (string skillDirectory in skillDirectories) + { + if (!includeHiddenAndSystemFiles && + Path.GetFileName(skillDirectory).StartsWith(".", StringComparison.Ordinal)) + { + continue; + } + var attributes = File.GetAttributes(skillDirectory); + if (!includeHiddenAndSystemFiles && + (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) + { + continue; + } + // The per-skill loader rejects links inside a skill; the same rule applies to the skill directory + // itself, which could otherwise be a link to a directory outside the skills root. + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{skillDirectory}' is a symbolic link or other reparse point. Links are not followed when loading skills, " + + "because a link can point outside the skills directory. Replace it with a regular directory.", + nameof(directoryPath)); + } + + if (!File.Exists(Path.Combine(skillDirectory, SkillsProtocol.SkillFileName))) + { + continue; + } + + skills.Add(McpServerSkill.CreateFromDirectory( + skillDirectory, uriPrefix, nameof(directoryPath), includeHiddenAndSystemFiles)); + } + + if (skills.Count == 0) + { + throw new ArgumentException( + $"No skill directories were found under '{fullDirectory}'. Each skill must be an immediate subdirectory containing a {SkillsProtocol.SkillFileName}.", + nameof(directoryPath)); + } + + return WithSkills(builder, skills, configure); + } + + /// + /// Enables MCP Skills support backed by the specified catalog. + /// + /// The server builder. + /// The catalog supplying the skills this server serves. + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// or is . + /// + /// + /// This registers skills/list and skills/get and declares the extension in the server's + /// capabilities. Declaring the extension commits the server to both methods. + /// + /// + /// A catalog supplies entries only. The skills' files must be served as ordinary resources through + /// resources/read, so register them as well (for example with WithResources). To have this done + /// automatically, build the skills with and use the + /// overload. + /// + /// + /// skills/list and skills/get are registered as raw request handlers and do not pass through the + /// request filters that guard the built-in resource methods, such as the ASP.NET Core authorization filters. + /// When some callers must not see some skills, the catalog decides from the + /// it receives, and the corresponding file resources must be guarded separately. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IMcpSkillCatalog catalog, + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(catalog); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (catalog is null) throw new ArgumentNullException(nameof(catalog)); +#endif + + var options = new McpSkillsOptions(); + configure?.Invoke(options); + + builder.Services.AddSingleton>( + _ => new McpSkillsConfigureOptions(catalog, options)); + + return builder; + } + + private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkillsOptions skillsOptions) + : IConfigureOptions + { + public void Configure(McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(SkillsProtocol.ExtensionId)) + { + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + } + + // A server declaring the skills extension must also declare the resources capability, since skill + // files are read through resources/read. + options.Capabilities.Resources ??= new ResourcesCapability(); + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = HandleListSkillsAsync, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + // RoutingNameParameter is deliberately left unset. The specification defines no Mcp-Name header + // mapping for skills/get, so requiring one on Streamable HTTP would reject conforming clients. + Method = SkillsProtocol.MethodSkillsGet, + Handler = HandleGetSkillAsync, + }); + } + + private async ValueTask HandleListSkillsAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams); + var page = await catalog.ListAsync(requestParams?.Cursor, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false); + // The sealed built-in catalog validates its owned snapshots at construction. + if (catalog is not InMemoryMcpSkillCatalog) + { + foreach (var entry in page.Skills) + { + ValidateCatalogEntry(entry); + } + } + + var result = new ListSkillsResult + { + Skills = [.. page.Skills], + NextCursor = page.NextCursor, + }; + + // resultType, ttlMs, and cacheScope are 2026-07-28 result fields. Earlier revisions reject them as + // unrecognized keys (issue #1721), so they are only emitted when the request was negotiated under + // 2026-07-28 or later, where ttlMs and cacheScope are required and default to the conservative + // "immediately stale, not shareable" values the SDK uses for the built-in list methods. + if (IsJuly2026OrLaterProtocolRequest(request)) + { + result.ResultType = "complete"; + result.TimeToLive = skillsOptions.TimeToLive ?? TimeSpan.Zero; + result.CacheScope = skillsOptions.CacheScope ?? CacheScope.Private; + } + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.ListSkillsResult); + } + + private async ValueTask HandleGetSkillAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.GetSkillRequestParams); + if (string.IsNullOrEmpty(requestParams?.Uri)) + { + throw new McpProtocolException("The 'uri' parameter is required.", McpErrorCode.InvalidParams); + } + + var skill = await catalog.GetAsync(requestParams!.Uri, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false) ?? + throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams); + if (catalog is not InMemoryMcpSkillCatalog) + { + ValidateCatalogEntry(skill); + } + if (!string.Equals(skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"The skill catalog answered a request for '{requestParams.Uri}' with the entry for '{skill.Uri}'.", + McpErrorCode.InternalError); + } + + var result = new GetSkillResult { Skill = skill }; + if (IsJuly2026OrLaterProtocolRequest(request)) + { + result.ResultType = "complete"; + result.TimeToLive = skillsOptions.TimeToLive.GetValueOrDefault(); + result.CacheScope = skillsOptions.CacheScope.GetValueOrDefault(CacheScope.Private); + } + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); + } + + /// + /// A custom catalog is trusted to answer, but not to be correct: an invalid entry is a server bug, reported + /// as an internal error rather than published to hosts that would have to reject it. + /// + private static void ValidateCatalogEntry(Skill? entry) + { + try + { + SkillValidation.Validate(entry!, "entry"); + } + catch (ArgumentException e) + { + throw new McpProtocolException($"The skill catalog returned an invalid entry: {e.Message}", McpErrorCode.InternalError); + } + } + + private static T? DeserializeParams(JsonRpcRequest request, JsonTypeInfo typeInfo) where T : class + { + if (request.Params is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(request.Params, typeInfo); + } + catch (JsonException e) + { + throw new McpProtocolException($"Invalid parameters for '{request.Method}': {e.Message}", McpErrorCode.InvalidParams); + } + } + + /// + /// Returns whether the request was negotiated under the 2026-07-28 protocol revision or later. Under that + /// revision every request carries its protocol version; requests from an initialize-handshake + /// session (2025-11-25 and earlier) carry none, so a missing version means an earlier revision. + /// + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest request) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request.Context?.ProtocolVersion); + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs new file mode 100644 index 000000000..c674e1a34 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs @@ -0,0 +1,42 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Configures the behavior of the MCP Skills extension on a server. +/// +public sealed class McpSkillsOptions +{ + /// + /// Gets or sets the freshness hint advertised on skills/list and skills/get results. + /// + /// + /// + /// This is the base protocol's ttlMs caching attribute, with the same semantics as on + /// tools/list. It is a freshness hint about the returned listing or entry, not an integrity property + /// of the skills' content. + /// + /// + /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that + /// revision or later, a value is sent as (immediately stale), + /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent. + /// + /// + public TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the cache scope advertised on skills/list and skills/get results. + /// + /// + /// + /// Set this to only when the catalog is identical for every caller. + /// A listing or entry that varies by principal must not be advertised as publicly cacheable. + /// + /// + /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that + /// revision or later, a value is sent as , + /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent. + /// + /// + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs new file mode 100644 index 000000000..c6fabd895 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -0,0 +1,1404 @@ +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Reads a supported subset of SKILL.md YAML frontmatter into a , without a YAML library. +/// This optional helper supports server authoring and host-side comparison for that subset. +/// +/// +/// +/// This reader supports block mappings, nested mappings and sequences, plain, quoted and block scalars, +/// and comments within its bounded YAML subset. Detected malformed input and unsupported constructs throw +/// ; unsupported forms include anchors, aliases, tags, complex keys and multi-line +/// flow collections. Parsing can stop at the first unsupported construct, so an exception is not proof that +/// the rest of the frontmatter is valid. This helper is not a general-purpose YAML validator. +/// +/// +/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, +/// otherwise strings). Other YAML parsers can differ in the forms and values they support, so values meant to +/// be strings, such as version numbers, are best quoted. +/// +/// +/// Hosts can use this reader for the frontmatter verification described on . A +/// from it is a failed verification unless the host parses the file another way. +/// +/// +public static class SkillFrontmatter +{ + private const string Delimiter = "---"; + + /// YAML's white space characters. Other Unicode spaces, such as U+00A0, are scalar content. + private static readonly char[] s_yamlWhitespace = [' ', '\t']; + + /// The deepest nesting of block collections the reader accepts. Frontmatter is shallow; this only exists to keep a hostile file from exhausting the stack. + private const int MaxNestingDepth = 32; + + /// + /// Thrown when the reader encounters an unsupported construct. Parsing may stop before the remaining + /// frontmatter is examined; this exception does not establish whole-document validity or metadata equality. + /// + internal sealed class UnsupportedYamlException(string message) : FormatException(message); + + /// Thrown when a quoted scalar has no closing quote on its line; a later delimiter may indicate an unsupported multi-line form. + private sealed class UnterminatedQuoteException(string message, char quote) : FormatException(message) + { + public char Quote { get; } = quote; + } + + /// Thrown when a flow collection has no closing bracket on its line; a later delimiter may indicate an unsupported multi-line form. + private sealed class UnterminatedFlowException(string message, char closer) : FormatException(message) + { + public char Closer { get; } = closer; + } + + /// + /// Parses the frontmatter at the start of . + /// + /// The full text of a SKILL.md. + /// The frontmatter as a JSON object. + /// + /// The text does not begin with a ----delimited frontmatter block, the block is not a mapping, or it + /// uses YAML this reader does not support. + /// + public static JsonObject Parse(string skillMarkdown) + { + var lines = SplitLines(skillMarkdown); + if (lines.Count == 0 || lines[0].TrimEnd(s_yamlWhitespace) != Delimiter) + { + throw new FormatException($"{SkillsProtocol.SkillFileName} must begin with a line containing only '{Delimiter}' that opens the YAML frontmatter."); + } + + int end = -1; + for (int i = 1; i < lines.Count; i++) + { + string trimmed = lines[i].TrimEnd(s_yamlWhitespace); + if (trimmed == Delimiter || trimmed == "...") + { + end = i; + break; + } + } + + if (end < 0) + { + throw new FormatException($"The YAML frontmatter of {SkillsProtocol.SkillFileName} is not closed by a line containing only '{Delimiter}'."); + } + + var body = new List(end - 1); + for (int i = 1; i < end; i++) + { + body.Add(new Line(lines[i], i + 1)); + } + + var parser = new Parser(body); + var root = parser.ParseDocument(); + return root as JsonObject ?? throw new FormatException("The frontmatter must be a YAML mapping of keys to values."); + } + + private static List SplitLines(string text) + { + if (text.Length > 0 && text[0] == '\uFEFF') + { + text = text.Substring(1); + } + + var lines = new List(); + int start = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + int lineEnd = i > start && text[i - 1] == '\r' ? i - 1 : i; + lines.Add(text.Substring(start, lineEnd - start)); + start = i + 1; + } + } + + if (start < text.Length) + { + lines.Add(text.Substring(start)); + } + + return lines; + } + + private readonly struct Line + { + public Line(string raw, int number) + { + Raw = raw; + Number = number; + + int indent = 0; + while (indent < raw.Length && raw[indent] == ' ') + { + indent++; + } + + // Tabs cannot indent block structure, but they may separate a value from its indicator and are content + // inside block scalars, so a leading tab only marks the line here; the parser rejects it where it matters. + int contentStart = indent; + while (contentStart < raw.Length && raw[contentStart] is ' ' or '\t') + { + contentStart++; + } + + Indent = indent; + HasTabIndentation = contentStart != indent; + Content = raw.Substring(contentStart); + IsBlank = Content.Length == 0 || Content[0] == '#'; + } + + public string Raw { get; } + public int Number { get; } + public int Indent { get; } + public string Content { get; } + + /// Whether a tab appears in the line's leading white space, which YAML does not allow for indentation. + public bool HasTabIndentation { get; } + + /// Whether the line is empty or a comment, and therefore structurally insignificant. + public bool IsBlank { get; } + } + + private sealed class Parser(List lines) + { + private readonly List _lines = lines; + private int _pos; + private int _depth; + + public JsonNode? ParseDocument() + { + SkipBlank(); + if (_pos >= _lines.Count) + { + return new JsonObject(); + } + + var first = _lines[_pos]; + if (first.Indent != 0) + { + throw new FormatException($"Line {first.Number}: the frontmatter must start at column 1."); + } + + var node = ParseBlock(0); + SkipBlank(); + if (_pos < _lines.Count) + { + throw new FormatException($"Line {_lines[_pos].Number}: unexpected content after the frontmatter mapping."); + } + + return node; + } + + private void SkipBlank() + { + while (_pos < _lines.Count && _lines[_pos].IsBlank) + { + _pos++; + } + } + + private Line? PeekSignificant() + { + for (int i = _pos; i < _lines.Count; i++) + { + if (!_lines[i].IsBlank) + { + return _lines[i]; + } + } + + return null; + } + + /// Parses the block node formed by the lines at . + private JsonNode? ParseBlock(int indent, int parentIndent = -1) + { + SkipBlank(); + var line = _lines[_pos]; + if (_depth >= MaxNestingDepth) + { + throw new FormatException($"Line {line.Number}: the frontmatter is nested more than {MaxNestingDepth} levels deep."); + } + + _depth++; + try + { + if (IsSequenceEntry(line.Content)) + { + return ParseSequence(indent); + } + + bool isExplicitMappingKey = line.Content[0] == '?' && + (line.Content.Length == 1 || line.Content[1] is ' ' or '\t'); + bool isScalarValue = parentIndent >= 0 && + line.Content[0] is not ('&' or '*' or '!') && + !isExplicitMappingKey && + !IsCompactMappingEntry(line.Content, line.Number) && + !IsFlowCollectionKey(line.Content, line.Number); + if (isScalarValue) + { + ThrowIfTabIndented(line); + var rest = PrepareValue(line.Content, out bool commentEndedValue); + _pos++; + return ParseValue(rest, commentEndedValue, parentIndent, line.Number, allowSameIndentSequence: false); + } + + return ParseMapping(indent); + } + finally + { + _depth--; + } + } + + private static bool IsSequenceEntry(string content) => + content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] is ' ' or '\t'); + + private bool IsFlowCollectionKey(string text, int lineNumber) + { + if (text[0] is not ('[' or '{')) + { + return false; + } + + // Find the flow node's boundary without resolving its contents as JSON values. + var closers = new Stack(); + bool inPlainScalar = false; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + switch (c) + { + case '[' or '{': + if (closers.Count >= MaxNestingDepth) + { + return false; + } + + closers.Push(c == '[' ? ']' : '}'); + inPlainScalar = false; + break; + + case ']' or '}': + if (closers.Count == 0 || + closers.Pop() != c) + { + return false; + } + + if (closers.Count == 0) + { + int colon = SkipSpaces(text, i + 1); + return colon < text.Length && + text[colon] == ':' && + (colon + 1 == text.Length || text[colon + 1] is ' ' or '\t'); + } + + inPlainScalar = false; + break; + + case '"' or '\'' when !inPlainScalar: + try + { + ParseQuotedScalar(text, i, lineNumber, out int end); + i = end - 1; + } + catch (FormatException) + { + return false; + } + + break; + + case ',': + case ':' when !inPlainScalar || + i + 1 == text.Length || + text[i + 1] is ' ' or '\t': + inPlainScalar = false; + break; + + case '#' when i == 0 || text[i - 1] is ' ' or '\t': + return false; + + case ' ' or '\t': + break; + + default: + inPlainScalar = true; + break; + } + } + + return false; + } + + /// + /// Whether a sequence item's text is a "key: value" line, with a plain or quoted key, that begins a nested + /// block mapping. + /// + private static bool IsCompactMappingEntry(string item, int lineNumber) + { + if (item[0] is '"' or '\'') + { + try + { + ParseQuotedScalar(item, 0, lineNumber, out int afterQuote); + int colon = SkipSpaces(item, afterQuote); + return colon < item.Length && item[colon] == ':' && (colon + 1 == item.Length || item[colon + 1] is ' ' or '\t'); + } + catch (FormatException) + { + // Not a well-formed quoted key; the item is parsed as a value and reported there. + return false; + } + } + + return item[0] is not ('[' or '{' or '|' or '>' or '&' or '*' or '!') && FindKeySeparator(item) >= 0; + } + + private static void ThrowIfTabIndented(Line line) + { + if (line.HasTabIndentation) + { + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + } + + /// + /// Rejects a plain scalar that starts with a character YAML gives another meaning: an anchor, alias, or tag + /// (constructs this reader does not support), a reserved character, a flow indicator, a block scalar header, + /// or a block indicator followed by white space. + /// + private static void ThrowIfReservedPlainStart(string text, int lineNumber) + { + if (text.Length == 0) + { + return; + } + + char c = text[0]; + switch (c) + { + case '&' or '*' or '!': + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + + case '@' or '`' or '%': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reserves. Quote the value."); + + case ']' or '}' or ',': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with the flow indicator '{c}'. Quote the value."); + + case '|' or '>': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reads as a block scalar header. Quote the value."); + + case '-' or '?' or ':' when text.Length == 1 || text[1] is ' ' or '\t': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}' followed by white space, which YAML reads as a block indicator. Quote the value."); + } + } + + private JsonObject ParseMapping(int indent) + { + var result = new JsonObject(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + ThrowIfTabIndented(line); + if (IsSequenceEntry(line.Content)) + { + throw new FormatException($"Line {line.Number}: a sequence entry cannot appear directly inside a mapping. Put it under a key."); + } + + string content = line.Content; + if (content[0] == '?' && (content.Length == 1 || content[1] is ' ' or '\t')) + { + throw new UnsupportedYamlException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter."); + } + + if (content[0] is '[' or '{') + { + throw new UnsupportedYamlException($"Line {line.Number}: flow collections are not supported as mapping keys in frontmatter."); + } + + int consumed; + string key; + if (content[0] is '"' or '\'') + { + key = ParseQuotedScalar(content, 0, line.Number, out consumed); + int colon = SkipSpaces(content, consumed); + if (colon >= content.Length || content[colon] != ':' || (colon + 1 < content.Length && content[colon + 1] is not (' ' or '\t'))) + { + throw new FormatException($"Line {line.Number}: expected ':' after the quoted key."); + } + + consumed = colon + 1; + } + else + { + int separator = FindKeySeparator(content); + if (separator < 0) + { + throw new FormatException($"Line {line.Number}: expected a 'key: value' pair."); + } + + string rawKey = content.Substring(0, separator).TrimEnd(s_yamlWhitespace); + if (rawKey.Length == 0) + { + throw new FormatException($"Line {line.Number}: empty mapping key."); + } + + ThrowIfReservedPlainStart(content, line.Number); + + // Keys resolve like values: "TRUE:" is the key "true" and "0x10:" is "16" to a YAML parser. + key = KeyToString(ResolvePlainScalar(rawKey, line.Number)); + consumed = separator + 1; + } + + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {line.Number}: duplicate key '{key}'."); + } + + string rest = PrepareValue(content.Substring(consumed), out bool commentEndedValue); + _pos++; + result[key] = ParseValue(rest, commentEndedValue, indent, line.Number, allowSameIndentSequence: true); + } + + return result; + } + + private JsonArray ParseSequence(int indent) + { + var result = new JsonArray(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent || (line.Indent == indent && !IsSequenceEntry(line.Content))) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + ThrowIfTabIndented(line); + // Everything after the '-', white space included, so that the item's column is known exactly. + string item = line.Content.Substring(1); + _pos++; + + string trimmedItem = item.TrimStart(s_yamlWhitespace); + if (trimmedItem.Length == 0 || trimmedItem[0] == '#') + { + // "- " followed by nothing: the value is the more-indented block that follows, or null. + var next = PeekSignificant(); + result.Add(next is { } n && n.Indent > indent ? ParseBlock(n.Indent, indent) : null); + continue; + } + + int itemIndent = indent + 1 + (item.Length - trimmedItem.Length); + if (IsSequenceEntry(trimmedItem) || IsCompactMappingEntry(trimmedItem, line.Number)) + { + if (item.IndexOf('\t', 0, item.Length - trimmedItem.Length) >= 0) + { + // The nested node's indentation would be set by a tab. + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + // A compact nested node ("- key: value" or "- - x"): re-read this line as if the item started on + // its own line at the item's column, then continue with the block at that indentation. + _pos--; + _lines[_pos] = new Line(new string(' ', itemIndent) + trimmedItem, line.Number); + result.Add(ParseBlock(itemIndent)); + continue; + } + + string itemValue = PrepareValue(trimmedItem, out bool itemCommentEndedValue); + result.Add(ParseValue(itemValue, itemCommentEndedValue, indent, line.Number, allowSameIndentSequence: false)); + } + + return result; + } + + /// + /// Parses the value that follows a key or sequence dash. is the remainder of the + /// line, with comments removed; the line itself has already been consumed. + /// + private JsonNode? ParseValue(string rest, bool commentEndedValue, int parentIndent, int lineNumber, bool allowSameIndentSequence) + { + if (rest.Length == 0) + { + var next = PeekSignificant(); + if (next is { } n) + { + if (n.Indent > parentIndent) + { + return ParseBlock(n.Indent, parentIndent); + } + + if (allowSameIndentSequence && n.Indent == parentIndent && IsSequenceEntry(n.Content)) + { + return ParseSequence(parentIndent); + } + } + + return null; + } + + switch (rest[0]) + { + case '|' or '>': + return ParseBlockScalar(rest, parentIndent, lineNumber); + + case '&' or '*' or '!': + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + + case '[' or '{': + try + { + return rest[0] == '[' ? ParseFlowSequence(rest, lineNumber) : ParseFlowMapping(rest, lineNumber); + } + catch (UnterminatedFlowException e) when (ClosesLater(e.Closer)) + { + // A later delimiter may indicate an unsupported multi-line flow collection. This + // classification does not validate its contents or the rest of the frontmatter. + throw new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter."); + } + + case '"' or '\'': + string quoted; + int consumed; + try + { + quoted = ParseQuotedScalar(rest, 0, lineNumber, out consumed); + } + catch (UnterminatedQuoteException e) when (ClosesLater(e.Quote)) + { + // A later delimiter may indicate an unsupported multi-line quoted scalar. This + // classification does not validate its contents or the rest of the frontmatter. + throw new UnsupportedYamlException($"Line {lineNumber}: multi-line quoted scalars are not supported in frontmatter."); + } + + if (StripComment(rest.Substring(consumed), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value."); + } + + return JsonValue.Create(quoted); + + default: + if (IsSequenceEntry(rest)) + { + throw new FormatException($"Line {lineNumber}: a sequence cannot start on the same line as its key. Put the '- ' entries on the following lines, or quote the value if '-' is meant literally."); + } + + ThrowIfReservedPlainStart(rest, lineNumber); + if (FindKeySeparator(rest) >= 0) + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally."); + } + + string plain = commentEndedValue ? rest : CollectPlainContinuation(rest, parentIndent, lineNumber, out commentEndedValue); + + // A comment ends a plain scalar, on whichever of its lines it appears. A more-indented line after + // it cannot continue the scalar and cannot start a nested block either, so it is an error, as it + // is to a YAML parser. + if (commentEndedValue && PeekSignificant() is { } following && following.Indent > parentIndent) + { + throw new FormatException( + $"Line {following.Number}: this line cannot continue the value that started on line {lineNumber}, because a comment ended that value."); + } + + return ResolvePlainScalar(plain, lineNumber); + } + } + + private bool ClosesLater(char quote) + { + for (int i = _pos; i < _lines.Count; i++) + { + if (_lines[i].Raw.IndexOf(quote) >= 0) + { + return true; + } + } + + return false; + } + + /// + /// Folds the more-indented continuation lines of a plain multi-line scalar into it. A trailing comment on a + /// continuation line ends the scalar, which reports. + /// + private string CollectPlainContinuation(string first, int parentIndent, int lineNumber, out bool commentEndedValue) + { + commentEndedValue = false; + var builder = new StringBuilder(first); + int pendingNewlines = 0; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + pendingNewlines++; + _pos++; + continue; + } + + if (line.Indent <= parentIndent || line.Content[0] == '#') + { + break; + } + + string stripped = StripComment(line.Content); + string text = stripped.Trim(s_yamlWhitespace); + if (text.Length == 0) + { + break; + } + + if (FindKeySeparator(text) >= 0) + { + throw new FormatException( + $"Line {line.Number}: a plain scalar cannot contain ': '. If this line is meant to be a key, check its indentation; " + + $"the value that started on line {lineNumber} continues onto any more-indented line."); + } + + builder.Append(pendingNewlines > 0 ? new string('\n', pendingNewlines) : " "); + pendingNewlines = 0; + builder.Append(text); + _pos++; + + if (stripped.Length != line.Content.Length) + { + commentEndedValue = true; + break; + } + } + + // Trailing blank lines belong to whatever follows, so give them back. + _pos -= pendingNewlines; + return builder.ToString(); + } + + private JsonNode? ParseBlockScalar(string header, int parentIndent, int lineNumber) + { + bool literal = header[0] == '|'; + char chomping = 'c'; + int explicitIndent = 0; + int headerPos = 1; + for (; headerPos < header.Length && header[headerPos] != ' '; headerPos++) + { + char c = header[headerPos]; + if (c is '-' or '+') + { + if (chomping != 'c') + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated chomping indicator."); + } + + chomping = c == '-' ? 's' : 'k'; + } + else if (c is >= '1' and <= '9') + { + if (explicitIndent != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated indentation indicator."); + } + + explicitIndent = c - '0'; + } + else + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}'."); + } + } + + if (StripComment(header.Substring(headerPos)).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': only a comment may follow the indicators."); + } + + // Gather the raw lines of the block: everything blank, plus everything indented more than the parent. + var raw = new List(); + int contentIndent = explicitIndent > 0 ? parentIndent + explicitIndent : -1; + int widestLeadingEmptyLine = 0; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + // A white-space-only line is empty unless it reaches past the content indentation, in which case + // the excess is content. Until the indentation is known, the widest such line is remembered: + // YAML rejects a leading empty line wider than the first content line. A tab short of the + // content indentation is neither indentation nor content. + if (line.HasTabIndentation && (contentIndent < 0 || line.Indent < contentIndent)) + { + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + if (contentIndent < 0) + { + widestLeadingEmptyLine = Math.Max(widestLeadingEmptyLine, line.Raw.Length); + raw.Add(string.Empty); + } + else + { + raw.Add(line.Raw.Length > contentIndent ? line.Raw.Substring(contentIndent) : string.Empty); + } + + _pos++; + continue; + } + + if (line.Indent <= parentIndent) + { + break; + } + + if (contentIndent < 0) + { + contentIndent = line.Indent; + if (widestLeadingEmptyLine > contentIndent) + { + throw new FormatException( + $"Line {line.Number}: a leading empty line of the block scalar is indented more than its first content line. Use an explicit indentation indicator, or remove the extra white space."); + } + } + else if (line.Indent < contentIndent) + { + break; + } + + raw.Add(line.Raw.Substring(contentIndent)); + _pos++; + } + + // Trailing blank lines are subject to chomping; count and remove them. + int trailing = 0; + while (raw.Count > 0 && raw[raw.Count - 1].Length == 0) + { + raw.RemoveAt(raw.Count - 1); + trailing++; + } + + var builder = new StringBuilder(); + if (literal) + { + for (int i = 0; i < raw.Count; i++) + { + if (i > 0) + { + builder.Append('\n'); + } + + builder.Append(raw[i]); + } + } + else + { + // Folded: adjacent non-empty, non-indented lines join with a space; empty lines become newlines; + // more-indented lines keep their line breaks. + bool previousMoreIndented = false; + int emptyRun = 0; + for (int i = 0; i < raw.Count; i++) + { + string text = raw[i]; + if (text.Length == 0) + { + emptyRun++; + continue; + } + + bool moreIndented = text[0] is ' ' or '\t'; + if (builder.Length == 0) + { + // Leading empty lines are content: each becomes a line break. + builder.Append('\n', emptyRun); + } + else if (emptyRun > 0) + { + builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0)); + } + else + { + builder.Append(moreIndented || previousMoreIndented ? '\n' : ' '); + } + + emptyRun = 0; + builder.Append(text); + previousMoreIndented = moreIndented; + } + } + + if (builder.Length > 0 || trailing > 0) + { + switch (chomping) + { + case 'c' when builder.Length > 0: + builder.Append('\n'); + break; + case 'k': + builder.Append('\n', builder.Length > 0 ? trailing + 1 : trailing); + break; + } + } + + return JsonValue.Create(builder.ToString()); + } + + private static JsonArray ParseFlowSequence(string text, int lineNumber) + { + var result = new JsonArray(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); + } + + if (text[pos] == ']') + { + pos++; + break; + } + + if (text[pos] == ',') + { + throw new FormatException($"Line {lineNumber}: unexpected ',' in flow sequence; every entry needs a value."); + } + + result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]")); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ':') + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); + } + + if (text[pos] == ',') + { + pos++; + } + else if (text[pos] != ']') + { + throw new FormatException($"Line {lineNumber}: expected ',' or ']' in flow sequence."); + } + } + + if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow sequence."); + } + + return result; + } + + private static JsonObject ParseFlowMapping(string text, int lineNumber) + { + var result = new JsonObject(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); + } + + if (text[pos] == '}') + { + pos++; + break; + } + + if (text[pos] == ',') + { + throw new FormatException($"Line {lineNumber}: unexpected ',' in flow mapping; every entry needs a key."); + } + + JsonNode? keyNode; + bool hasValue; + if (text[pos] is '"' or '\'') + { + keyNode = JsonValue.Create(ParseQuotedScalar(text, pos, lineNumber, out int afterQuote)); + pos = SkipSpaces(text, afterQuote); + hasValue = pos < text.Length && text[pos] == ':'; + if (hasValue) + { + pos++; + } + } + else + { + // A plain key ends at ',' or '}' (giving it a null value) or at a ':' that is followed by a + // space, a comma, a closing brace, or the end of the text. A ':' followed by anything else is + // part of the key, so "{version:1}" is the key "version:1" with a null value. + int start = pos; + int colonAt = -1; + while (pos < text.Length && text[pos] is not (',' or '}')) + { + if (text[pos] == ':' && (pos + 1 == text.Length || text[pos + 1] is ' ' or '\t' or ',' or '}')) + { + colonAt = pos; + break; + } + + if (text[pos] == '#' && (pos == start || text[pos - 1] is ' ' or '\t')) + { + throw new FormatException($"Line {lineNumber}: a comment inside a flow collection runs to the end of the line, leaving the collection unterminated. Move the comment after the closing brace."); + } + + pos++; + } + + string rawKey = text.Substring(start, pos - start).Trim(s_yamlWhitespace); + if (rawKey.Length > 0 && rawKey[0] is '[' or '{') + { + throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + } + + ThrowIfReservedPlainStart(rawKey, lineNumber); + keyNode = ResolvePlainScalar(rawKey, lineNumber); + hasValue = colonAt >= 0; + if (hasValue) + { + pos = colonAt + 1; + } + } + + string key = KeyToString(keyNode); + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {lineNumber}: duplicate key '{key}'."); + } + + result[key] = hasValue ? ParseFlowScalar(text, ref pos, lineNumber, ",}") : null; + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); + } + + if (text[pos] == ',') + { + pos++; + } + else if (text[pos] != '}') + { + throw new FormatException($"Line {lineNumber}: expected ',' or '}}' in flow mapping."); + } + } + + if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow mapping."); + } + + return result; + } + + private static JsonNode? ParseFlowScalar(string text, ref int pos, int lineNumber, string terminators) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new FormatException($"Line {lineNumber}: unexpected end of flow collection."); + } + + char c = text[pos]; + if (c is '[' or '{') + { + throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + } + + if (c is '"' or '\'') + { + string quoted = ParseQuotedScalar(text, pos, lineNumber, out int consumed); + pos = consumed; + return JsonValue.Create(quoted); + } + + int start = pos; + while (pos < text.Length && terminators.IndexOf(text[pos]) < 0) + { + if (text[pos] == '#' && (pos == start || text[pos - 1] is ' ' or '\t')) + { + throw new FormatException($"Line {lineNumber}: a comment inside a flow collection runs to the end of the line, leaving the collection unterminated. Move the comment after the closing bracket."); + } + + pos++; + } + + string plain = text.Substring(start, pos - start).Trim(s_yamlWhitespace); + if (FindKeySeparator(plain) >= 0) + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + + ThrowIfReservedPlainStart(plain, lineNumber); + return ResolvePlainScalar(plain, lineNumber); + } + + /// Parses a quoted scalar starting at ; returns the index after the closing quote. + private static string ParseQuotedScalar(string text, int start, int lineNumber, out int end) + { + char quote = text[start]; + var builder = new StringBuilder(); + int i = start + 1; + while (i < text.Length) + { + char c = text[i]; + if (c == quote) + { + if (quote == '\'' && i + 1 < text.Length && text[i + 1] == '\'') + { + builder.Append('\''); + i += 2; + continue; + } + + end = i + 1; + string result = builder.ToString(); + for (int k = 0; k < result.Length; k++) + { + if (char.IsHighSurrogate(result[k]) && k + 1 < result.Length && char.IsLowSurrogate(result[k + 1])) + { + k++; + } + else if (char.IsSurrogate(result[k])) + { + throw new FormatException($"Line {lineNumber}: the quoted scalar contains an unpaired surrogate escape, which is not a valid Unicode character."); + } + } + + return result; + } + + if (quote == '"' && c == '\\') + { + if (++i >= text.Length) + { + break; + } + + char e = text[i]; + switch (e) + { + case '0': builder.Append('\0'); break; + case 'a': builder.Append('\a'); break; + case 'b': builder.Append('\b'); break; + case 't' or '\t': builder.Append('\t'); break; + case 'n': builder.Append('\n'); break; + case 'v': builder.Append('\v'); break; + case 'f': builder.Append('\f'); break; + case 'r': builder.Append('\r'); break; + case 'e': builder.Append('\u001B'); break; + case ' ': builder.Append(' '); break; + case '"': builder.Append('"'); break; + case '/': builder.Append('/'); break; + case '\\': builder.Append('\\'); break; + case 'N': builder.Append('\u0085'); break; + case '_': builder.Append('\u00A0'); break; + case 'L': builder.Append('\u2028'); break; + case 'P': builder.Append('\u2029'); break; + case 'x': builder.Append((char)ParseHex(text, i + 1, 2, lineNumber)); i += 2; break; + case 'u': builder.Append((char)ParseHex(text, i + 1, 4, lineNumber)); i += 4; break; + case 'U': + int scalar = ParseHex(text, i + 1, 8, lineNumber); + if (scalar is < 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF)) + { + throw new FormatException($"Line {lineNumber}: '\\U{text.Substring(i + 1, 8)}' is not a valid Unicode scalar value."); + } + + builder.Append(char.ConvertFromUtf32(scalar)); + i += 8; + break; + default: + throw new FormatException($"Line {lineNumber}: unsupported escape sequence '\\{e}' in double-quoted scalar."); + } + + i++; + continue; + } + + builder.Append(c); + i++; + } + + throw new UnterminatedQuoteException($"Line {lineNumber}: unterminated quoted scalar.", quote); + } + + private static int ParseHex(string text, int start, int length, int lineNumber) + { + if (start + length > text.Length || + !int.TryParse(text.Substring(start, length), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int value)) + { + throw new FormatException($"Line {lineNumber}: invalid hexadecimal escape in double-quoted scalar."); + } + + return value; + } + + /// + /// Renders a resolved key as a JSON object key the way reference parsers do: strings as themselves, null as + /// the empty string, and booleans and numbers as their JSON text. + /// + private static string KeyToString(JsonNode? key) => + key is null ? string.Empty + : key is JsonValue value && value.TryGetValue(out string? text) ? text + : key.ToJsonString(); + + /// Resolves an unquoted scalar per the YAML 1.2 core schema. + private static JsonNode? ResolvePlainScalar(string text, int lineNumber) + { + switch (text) + { + case "" or "~" or "null" or "Null" or "NULL": + return null; + case "true" or "True" or "TRUE": + return JsonValue.Create(true); + case "false" or "False" or "FALSE": + return JsonValue.Create(false); + } + + if (TryResolveNumber(text, lineNumber) is { } number) + { + return number; + } + + return JsonValue.Create(text); + } + + private static JsonNode? TryResolveNumber(string text, int lineNumber) + { + // Hexadecimal and octal integers take no sign in the core schema, and may exceed 64 bits. + if (text.StartsWith("0x", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, IsHexDigit)) + { + var value = BigInteger.Parse("0" + text.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + + if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7')) + { + BigInteger value = BigInteger.Zero; + for (int j = 2; j < text.Length; j++) + { + value = (value * 8) + (text[j] - '0'); + } + + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + + int i = 0; + bool negative = false; + if (text[0] is '+' or '-') + { + negative = text[0] == '-'; + i = 1; + } + + if (i >= text.Length) + { + return null; + } + + string body = text.Substring(i); + + if (body is ".inf" or ".Inf" or ".INF" or ".nan" or ".NaN" or ".NAN") + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented in JSON."); + } + + // Integer: digits only. + if (IsAll(body, 0, IsDigit)) + { + string digits = body.TrimStart('0').PadLeft(1, '0'); + return JsonNode.Parse(negative && digits != "0" ? "-" + digits : digits); + } + + // Float: [digits][.digits][e[+-]digits], with at least one digit somewhere in the mantissa. + int mantissaEnd = 0; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + } + + int integerDigits = mantissaEnd; + int fractionDigits = 0; + if (mantissaEnd < body.Length && body[mantissaEnd] == '.') + { + mantissaEnd++; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + fractionDigits++; + } + } + + if (integerDigits + fractionDigits == 0) + { + return null; + } + + int exponentEnd = mantissaEnd; + if (exponentEnd < body.Length && body[exponentEnd] is 'e' or 'E') + { + exponentEnd++; + if (exponentEnd < body.Length && body[exponentEnd] is '+' or '-') + { + exponentEnd++; + } + + int exponentDigits = 0; + while (exponentEnd < body.Length && IsDigit(body[exponentEnd])) + { + exponentEnd++; + exponentDigits++; + } + + if (exponentDigits == 0) + { + return null; + } + } + + if (exponentEnd != body.Length) + { + return null; + } + + if (!double.TryParse(body, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) || double.IsInfinity(parsed)) + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented as a JSON number."); + } + + // JSON has no negative zero worth preserving: reference parsers render -0 and -0.0 as 0. + return JsonValue.Create(negative && parsed != 0 ? -parsed : parsed); + } + + private static bool IsDigit(char c) => c is >= '0' and <= '9'; + + private static bool IsHexDigit(char c) => c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + + private static bool IsAll(string text, int start, Func predicate) + { + if (start >= text.Length) + { + return false; + } + + for (int i = start; i < text.Length; i++) + { + if (!predicate(text[i])) + { + return false; + } + } + + return true; + } + + private static int SkipSpaces(string text, int pos) + { + while (pos < text.Length && text[pos] is ' ' or '\t') + { + pos++; + } + + return pos; + } + + /// Finds the ':' that separates a plain key from its value: the first ':' followed by white space or the end of the line. + private static int FindKeySeparator(string content) + { + for (int i = 0; i < content.Length; i++) + { + if (content[i] == ':' && (i + 1 == content.Length || content[i + 1] is ' ' or '\t')) + { + return i; + } + + if (content[i] is ' ' or '\t' && i + 1 < content.Length && content[i + 1] == '#') + { + // Anything after " #" is a comment; a key cannot be separated inside one. + return -1; + } + } + + return -1; + } + + /// + /// Prepares the remainder of a line for : quoted values and flow collections are + /// returned as written, since their own parsers find their end and check what follows; anything else is a + /// plain value, from which a trailing comment is removed. + /// + private static string PrepareValue(string rest, out bool commentEndedValue) + { + commentEndedValue = false; + rest = rest.TrimStart(s_yamlWhitespace); + if (rest.Length > 0 && rest[0] is '"' or '\'' or '[' or '{') + { + return rest.TrimEnd(s_yamlWhitespace); + } + + string stripped = StripComment(rest); + commentEndedValue = stripped.Length != rest.Length && stripped.Trim(s_yamlWhitespace).Length > 0; + return stripped.Trim(s_yamlWhitespace); + } + + /// + /// Removes a trailing comment from plain text. A comment must follow whitespace unless the text already + /// starts at a separation boundary; quotes inside plain text never suppress a comment. + /// + private static string StripComment(string text, bool allowLeadingComment = true) + { + for (int i = 0; i < text.Length; i++) + { + bool isSeparated = i == 0 ? allowLeadingComment : text[i - 1] is ' ' or '\t'; + if (text[i] == '#' && + isSeparated) + { + return text.Substring(0, i); + } + } + + return text; + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs new file mode 100644 index 000000000..1268cd78d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -0,0 +1,373 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Validates skill entries against the structural requirements of the Skills extension specification, so that +/// a server never publishes an entry a conforming host would refuse to load. +/// +internal static class SkillValidation +{ + private const string SkillFileSuffix = "/" + SkillsProtocol.SkillFileName; + + /// + /// Returns the skill root (the SKILL.md URI with its /SKILL.md suffix removed), or throws if + /// does not end in /SKILL.md. + /// + public static string GetSkillRoot(string skillUri, string paramName) + { + if (string.IsNullOrEmpty(skillUri)) + { + throw new ArgumentException("A skill URI must not be null or empty.", paramName); + } + + if (!skillUri.EndsWith(SkillFileSuffix, StringComparison.Ordinal) || skillUri.Length == SkillFileSuffix.Length) + { + throw new ArgumentException( + $"The skill URI '{skillUri}' must be the URI of the skill's {SkillsProtocol.SkillFileName}, ending in '{SkillFileSuffix}'.", + paramName); + } + + return skillUri.Substring(0, skillUri.Length - SkillFileSuffix.Length); + } + + /// + /// Returns the final path segment of a skill root, which the specification requires to equal the skill's name. + /// + public static string GetNameSegment(string skillRoot) + { + int slash = skillRoot.LastIndexOf('/'); + return slash < 0 ? skillRoot : skillRoot.Substring(slash + 1); + } + + /// + /// Returns whether satisfies the Agent Skills naming rules: 1 to 64 characters of + /// Unicode lowercase (or caseless) letters, digits, and hyphens, with no leading, trailing, or consecutive hyphens. + /// + public static bool IsValidSkillName(string name) + { + if (name.Length == 0) + { + return false; + } + + int scalarCount = 0; + bool previousIsHyphen = true; + for (int index = 0; index < name.Length; index++) + { + char c = name[index]; + bool isHyphen = c == '-'; + var category = CharUnicodeInfo.GetUnicodeCategory(name, index); + bool isAlphanumeric = category is UnicodeCategory.LowercaseLetter or + UnicodeCategory.ModifierLetter or UnicodeCategory.OtherLetter or UnicodeCategory.DecimalDigitNumber; + if (!isAlphanumeric && + !isHyphen) + { + return false; + } + + if (isHyphen && + previousIsHyphen) + { + return false; + } + + if (++scalarCount > 64) + { + return false; + } + + if (char.IsHighSurrogate(c)) + { + index++; + } + + previousIsHyphen = isHyphen; + } + + return !previousIsHyphen; + } + + /// + /// Returns whether has the form sha256:{hex} with 64 lowercase hexadecimal digits. + /// + public static bool IsValidDigest(string? digest) + { + const int HexLength = 64; + if (digest is null || + digest.Length != SkillsProtocol.DigestPrefix.Length + HexLength || + !digest.StartsWith(SkillsProtocol.DigestPrefix, StringComparison.Ordinal)) + { + return false; + } + + for (int i = SkillsProtocol.DigestPrefix.Length; i < digest.Length; i++) + { + if (digest[i] is not ((>= '0' and <= '9') or (>= 'a' and <= 'f'))) + { + return false; + } + } + + return true; + } + + /// + /// Checks that is an absolute URI whose path has no empty, ., or .. + /// segments and no query or fragment, so that a prefix comparison against a skill root is meaningful. + /// + public static void ValidateUriShape(string uri, string what, string paramName) + { + int schemeEnd = uri.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd <= 0 || !System.Uri.TryCreate(uri, UriKind.Absolute, out _)) + { + throw new ArgumentException($"{what} '{uri}' must be a syntactically valid absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); + } + + if (uri.IndexOf('?') >= 0 || uri.IndexOf('#') >= 0) + { + throw new ArgumentException($"{what} '{uri}' must not contain a query or fragment.", paramName); + } + + // Check the raw text rather than System.Uri's view of it, which decodes and compacts dot segments, so that + // "%2e%2e" is caught as well as "..". The authority (the first segment) may be empty, as in + // file:///name/SKILL.md; path segments may not. + string[] segments = uri.Substring(schemeEnd + 3).Split('/'); + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + if (segment.Length == 0 && i > 0) + { + throw new ArgumentException($"{what} '{uri}' must not contain empty path segments.", paramName); + } + + string decoded = System.Uri.UnescapeDataString(segment); + if (decoded == "." || decoded == ".." || decoded.IndexOf('/') >= 0 || decoded.IndexOf('\\') >= 0) + { + throw new ArgumentException( + $"{what} '{uri}' must not contain '.', '..', or encoded separator path segments, which would resolve outside the skill.", + paramName); + } + } + } + + /// + /// Returns a copy of that shares no mutable state with it. + /// + public static Skill Snapshot(Skill skill) => new() + { + Uri = skill.Uri, + Frontmatter = (JsonObject)skill.Frontmatter.DeepClone(), + Resources = skill.Resources.IsDynamic + ? SkillResources.Dynamic + : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })), + }; + + /// + /// Validates an entry received from a server, throwing describing the + /// first violation found. Hosts must not load invalid entries, so an invalid entry is a verification failure. + /// + public static void ValidateReceived(Skill skill, string method) + { + try + { + Validate(skill, "skill"); + } + catch (ArgumentException e) + { + throw new SkillVerificationException($"The server returned an invalid skill entry from '{method}': {e.Message}", e); + } + } + + /// + /// Validates a complete skill entry, throwing describing the first violation found. + /// + public static void Validate(Skill skill, string paramName) + { + if (skill is null) + { + throw new ArgumentNullException(paramName); + } + + string root = GetSkillRoot(skill.Uri, paramName); + ValidateUriShape(skill.Uri, "The skill URI", paramName); + string nameSegment = GetNameSegment(root); + + if (skill.Frontmatter is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no frontmatter.", paramName); + } + + string? name = skill.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'name' in its frontmatter.", paramName); + } + + if (!IsValidSkillName(name!)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', which does not satisfy the Agent Skills naming rules " + + "(1 to 64 lowercase or caseless letters, digits, and single hyphens, not starting or ending with a hyphen).", + paramName); + } + + if (!string.Equals(name, nameSegment, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', but the path segment preceding /{SkillsProtocol.SkillFileName} is '{nameSegment}'. " + + "The specification requires them to be equal.", + paramName); + } + + string? description = skill.Description; + if (string.IsNullOrEmpty(description)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName); + } + + if (description!.Length > MaxDescriptionLength) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a description of {description.Length} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.", + paramName); + } + + ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); + + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode)) + { + if (metadataNode is not JsonObject metadata) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it string key-value entries, or remove the key if it is not needed.", + paramName); + } + + foreach (var entry in metadata) + { + if (entry.Value is null || + entry.Value.GetValueKind() != JsonValueKind.String) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' value for '{entry.Key}' that is not a string.", + paramName); + } + } + } + + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest. Use SkillResources.Dynamic for generated content.", paramName); + } + + if (skill.Resources.IsDynamic) + { + return; + } + + var resources = skill.Resources.Resources!; + if (resources.Count == 0) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has an empty resources manifest. A manifest must list every file of the skill, {SkillsProtocol.SkillFileName} included.", + paramName); + } + + if (resources.Count > SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists {resources.Count} files, exceeding the limit of {SkillsProtocol.MaxResourcesPerSkill} per skill.", + paramName); + } + + string rootPrefix = root + "/"; + var seen = new HashSet(StringComparer.Ordinal); + bool hasSkillFile = false; + long totalSize = 0; + + foreach (var resource in resources) + { + if (resource is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a null entry in its resources manifest.", paramName); + } + + if (string.IsNullOrEmpty(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a resource with a null or empty URI.", paramName); + } + + ValidateUriShape(resource.Uri, $"Skill '{skill.Uri}' lists the resource", paramName); + if (!resource.Uri.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}', which is not within the skill's directory '{root}'.", + paramName); + } + + if (!seen.Add(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' more than once.", paramName); + } + + if (!IsValidDigest(resource.Digest)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with the digest '{resource.Digest}', " + + "which is not of the form 'sha256:' followed by 64 lowercase hexadecimal digits.", + paramName); + } + + if (resource.Size < 0) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with a negative size.", paramName); + } + + if (resource.Size > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' totals more than the limit of {SkillsProtocol.MaxTotalSizeBytes} bytes per skill.", + paramName); + } + + totalSize += resource.Size; + hasSkillFile |= string.Equals(resource.Uri, skill.Uri, StringComparison.Ordinal); + } + + if (!hasSkillFile) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' does not list its own {SkillsProtocol.SkillFileName} in its resources manifest.", + paramName); + } + + } + + private const int MaxDescriptionLength = 1024; + private const int MaxCompatibilityLength = 500; + + private static void ValidateOptionalString(Skill skill, string key, int? maxLength, string paramName) + { + if (!skill.Frontmatter.TryGetPropertyValue(key, out var node)) + { + return; + } + + if (node is not JsonValue value || !value.TryGetValue(out string? text)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string. Give it a value, or remove the key if it is not needed.", + paramName); + } + + if (text!.Length == 0 || (maxLength is { } max && text.Length > max)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {text.Length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.", + paramName); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs new file mode 100644 index 000000000..8940fffd2 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs @@ -0,0 +1,57 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Provides constants for the MCP Skills extension (SEP-2640). +/// +/// +/// See the Skills extension specification +/// for details. +/// +public static class SkillsProtocol +{ + /// + /// The extension identifier for the MCP Skills extension, as it appears in the extensions + /// field of a server's capabilities. + /// + public const string ExtensionId = "io.modelcontextprotocol/skills"; + + /// + /// The name of the request method sent from the client to enumerate the skills a server serves. + /// + public const string MethodSkillsList = "skills/list"; + + /// + /// The name of the request method sent from the client to retrieve a single skill's entry by URI. + /// + public const string MethodSkillsGet = "skills/get"; + + /// + /// The file name of the manifest every skill directory must contain at its root. + /// + public const string SkillFileName = "SKILL.md"; + + /// + /// The maximum number of files a single skill may declare in its manifest, included. + /// + /// + /// Hosts must support skills up to and including this limit. Servers should not serve a skill that exceeds it. + /// + public const int MaxResourcesPerSkill = 512; + + /// + /// The maximum total size in bytes of a single skill's files, summed over its manifest. + /// + /// + /// Hosts must support skills up to and including this limit. Servers should not serve a skill that exceeds it. + /// + public const long MaxTotalSizeBytes = 16 * 1024 * 1024; + + /// The value of a skill's resources field when its content is generated dynamically. + internal const string DynamicResourcesSentinel = "dynamic"; + + /// The prefix of a manifest digest. + internal const string DigestPrefix = "sha256:"; + + /// The MIME type recommended for a skill's SKILL.md resource. + internal const string SkillFileMimeType = "text/markdown"; +} diff --git a/src/PACKAGE.md b/src/PACKAGE.md index b3c1d9143..3926c3adb 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -18,6 +18,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Skills](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Skills.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills) - MCP Skills extension for serving and consuming Agent Skills with verifiable file manifests. + - **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 98c225dd9..6d803a14d 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -396,10 +396,8 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) return (core, prerelease); } - - /// whether it succeeded along with the captured stdout/stderr. Centralizes the process - /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) - /// shared by the server-side conformance tests. + /// + /// Runs the server-side conformance process and captures its output with a five-minute timeout. /// /// Arguments to pass to the conformance runner. /// Optional callback invoked for each captured stdout/stderr line. @@ -407,13 +405,15 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) /// Forwarded to . /// /// Token used to cancel the run. + /// An alternative process for isolated process-outcome tests. public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync( string arguments, Action? onLine = null, bool appendProtocolVersionFromEnv = true, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + ProcessStartInfo? processStartInfo = null) { - var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + var startInfo = processStartInfo ?? ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); @@ -484,34 +484,13 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) var stdoutText = outputBuilder.ToString(); var stderrText = errorBuilder.ToString(); - // The Node.js conformance runner can crash during cleanup on Windows with a libuv - // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit - // code even though every conformance check passed. When that happens, fall back to - // parsing the "Test Results:" summary in stdout to decide success. - bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); - - return (success, stdoutText, stderrText); - } - - /// - /// Parses the conformance runner output for a "Test Results:" line such as - /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed - /// and none failed. - /// - private static bool ConformanceOutputIndicatesSuccess(string output) - { - // Match lines like "Passed: 3/3, 0 failed, 0 warnings" - var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); - if (!match.Success) + bool success = process.ExitCode == 0; + if (!success) { - return false; + stderrText += $"{Environment.NewLine}Conformance runner exited with code {process.ExitCode}."; } - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); - - return passed == total && failed == 0 && total > 0; + return (success, stdoutText, stderrText); } /// diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66edf..6c1d79770 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -16,11 +16,13 @@ + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd9361..7b695d963 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; @@ -11,7 +12,13 @@ services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithMcpApps(); + .WithMcpApps() + .WithSkills( + [ + McpServerSkill.Create( + "skill://aot/SKILL.md", + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]), + ]); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -41,6 +48,34 @@ throw new Exception($"Unexpected result: {result}"); } +// List, get, and verify a skill. +var skills = await client.ListSkillsAsync(); +if (skills.Count != 1 || skills[0].Name != "aot") +{ + throw new Exception($"Unexpected skills listing: {skills.Count} entries."); +} + +var skillResult = await client.GetSkillAsync(new GetSkillRequestParams { Uri = "skill://aot/SKILL.md" }); +if (skillResult.ResultType != "complete" || + skillResult.TimeToLive != TimeSpan.Zero || + skillResult.CacheScope != CacheScope.Private) +{ + throw new Exception("Unexpected skill cache hints."); +} + +var skill = skillResult.Skill; +if (skill.Description != "An AOT-published skill." || + skill.Frontmatter["metadata"]?["note"]?.GetValue() != "First\n\tCode\nLast\n") +{ + throw new Exception("Unexpected skill frontmatter."); +} + +var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri); +if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot")) +{ + throw new Exception("Unexpected skill content."); +} + Console.WriteLine("Success!"); [McpServerToolType] diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs index 21fb98c2e..6bd8c749b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using Microsoft.AspNetCore.Builder; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -7,34 +7,42 @@ namespace ModelContextProtocol.ConformanceTests; /// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP /// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at /// , "/stateless") on one port. A single long-lived server avoids -/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and -/// centralizes the port-binding logic that the stateful and stateless conformance tests previously -/// duplicated. Shared by and +/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows. Kestrel +/// assigns a loopback port, and initialization awaits this host's startup before publishing its URL. +/// Shared by and /// via . /// public sealed class ConformanceServerFixture : IAsyncLifetime { - // Use different ports for each target framework to allow parallel execution across the - // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003. - private static int GetPortForTargetFramework() + private readonly Func _createApplication; + private readonly TimeProvider _timeProvider; + private WebApplication? _app; + private string? _serverUrl; + private CancellationTokenSource? _startupTimeoutCts; + private CancellationTokenSource? _startupCts; + private Task? _startupTask; + private Task? _stopTask; + private Task? _disposeTask; + private bool _startupOutcomeObserved; + + public ConformanceServerFixture() + : this(() => new ConformanceServer.Program().CreateApplication(["--urls", "http://127.0.0.1:0"])) { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + } - return targetFramework switch - { - "net10.0" => 3001, - "net9.0" => 3002, - "net8.0" => 3003, - _ => 3001 // Default fallback - }; + internal ConformanceServerFixture(Func createApplication) + : this(createApplication, TimeProvider.System) + { } - private Task? _serverTask; - private CancellationTokenSource? _serverCts; + internal ConformanceServerFixture(Func createApplication, TimeProvider timeProvider) + { + _createApplication = createApplication; + _timeProvider = timeProvider; + } /// Base URL of the stateful MCP endpoint (mapped at "/"). - public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; + public string ServerUrl => _serverUrl ?? throw new InvalidOperationException("The conformance server has not started."); /// /// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28 @@ -42,57 +50,115 @@ private static int GetPortForTargetFramework() /// public string StatelessServerUrl => $"{ServerUrl}/stateless"; - public async ValueTask InitializeAsync() - { - _serverCts = new CancellationTokenSource(); - _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + public ValueTask InitializeAsync() => InitializeAsync(TestContext.Current.CancellationToken); - // Wait for server to be ready (retry for up to 30 seconds) - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + internal async ValueTask InitializeAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _startupTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30), _timeProvider); + _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _startupTimeoutCts.Token); + _app = _createApplication(); + _startupTask = _app.StartAsync(_startupCts.Token); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = _startupCts.Token.Register(() => cancelled.TrySetResult(true)); - while (stopwatch.Elapsed < timeout) + try { - try - { - await httpClient.GetAsync($"{ServerUrl}/health"); - return; - } - catch (HttpRequestException) + await Task.WhenAny(_startupTask, cancelled.Task); + if (_startupTask.IsCompleted) { - // Connection refused means server not ready yet - } - catch (TaskCanceledException) - { - // Timeout means server might be processing, give it more time + _startupOutcomeObserved = true; + await _startupTask; } + _startupCts.Token.ThrowIfCancellationRequested(); + _startupTimeoutCts.CancelAfter(Timeout.InfiniteTimeSpan); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _startupCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer failed to start within 30 seconds.", error); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed.", error); + } - await Task.Delay(500); + if (!_app.Lifetime.ApplicationStarted.IsCancellationRequested || + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed."); } - throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + _serverUrl = _app.Urls.Single(); } public async ValueTask DisposeAsync() { - if (_serverCts != null) + if (_app is null) + { + DisposeStartupCancellation(); + return; + } + + using var shutdownCts = new CancellationTokenSource(TestConstants.DefaultTimeout, _timeProvider); + try { - _serverCts.Cancel(); - if (_serverTask != null) + if (_startupCts is not null) { - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch + await _startupCts.CancelAsync().WaitAsync(shutdownCts.Token); + } + + if (_startupTask is not null) + { + // Wait for completion without throwing its already-reported startup error. + // A timed-out, still-running startup retains ownership of the app and tokens. + await WaitForCompletionAsync(_startupTask, shutdownCts.Token); + } + + shutdownCts.Token.ThrowIfCancellationRequested(); + _stopTask ??= _app.StopAsync(shutdownCts.Token); + await WaitForCompletionAsync(_stopTask, shutdownCts.Token); + shutdownCts.Token.ThrowIfCancellationRequested(); + _disposeTask ??= _app.DisposeAsync().AsTask(); + await _disposeTask.WaitAsync(shutdownCts.Token); + + var startupOutcome = Task.CompletedTask; + if (!_startupOutcomeObserved && + _startupTask is not null) + { + _startupOutcomeObserved = true; + bool expectedCancellation = _startupTask.IsCanceled && + (_startupCts?.IsCancellationRequested).GetValueOrDefault(); + if (!expectedCancellation) { - // Ignore exceptions during shutdown + startupOutcome = _startupTask; } } - _serverCts.Dispose(); + + _app = null; + _serverUrl = null; + DisposeStartupCancellation(); + + // Observe failures only after safely releasing the host; neither a late startup + // fault nor a StopAsync failure is converted into successful cleanup. + await Task.WhenAll(_stopTask, startupOutcome); } + catch (OperationCanceledException error) when (shutdownCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer cleanup did not complete within 60 seconds.", error); + } + } + + private async Task WaitForCompletionAsync(Task task, CancellationToken cancellationToken) + { + await Task.WhenAny(task).WaitAsync(cancellationToken); + } + + private void DisposeStartupCancellation() + { + _startupCts?.Dispose(); + _startupTimeoutCts?.Dispose(); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs new file mode 100644 index 000000000..214269477 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs @@ -0,0 +1,351 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.ConformanceTests; +using ModelContextProtocol.Tests.Utils; +using System.Net; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class ConformanceServerFixtureTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Fact] + public async Task Initialize_ActualConformanceHosts_OwnDistinctReadyEndpoints() + { + await using var first = new ConformanceServerFixture(); + await using var second = new ConformanceServerFixture(); + await first.InitializeAsync(TestContext.Current.CancellationToken); + await second.InitializeAsync(TestContext.Current.CancellationToken); + Assert.NotEqual(first.ServerUrl, second.ServerUrl); + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + foreach (var fixture in new[] { first, second }) + { + var address = new Uri(fixture.ServerUrl); + Assert.True(address.IsLoopback); + Assert.NotEqual(0, address.Port); + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + await using var transport = new HttpClientTransport(new() { Endpoint = new Uri(fixture.StatelessServerUrl) }); + await using var client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); + Assert.NotEmpty(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + } + } + + [Fact] + public async Task Initialize_WaitsForStartupBeforePublishingUrl() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(true); + await release.Task.WaitAsync(cancellationToken); + }))); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.False(startup.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + } + finally + { + release.TrySetResult(true); + } + await startup; + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Initialize_StartupFault_SurfacesOriginalError() + { + var expected = new InvalidOperationException("Synthetic host startup failure."); + await using var fixture = new ConformanceServerFixture(() => CreateApplication( + _ => new StartupService(_ => Task.FromException(expected)))); + + var actual = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Same(expected, actual); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_HostStopsDuringStartup_DoesNotReportReady() + { + await using var fixture = new ConformanceServerFixture(() => CreateApplication(services => + new StartupService(_ => + { + services.GetRequiredService().StopApplication(); + return Task.CompletedTask; + }))); + + var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("stopped before startup completed", error.Message); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_CallerCancellation_PropagatesToHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => startup); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_StartupTimeout_CancelsHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => startup); + Assert.Contains("30 seconds", error.Message); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_NoncooperativeStartup_IsBoundedAndOwnedUntilDrain() + { + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + Task? disposal = null; + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + await service.CancellationObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("ConformanceServer failed to start within 30 seconds", error.Message); + + disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await Assert.ThrowsAsync(() => startup); + if (disposal is not null) + { + await disposal; + } + else + { + await fixture.DisposeAsync(); + } + } + } + + [Fact] + public async Task Dispose_NoncooperativeStartup_ReportsDeadlineAndRetainsOwnership() + { + var clock = new FakeTimeProvider(); + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture( + () => CreateApplication(_ => service, useNoSocketServer: true), clock); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + clock.Advance(TestConstants.DefaultTimeout); + var error = await Assert.ThrowsAsync(() => + disposal.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("cleanup did not complete within 60 seconds", error.Message); + Assert.False(service.Disposed); + + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Dispose_LateStartupFault_IsReportedAfterSafeCleanup() + { + var expected = new InvalidOperationException("Synthetic late startup failure."); + var service = new NoncooperativeStartupService { Failure = expected }; + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + var actual = await Assert.ThrowsAsync(() => disposal); + Assert.Same(expected, actual); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + private WebApplication CreateApplication(Func serviceFactory, bool useNoSocketServer = false) + { + var builder = WebApplication.CreateBuilder(["--urls", "http://127.0.0.1:0"]); + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(XunitLoggerProvider); + if (useNoSocketServer) + { + builder.Services.AddSingleton(); + } + builder.Services.AddSingleton(serviceFactory); + var app = builder.Build(); + app.MapGet("/health", () => "Healthy"); + return app; + } + + private sealed class StartupService(Func start) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => start(cancellationToken); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class NoncooperativeStartupService : IHostedService, IDisposable + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool StartupCompleted { get; private set; } + public bool Disposed { get; private set; } + public bool DisposedDuringStartup { get; private set; } + public Exception? Failure { get; init; } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => CancellationObserved.TrySetResult(true)); + Entered.TrySetResult(true); + await Release.Task; + StartupCompleted = true; + if (Failure is { } error) + { + throw error; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + DisposedDuringStartup = !StartupCompleted; + Disposed = true; + } + } + + private sealed class NoSocketServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 2cb1d736e..a47332a0d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -15,6 +15,28 @@ public class ServerConformanceTests( ConformanceServerFixture fixture, ITestOutputHelper output) { + [Theory] + [InlineData("sep-2640-skills-enumeration", "2026-07-28")] + [InlineData("sep-2640-skills-manifest", "2026-07-28")] + [InlineData("sep-2640-skills-directory", "2026-07-28")] + [InlineData("sep-2640-skills-enumeration", "2025-11-25")] + [InlineData("sep-2640-skills-manifest", "2025-11-25")] + [InlineData("sep-2640-skills-directory", "2025-11-25")] + public async Task RunSkillsConformanceTest(string scenario, string protocolVersion) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + var url = protocolVersion == "2026-07-28" ? fixture.StatelessServerUrl : fixture.ServerUrl; + + var result = await RunConformanceTestsAsync( + $"server --url {url} --scenario {scenario} --spec-version {protocolVersion} --force", + appendProtocolVersionFromEnv: false); + + Assert.True(result.Success, + $"Skills scenario '{scenario}' for '{protocolVersion}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + Assert.DoesNotContain("SKIPPED: scenario", result.Output); + Assert.Matches(@"Passed:\s*[1-9]\d*/[1-9]\d*,\s*0\s*failed", result.Output); + } + [Fact] public async Task RunConformanceTests() { @@ -152,11 +174,13 @@ public async Task RunTasksExtensionConformanceTest(string scenario) $"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync( + string arguments, bool appendProtocolVersionFromEnv = true) { return await NodeHelpers.RunServerConformanceAsync( arguments, line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: appendProtocolVersionFromEnv, cancellationToken: TestContext.Current.CancellationToken); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs new file mode 100644 index 000000000..3f93b2821 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Security.Claims; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class SkillsAuthorizationTests(ITestOutputHelper output) : KestrelInMemoryTest(output) +{ + private const string SkillUri = "skill://private/report/SKILL.md"; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed) + { + await using var app = await StartServer(allowed); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + if (allowed) + { + Assert.Equal(SkillUri, Assert.Single(skills).Uri); + Assert.Equal(SkillUri, Assert.Single(resources).Uri); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken); + Assert.Single(content.Contents); + } + else + { + Assert.Empty(skills); + Assert.Empty(resources); + var metadataError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode); + var resourceError = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, resourceError.ErrorCode); + } + } + + [Fact] + public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead() + { + await using var app = await StartServer(allowed: true, hideResourceListing: true); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + Assert.Equal(SkillUri, skill.Uri); + Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents); + } + + [Fact] + public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess() + { + await using var app = await StartServer(allowed: true, authenticated: false); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + } + + private async Task StartServer( + bool allowed, bool hideResourceListing = false, bool authenticated = true) + { + var skill = McpServerSkill.Create(SkillUri, + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: report\ndescription: Private report\n---\n"), + ]); + var policy = new ReadPolicy(); + var catalog = new AuthorizedCatalog(new InMemoryMcpSkillCatalog([skill.ProtocolSkill]), policy); + Builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithSkills(catalog) + .WithResources(skill.Resources) + .WithRequestFilters(filters => + { + filters.AddListResourcesFilter(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + result.Resources = result.Resources + .Where(resource => !hideResourceListing && policy.CanRead(context.User, resource.Uri)) + .ToList(); + return result; + }); + filters.AddReadResourceFilter(next => (context, cancellationToken) => + { + if (!policy.CanRead(context.User, context.Params.Uri)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); + } + return next(context, cancellationToken); + }); + }); + + var app = Builder.Build(); + app.Use(async (context, next) => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], authenticated ? "Test" : null)); + await next(context); + }); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private sealed class ReadPolicy + { + public bool CanRead(ClaimsPrincipal? user, string uri) => + uri.StartsWith("skill://private/", StringComparison.Ordinal) && + (user?.Identity?.IsAuthenticated).GetValueOrDefault() && + (user?.IsInRole("reader")).GetValueOrDefault(); + } + + private sealed class AuthorizedCatalog(InMemoryMcpSkillCatalog catalog, ReadPolicy policy) : IMcpSkillCatalog + { + public async ValueTask ListAsync( + string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + var page = await catalog.ListAsync(cursor, context, cancellationToken); + return new McpSkillPage + { + Skills = page.Skills.Where(skill => policy.CanRead(context.User, skill.Uri)).ToArray(), + NextCursor = page.NextCursor, + }; + } + + public ValueTask GetAsync( + string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + policy.CanRead(context.User, uri) + ? catalog.GetAsync(uri, context, cancellationToken) + : new ValueTask((Skill?)null); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index dffffa9d3..7f2ca8212 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 73f63821e..568644262 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -1,8 +1,10 @@ using ConformanceServer.Prompts; using ConformanceServer.Resources; using ConformanceServer.Tools; +using ModelContextProtocol.ConformanceServer.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Extensions.Tasks; using System.Collections.Concurrent; using System.Diagnostics; @@ -13,6 +15,12 @@ namespace ModelContextProtocol.ConformanceServer; public class Program { public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default) + { + var app = new Program().CreateApplication(args, loggerProvider); + await app.RunAsync(cancellationToken); + } + + public WebApplication CreateApplication(string[] args, ILoggerProvider? loggerProvider = null) { var builder = WebApplication.CreateBuilder(args); @@ -37,7 +45,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide app.MapGet("/health", () => "Healthy"); - await app.RunAsync(cancellationToken); + return app; } // Registers the conformance MCP server (tools, prompts, resources, filters, and handlers) @@ -53,6 +61,12 @@ private static void ConfigureConformanceMcpServer( .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() + .WithSkills(ConformanceSkills.CreateCatalog(), options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }) + .WithResources(ConformanceSkills.CreateResources()) .WithTasks( new InMemoryMcpTaskStore { diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs new file mode 100644 index 000000000..81fe15827 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -0,0 +1,97 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.ConformanceServer.Skills; + +/// +/// Builds the SEP-2640 skill fixture the conformance scenarios run against. +/// +/// +/// +/// The three static skills are authored through , so their frontmatter is read from +/// their SKILL.md and their manifests are computed from the same bytes their resources serve. A fourth, +/// deliberately unenumerable skill is added to the catalog by hand: it is served and answerable through +/// skills/get, but carries no digests and so cannot be content-bound. +/// +/// +/// The page size is deliberately small so the scenarios exercise cursor pagination. +/// +/// +public static class ConformanceSkills +{ + private const string GeneratedReportUri = "skill://generated-report/SKILL.md"; + + private static readonly McpServerSkill[] s_skills = + [ + McpServerSkill.Create( + "skill://git-workflow/SKILL.md", + [ + SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"), + ]), + + McpServerSkill.Create( + "skill://pdf-processing/SKILL.md", + [ + SkillFile("pdf-processing", "Extract, fill, and assemble PDF documents", "# PDF processing\n"), + McpServerSkillFile.FromText("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"), + McpServerSkillFile.FromText("scripts/extract.py", "import sys\n\nprint('extract')\n"), + McpServerSkillFile.FromText("templates/invoice.md", "# Invoice\n"), + McpServerSkillFile.FromText("templates/regional/eu-invoice.md", "# EU Invoice\n"), + ]), + + McpServerSkill.Create( + "skill://acme/billing/refunds/SKILL.md", + [ + SkillFile("refunds", "Process customer refund requests per company policy", "# Refunds\n"), + McpServerSkillFile.FromText("examples/email.md", "Subject: Your refund\n"), + ]), + ]; + + private static readonly Skill s_generatedReport = new() + { + Uri = GeneratedReportUri, + Frontmatter = Frontmatter("generated-report", "Assemble a report from live data"), + Resources = SkillResources.Dynamic, + }; + + /// + /// Creates the catalog backing skills/list and skills/get. + /// + public static IMcpSkillCatalog CreateCatalog() => + new InMemoryMcpSkillCatalog([.. s_skills.Select(s => s.ProtocolSkill), s_generatedReport], pageSize: 2); + + /// + /// Creates the resources serving every skill file, so the files are enumerable through resources/list + /// and readable through resources/read. + /// + public static IEnumerable CreateResources() + { + foreach (var skill in s_skills) + { + foreach (var resource in skill.Resources) + { + yield return resource; + } + } + + yield return McpServerResource.Create( + () => $"---\nname: generated-report\ndescription: Assemble a report from live data\n---\n\n# Generated report\n\nGenerated at {DateTimeOffset.UtcNow:O}.\n", + new McpServerResourceCreateOptions + { + UriTemplate = GeneratedReportUri, + Name = "generated-report", + Description = "Assemble a report from live data", + MimeType = "text/markdown", + }); + } + + private static JsonObject Frontmatter(string name, string description) => new() + { + ["name"] = name, + ["description"] = description, + }; + + private static McpServerSkillFile SkillFile(string name, string description, string body) => + McpServerSkillFile.FromText(SkillsProtocol.SkillFileName, $"---\nname: {name}\ndescription: {description}\n---\n\n{body}"); +} diff --git a/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs new file mode 100644 index 000000000..23f82add5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs @@ -0,0 +1,493 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class DiscoveryAuthorizationTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Fact] + public async Task Discover_AuthorizesOncePerClient() + { + using var server = new AuthorizationServer(); + await ConnectTwiceAsync(server); + + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData(HttpTransportMode.AutoDetect)] + [InlineData(HttpTransportMode.StreamableHttp)] + public async Task Discover_MetadataHttpTimeout_TriesNextEndpoint(HttpTransportMode transportMode) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = "metadata" }; + using var httpClient = new HttpClient(server) { Timeout = TimeSpan.FromMilliseconds(400) }; + using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + caller.CancelAfter(TimeSpan.FromSeconds(8)); + await using var transport = CreateTransport(server, httpClient, transportMode); + var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(3) }; + + await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token); + + Assert.False(caller.IsCancellationRequested); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(2, server.MetadataRequests); + Assert.Single(server.AuthorizationStates); + Assert.Equal(1, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("large", false)] + [InlineData("maximum", false)] + [InlineData("infinite", false)] + [InlineData("large", true)] + [InlineData("maximum", true)] + [InlineData("infinite", true)] + public async Task Discover_AcceptsFullProbeTimeoutRange(string probeKind, bool infiniteInitialization) + { + using var server = new AuthorizationServer(); + using var httpClient = new HttpClient(server); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + caller.CancelAfter(TestConstants.DefaultTimeout); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions + { + DiscoverProbeTimeout = GetProbeTimeout(probeKind), + InitializationTimeout = infiniteInitialization ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(3), + }; + + await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token); + + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); + Assert.Equal(1, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("large")] + [InlineData("maximum")] + [InlineData("infinite")] + public async Task Discover_LargeOrInfiniteProbe_StillHonorsInitializationTimeout(string probeKind) + { + using var server = new AuthorizationServer { SilentBeforeAuthorization = true }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions + { + DiscoverProbeTimeout = GetProbeTimeout(probeKind), + InitializationTimeout = TimeSpan.FromMilliseconds(250), + }; + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.Equal(1, server.SilentProbeCancellations); + Assert.Equal(0, server.InitializeRequests); + } + + private TimeSpan GetProbeTimeout(string probeKind) => probeKind switch + { + "large" => TimeSpan.FromDays(60), + "maximum" => TimeSpan.MaxValue, + "infinite" => Timeout.InfiniteTimeSpan, + _ => throw new ArgumentOutOfRangeException(nameof(probeKind)), + }; + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_AuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + var connection = ConnectTwiceAsync(server); + try + { + await WaitForAuthorizationAsync(gate, connection); + // Keep the first authorization pending for longer than the configured probe. + // This timer exercises the deadline; the gate controls when authorization can complete. + await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout); + } + finally + { + gate.Release.TrySetResult(true); + } + await connection; + + Assert.Equal(0, gate.Cancellations); + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_CallerCancellationDuringAuthorization_Propagates(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + + var connection = McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: cancellation.Token); + await WaitForAuthorizationAsync(gate, connection); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => connection); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(1, server.MetadataRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_InitializationTimeoutDuringAuthorization_Propagates(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(1) }; + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.True(gate.Entered.Task.IsCompleted); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(1, server.MetadataRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Discover_SilentServer_StillFallsBackWithinProbeBudget(bool afterAuthorization) + { + using var server = new AuthorizationServer { SilentBeforeAuthorization = !afterAuthorization, SilentAfterAuthorization = afterAuthorization }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions { DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250) }; + + await using var client = await McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(1, server.InitializeRequests); + Assert.Single(server.AuthorizationStates); + Assert.Equal(1, server.SilentProbeCancellations); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_SseAuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage, UseLegacySse = true }; + var connection = ConnectTwiceAsync(server); + try + { + await WaitForAuthorizationAsync(gate, connection); + await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout); + } + finally + { + gate.Release.TrySetResult(true); + } + await connection; + + Assert.Equal(0, gate.Cancellations); + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(2, server.InitializeRequests); + } + + private async Task WaitForAuthorizationAsync(AuthorizationGate gate, Task connection) + { + await Task.WhenAny(gate.Entered.Task, connection).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + if (connection.IsCompleted) + { + await connection; + } + Assert.True(gate.Entered.Task.IsCompleted, "The connection completed without entering authorization."); + } + + private async Task ConnectTwiceAsync(AuthorizationServer server) + { + using var httpClient = new HttpClient(server) { Timeout = TestConstants.DefaultTimeout }; + for (int connection = 0; connection < 2; connection++) + { + await using var transport = CreateTransport(server, httpClient); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + } + + private HttpClientTransport CreateTransport( + AuthorizationServer server, HttpClient httpClient, HttpTransportMode transportMode = HttpTransportMode.AutoDetect) + { + return new HttpClientTransport(new() + { + Endpoint = new Uri("http://localhost/mcp"), + TransportMode = transportMode, + OAuth = new() + { + ClientId = "synthetic-client", + RedirectUri = new Uri("http://localhost/callback"), + AuthorizationCallbackHandler = server.AuthorizeAsync, + }, + }, httpClient, LoggerFactory); + } + + private sealed class AuthorizationGate : IDisposable + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Cancellations { get; private set; } + + public async Task WaitAsync(CancellationToken cancellationToken) + { + if (!Entered.TrySetResult(true)) + { + return; + } + + try + { + await Release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + Cancellations++; + throw; + } + } + + public async Task HoldPastProbeAsync(TimeSpan probeTimeout) + { + var elapsed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var timer = new Timer(_ => elapsed.TrySetResult(true), null, + probeTimeout + TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan); + await elapsed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + + public void Dispose() => Release.TrySetResult(true); + } + + private sealed class AuthorizationServer : HttpMessageHandler + { + private readonly List _ssePipes = []; + private Pipe? _ssePipe; + + public AuthorizationGate? Gate { get; init; } + public string? GatedStage { get; init; } + public bool UseLegacySse { get; init; } + public bool SilentBeforeAuthorization { get; init; } + public bool SilentAfterAuthorization { get; init; } + public List AuthorizationStates { get; } = []; + public int MetadataRequests { get; private set; } + public int AuthorizedDiscoverRequests { get; private set; } + public int InitializeRequests { get; private set; } + public int SilentProbeCancellations { get; private set; } + + public async Task AuthorizeAsync(AuthorizationCallbackContext context, CancellationToken cancellationToken) + { + var stateParameter = context.AuthorizationUri.Query.TrimStart('?').Split('&') + .Single(parameter => parameter.StartsWith("state=", StringComparison.Ordinal)); + var state = Uri.UnescapeDataString(stateParameter.Substring("state=".Length)); + AuthorizationStates.Add(state); + if (GatedStage == "callback") + { + await Assert.IsType(Gate).WaitAsync(cancellationToken); + } + return new AuthorizationResult { Code = "synthetic-code", State = state }; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = Assert.IsType(request.RequestUri); + Assert.True(uri.IsLoopback); + switch (uri.AbsolutePath) + { + case "/.well-known/oauth-protected-resource": + return JsonResponse("""{"resource":"http://localhost/mcp","authorization_servers":["http://localhost"]}"""); + + case "/.well-known/oauth-authorization-server": + case "/.well-known/openid-configuration": + MetadataRequests++; + if (GatedStage == "metadata") + { + await Assert.IsType(Gate).WaitAsync(cancellationToken); + } + return JsonResponse(""" + {"issuer":"http://localhost","authorization_endpoint":"http://localhost/authorize", + "token_endpoint":"http://localhost/token","code_challenge_methods_supported":["S256"]} + """); + + case "/token": + return JsonResponse("""{"access_token":"synthetic-token","token_type":"Bearer","expires_in":300}"""); + + case "/mcp": + case "/messages": + if (UseLegacySse && + uri.AbsolutePath == "/mcp" && + request.Method == HttpMethod.Post) + { + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + } + + if (UseLegacySse && + request.Method == HttpMethod.Get && + request.Headers.Authorization is not null) + { + _ssePipe = new Pipe(); + _ssePipes.Add(_ssePipe); + await _ssePipe.Writer.WriteAsync(Encoding.UTF8.GetBytes("event: endpoint\ndata: /messages\n\n"), cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(_ssePipe.Reader.AsStream()) + { + Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") }, + }, + }; + } + + JsonRpcMessage? message = null; + if (request.Content is { } content) + { + var body = await content.ReadAsStringAsync(cancellationToken); + message = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + } + + if (SilentBeforeAuthorization && + message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) + { + await WaitForProbeCancellationAsync(cancellationToken); + } + + if (request.Headers.Authorization is null) + { + var challenge = EmptyResponse(HttpStatusCode.Unauthorized); + challenge.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")); + return challenge; + } + + if (message is JsonRpcNotification) + { + return EmptyResponse(HttpStatusCode.Accepted); + } + var rpc = Assert.IsType(message); + switch (rpc.Method) + { + case RequestMethods.ServerDiscover: + AuthorizedDiscoverRequests++; + if (SilentAfterAuthorization) + { + await WaitForProbeCancellationAsync(cancellationToken); + } + if (UseLegacySse) + { + return await ReplyAsync(new JsonRpcError + { + Id = rpc.Id, + Error = new() { Code = -32601, Message = "Method not found" }, + }, cancellationToken); + } + return await ReplyAsync(RpcResponse(rpc, new DiscoverResult + { + SupportedVersions = ["2026-07-28"], + Capabilities = new(), + }), cancellationToken); + + case RequestMethods.Initialize: + InitializeRequests++; + return await ReplyAsync(RpcResponse(rpc, new InitializeResult + { + ProtocolVersion = "2025-11-25", + ServerInfo = new() { Name = "synthetic-server", Version = "1.0" }, + Capabilities = new(), + }), cancellationToken); + } + throw new InvalidOperationException($"Unexpected RPC: {rpc.Method}"); + + default: + throw new InvalidOperationException($"Unexpected synthetic request: {request.Method} {uri}"); + } + } + + private async Task WaitForProbeCancellationAsync(CancellationToken cancellationToken) + { + var noResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + await noResponse.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + SilentProbeCancellations++; + throw; + } + throw new InvalidOperationException("A silent probe must be cancelled, not completed."); + } + + private JsonRpcResponse RpcResponse(JsonRpcRequest request, TResult result) + { + return new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions), + }; + } + + private async Task ReplyAsync(JsonRpcMessage response, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(response, McpJsonUtilities.DefaultOptions); + if (UseLegacySse) + { + var pipe = Assert.IsType(_ssePipe); + await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"event: message\ndata: {json}\n\n"), cancellationToken); + return EmptyResponse(HttpStatusCode.Accepted); + } + return JsonResponse(json); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var pipe in _ssePipes) + { + pipe.Writer.Complete(); + } + _ssePipes.Clear(); + } + base.Dispose(disposing); + } + + private HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status) + { + Content = new StringContent(string.Empty), + }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs new file mode 100644 index 000000000..6501275c5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -0,0 +1,288 @@ +#pragma warning disable MCPEXP002 // Raw request handlers are the point of this test. + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// The client extensions validate every entry a server returns against the specification's structural +/// requirements, since hosts must not load invalid entries. These tests stand up a server whose raw +/// skills/list and skills/get handlers return entries the SDK's own server side would never publish. +/// +public class McpSkillsClientValidationTests : ClientServerTestBase +{ + private JsonObject? _listResponse; + + public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= []; + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (request, _) => + { + if (_listResponse is not null) + { + return new ValueTask(_listResponse.DeepClone()); + } + + var json = request.Params?["cursor"]?.GetValue() switch + { + "no-skills-array" => """{ "skills": null }""", + "missing-skills-array" => "{}", + "empty-skills-array" => """{ "skills": [] }""", + _ => """ + { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] }, + { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" }, + "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] } + """, + }; + return new ValueTask(JsonNode.Parse(json)); + }, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsGet, + Handler = (request, _) => + { + // Any request other than the escaping skill is answered with a valid entry for a different URI. + string? requested = request.Params?["uri"]?.GetValue(); + string json = requested == "skill://escape/SKILL.md" + ? """ + { "skill": { "uri": "skill://escape/SKILL.md", "frontmatter": { "name": "escape", "description": "d" }, + "resources": [ { "uri": "skill://escape/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 }, + { "uri": "skill://escape/../secret.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } } + """ + : """ + { "skill": { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } } + """; + return new ValueTask(JsonNode.Parse(json)); + }, + }); + }); + } + + [Fact] + public async Task ListSkillsAsync_RejectsAnInvalidEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("skills/list", exception.Message); + Assert.Contains("skill://bad/SKILL.md", exception.Message); + } + + [Theory] + [InlineData("no-skills-array")] + [InlineData("missing-skills-array")] + public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string cursor) + { + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ListSkillsAsync_AcceptsAnEmptySkillsArray() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, TestContext.Current.CancellationToken); + + Assert.Empty(result.Skills); + } + + [Theory] + [InlineData("\U00010428")] + [InlineData("\U00020000")] + public async Task ListSkillsAsync_AcceptsSupplementaryUnicodeNames(string name) + { + _listResponse = CreateListResponse(name); + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(name, Assert.Single(result).Name); + } + + [Fact] + public async Task ListSkillsAsync_RejectsTitlecaseNames() + { + _listResponse = CreateListResponse("\u01c5"); + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value) + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) }); + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("metadata", exception.Message); + } + + [Fact] + public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionFields() + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" }); + var frontmatter = _listResponse["skills"]![0]!["frontmatter"]!.AsObject(); + frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) }; + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter)); + } + + [Fact] + public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal("good", Assert.Single(result.Page.Skills).Name); + var rejected = Assert.Single(result.RejectedEntries); + Assert.Equal(1, rejected.Index); + Assert.IsType(rejected.Error); + } + + [Fact] + public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints() + { + _listResponse = CreateListResponse("alpha"); + var entries = _listResponse["skills"]!.AsArray(); + entries.Add(null); + entries.Add(JsonNode.Parse("42")); + entries.Add(new JsonObject()); + _listResponse["nextCursor"] = "next-page"; + _listResponse["ttlMs"] = 3000; + _listResponse["cacheScope"] = "public"; + _listResponse["resultType"] = "complete"; + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Single(result.Page.Skills); + Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index)); + Assert.Equal("next-page", result.Page.NextCursor); + Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive); + Assert.Equal(CacheScope.Public, result.Page.CacheScope); + Assert.Equal("complete", result.Page.ResultType); + Assert.Equal(4, entries.Count); + } + + [Theory] + [InlineData("no-skills-array")] + [InlineData("missing-skills-array")] + public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor) + { + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DiagnosticListingReportsAnAllInvalidPageRatherThanHidingItsFailure() + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = 1 }); + _listResponse["nextCursor"] = "next-page"; + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Empty(result.Page.Skills); + Assert.Contains("metadata", Assert.Single(result.RejectedEntries).Error.Message); + Assert.Equal("next-page", result.Page.NextCursor); + } + + [Fact] + public async Task GetSkillAsync_RejectsAValidEntryForADifferentUri() + { + await using McpClient client = await CreateMcpClientForServer(); + + // The entry itself is valid; it is just not the skill that was asked for. + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://other/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skill://other/SKILL.md", exception.Message); + Assert.Contains("skill://good/SKILL.md", exception.Message); + + // Asking for the URI the server actually returns succeeds. + var good = await client.GetSkillAsync("skill://good/SKILL.md", TestContext.Current.CancellationToken); + Assert.Equal("good", good.Name); + } + + [Fact] + public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://escape/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skills/get", exception.Message); + Assert.Contains("'..'", exception.Message); + } + + private JsonObject CreateListResponse(string name, JsonObject? metadata = null) + { + var uri = $"skill://catalog/{name}/SKILL.md"; + var frontmatter = new JsonObject { ["name"] = name, ["description"] = "d" }; + if (metadata is not null) + { + frontmatter["metadata"] = metadata; + } + + return new JsonObject + { + ["skills"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["frontmatter"] = frontmatter, + ["resources"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["digest"] = "sha256:" + new string('a', 64), + ["size"] = 1, + }), + }), + }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs new file mode 100644 index 000000000..3bdfa0c33 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs @@ -0,0 +1,190 @@ +#pragma warning disable MCPEXP002 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class McpSkillsEnumerationTests : ClientServerTestBase +{ + private Func? _page; + private readonly List _cursors = []; + + public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder) + { + services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= []; + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (request, _) => + { + _cursors.Add(request.Params?["cursor"]?.GetValue()); + return new ValueTask(_page!(_cursors.Count)); + }, + }); + }); + } + + [Theory] + [InlineData("repeat")] + [InlineData("")] + public async Task RepeatedCursorFailsInsteadOfLooping(string cursor) + { + _page = number => Page(cursor, $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("repeated", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent() + { + _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing() + { + _page = number => number == 1 ? Page("", "first") : Page(null, "second"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name)); + Assert.Equal(2, _cursors.Count); + Assert.Null(_cursors[0]); + Assert.Equal("", _cursors[1]); + } + + [Fact] + public async Task EverChangingCursorsStopAtTheRequestedPageLimit() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken)); + + Assert.Contains("3 pages", error.Message); + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task DefaultPageLimitIsOneHundred() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("100 pages", error.Message); + Assert.Equal(100, _cursors.Count); + } + + [Fact] + public async Task EntryLimitFailsWithoutReturningATruncatedList() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken)); + + Assert.Contains("3 entries", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task LastPageMayExactlyReachBothLimits() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken); + + Assert.Equal(3, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly() + { + _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray()); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + Assert.Contains("1000 entries", error.Message); + + var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken); + Assert.Equal(1001, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(1, 0)] + [InlineData(1, -1)] + public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills) + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken)); + Assert.Empty(_cursors); + } + + [Fact] + public async Task CancellationStopsBeforeRequestingAPage() + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await client.ListSkillsAsync(cancellation.Token)); + Assert.Empty(_cursors); + } + + private JsonObject Page(string? nextCursor, params string[] names) + { + var entries = new JsonArray(); + foreach (var name in names) + { + entries.Add(new JsonObject + { + ["uri"] = $"skill://{name}/SKILL.md", + ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" }, + ["resources"] = "dynamic", + }); + } + return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs new file mode 100644 index 000000000..c8164defc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -0,0 +1,186 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for : digest formatting and content verification against a manifest. +/// +public class SkillVerifierTests +{ + private const string Uri = "skill://alpha/SKILL.md"; + + private static SkillResource Entry(byte[] content) => new() + { + Uri = Uri, + Digest = SkillVerifier.ComputeDigest(content), + Size = content.Length, + }; + + [Theory] + [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] + public void ComputeDigest_MatchesKnownVectors(string input, string expected) + { + Assert.Equal(expected, SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(input))); + } + + [Fact] + public void Verify_AcceptsMatchingBytes() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + + SkillVerifier.Verify(Entry(content), content); + } + + [Fact] + public void Verify_RejectsSizeMismatch() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + var entry = Entry(content); + entry.Size += 1; + + var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content)); + Assert.Contains("bytes", exception.Message); + } + + [Fact] + public void Verify_RejectsDigestMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n")); + + var exception = Assert.Throws(() => + SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n"))); + Assert.Contains("digest", exception.Message); + } + + [Fact] + public void Verify_AcceptsUppercaseDigestFromServer() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var entry = Entry(content); + entry.Digest = entry.Digest.ToUpperInvariant().Replace("SHA256", "sha256"); + + SkillVerifier.Verify(entry, content); + } + + [Fact] + public void Verify_TextContents_HashesUtf8Encoding() + { + const string Text = "# Héllo\n"; + var entry = Entry(Encoding.UTF8.GetBytes(Text)); + + SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = Text }); + } + + [Fact] + public void Verify_Contents_RejectsMissingTextAsVerificationFailure() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! })); + } + + [Fact] + public void Verify_BlobContents_HashesDecodedBytes() + { + byte[] content = [0x00, 0xFF, 0x10, 0x80]; + var entry = Entry(content); + + SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri)); + } + + [Fact] + public void Verify_Contents_RejectsUriMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => + SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" })); + } + + [Fact] + public void Verify_Skill_ChecksEveryContentAgainstManifest() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var skill = CreateSkill(content); + + SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] })); + } + + [Fact] + public void Verify_Skill_RejectsUnlistedFile() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] })); + Assert.Contains("not listed", exception.Message); + } + + [Fact] + public void Verify_Skill_RejectsEmptyResult() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); + } + + [Fact] + public void Verify_Skill_RejectsMissingManifestWithoutCrashing() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + skill.Resources = null!; + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + [Fact] + public void Verify_Skill_ThrowsForDynamicSkill() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + skill.Resources = SkillResources.Dynamic; + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + [Fact] + public void Verify_SkillAndUri_RequiresTheRequestedFileInTheResult() + { + byte[] skillFile = Encoding.UTF8.GetBytes("x"); + byte[] other = Encoding.UTF8.GetBytes("other"); + var skill = CreateSkill(skillFile); + skill.Resources = SkillResources.FromResources( + [ + Entry(skillFile), + new SkillResource { Uri = "skill://alpha/other.md", Digest = SkillVerifier.ComputeDigest(other), Size = other.Length }, + ]); + + // A correctly digested but different file of the same skill must not satisfy a read of SKILL.md. + var substituted = new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/other.md", Text = "other" }] }; + SkillVerifier.Verify(skill, substituted); + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, Uri, substituted)); + Assert.Contains("returned no contents for that URI", exception.Message); + + SkillVerifier.Verify(skill, Uri, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + + Assert.Throws(() => + SkillVerifier.Verify(skill, "skill://alpha/unlisted.md", new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + private static Skill CreateSkill(byte[] skillFileContent) => new() + { + Uri = Uri, + Frontmatter = new JsonObject { ["name"] = "alpha", ["description"] = "d" }, + Resources = SkillResources.FromResources([Entry(skillFileContent)]), + }; +} diff --git a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs index 4e500b5ab..125d78e63 100644 --- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs +++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs @@ -62,7 +62,7 @@ protected McpServer StartServer() return Server; } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { await _cts.CancelAsync(); diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 677d77357..0ae1d0775 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -84,6 +84,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs new file mode 100644 index 000000000..bca8ae7ee --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -0,0 +1,325 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization and deserialization tests for the SEP-2640 skills protocol types, with particular attention to +/// the array-or-"dynamic" union used for a skill's manifest. +/// +public class SkillSerializationTests +{ + private static Skill CreateEntry(SkillResources resources) => new() + { + Uri = "skill://git-workflow/SKILL.md", + Frontmatter = new JsonObject + { + ["name"] = "git-workflow", + ["description"] = "Follow this team's Git conventions", + }, + Resources = resources, + }; + + private static SkillResource CreateResource(string uri, string digest, long size) => new() + { + Uri = uri, + Digest = digest, + Size = size, + }; + + [Fact] + public void Skill_WithEnumeratedResources_RoundTrips() + { + var original = CreateEntry(SkillResources.FromResources( + [ + CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('a', 64), 2314), + CreateResource("skill://git-workflow/examples/email.md", "sha256:" + new string('b', 64), 962), + ])); + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Uri); + Assert.Equal("git-workflow", deserialized.Name); + Assert.Equal("Follow this team's Git conventions", deserialized.Description); + Assert.False(deserialized.Resources.IsDynamic); + + var resources = deserialized.Resources.Resources; + Assert.NotNull(resources); + Assert.Equal(2, resources.Count); + Assert.Equal("skill://git-workflow/SKILL.md", resources[0].Uri); + Assert.Equal(2314, resources[0].Size); + Assert.Equal("sha256:" + new string('b', 64), resources[1].Digest); + } + + [Fact] + public void Skill_WithDynamicResources_SerializesAsTheDynamicString() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.Equal("dynamic", node["resources"]?.GetValue()); + } + + [Fact] + public void Skill_WithDynamicResources_RoundTrips() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Resources.IsDynamic); + Assert.Null(deserialized.Resources.Resources); + } + + [Fact] + public void Skill_WithEmptyResourceArray_RoundTripsAsNonDynamic() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.FromResources([])), McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.False(deserialized.Resources.IsDynamic); + Assert.Empty(deserialized.Resources.Resources!); + } + + [Fact] + public void Skill_PreservesArbitraryFrontmatterFields() + { + const string Json = """ + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { + "name": "refunds", + "description": "Process refunds", + "license": "Apache-2.0", + "metadata": { "version": "2.1.0", "owner": "billing" }, + "allowed-tools": "Bash(git:*)" + }, + "resources": "dynamic" + } + """; + + var skill = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(skill); + Assert.Equal("Apache-2.0", skill.Frontmatter["license"]?.GetValue()); + Assert.Equal("2.1.0", skill.Frontmatter["metadata"]?["version"]?.GetValue()); + Assert.Equal("Bash(git:*)", skill.Frontmatter["allowed-tools"]?.GetValue()); + + string reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized))); + } + + [Fact] + public void Skill_NameAndDescription_AreNullWhenAbsentOrNotStrings() + { + var skill = new Skill + { + Uri = "skill://x/SKILL.md", + Frontmatter = new JsonObject { ["name"] = 42 }, + Resources = SkillResources.Dynamic, + }; + + Assert.Null(skill.Name); + Assert.Null(skill.Description); + } + + [Theory] + [InlineData("\"static\"")] + [InlineData("\"Dynamic\"")] + [InlineData("\"\"")] + [InlineData("null")] + [InlineData("123")] + [InlineData("true")] + [InlineData("{}")] + [InlineData("[null]")] + public void Skill_WithInvalidResourcesValue_Throws(string resourcesJson) + { + string json = $$""" + { + "uri": "skill://git-workflow/SKILL.md", + "frontmatter": { "name": "git-workflow", "description": "d" }, + "resources": {{resourcesJson}} + } + """; + + Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill)); + } + + [Fact] + public void Skill_WithoutResources_Throws() + { + const string Json = """ + { + "uri": "skill://git-workflow/SKILL.md", + "frontmatter": { "name": "git-workflow", "description": "d" } + } + """; + + Assert.ThrowsAny(() => JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill)); + } + + [Fact] + public void SkillResources_FromResources_CopiesTheSource() + { + var mutable = new List + { + CreateResource("skill://a/SKILL.md", "sha256:" + new string('c', 64), 1), + }; + + var resources = SkillResources.FromResources(mutable); + mutable.Add(CreateResource("skill://a/extra.md", "sha256:" + new string('d', 64), 2)); + + Assert.Single(resources.Resources!); + } + + [Fact] + public void SkillResources_FromResources_WithNull_Throws() => + Assert.Throws(() => SkillResources.FromResources(null!)); + + [Fact] + public void ListSkillsResult_RoundTripsCursorAndCacheAttributes() + { + var original = new ListSkillsResult + { + Skills = [CreateEntry(SkillResources.Dynamic)], + NextCursor = "cursor-1", + ResultType = "complete", + TimeToLive = TimeSpan.FromMinutes(5), + CacheScope = CacheScope.Public, + }; + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsResult); + var node = JsonNode.Parse(json)!.AsObject(); + Assert.Equal(300_000, node["ttlMs"]?.GetValue()); + Assert.Equal("public", node["cacheScope"]?.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsResult); + Assert.NotNull(deserialized); + Assert.Equal("cursor-1", deserialized.NextCursor); + Assert.Equal("complete", deserialized.ResultType); + Assert.Equal(TimeSpan.FromMinutes(5), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + Assert.Single(deserialized.Skills); + } + + [Fact] + public void ListSkillsResult_ToleratesUnknownCacheScopeOnRead() + { + const string Json = """{ "skills": [], "ttlMs": 0, "cacheScope": "regional" }"""; + + var deserialized = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.ListSkillsResult); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.CacheScope); + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + } + + [Fact] + public void ListSkillsResult_OmitsOptionalFieldsWhenUnset() + { + string json = JsonSerializer.Serialize(new ListSkillsResult(), McpSkillsJsonContext.Default.ListSkillsResult); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + Assert.False(node.ContainsKey("nextCursor")); + Assert.False(node.ContainsKey("resultType")); + Assert.Empty(node["skills"]!.AsArray()); + } + + [Fact] + public void GetSkillResult_RoundTrips() + { + var original = new GetSkillResult + { + Skill = CreateEntry(SkillResources.FromResources( + [ + CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('e', 64), 10), + ])), + ResultType = "complete", + }; + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.GetSkillResult); + Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor")); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + Assert.NotNull(deserialized); + Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Skill.Uri); + Assert.Equal("complete", deserialized.ResultType); + } + + [Theory] + [InlineData(0, CacheScope.Private)] + [InlineData(300_000, CacheScope.Public)] + [InlineData(-1, CacheScope.Private)] + public void GetSkillResult_RoundTripsCacheAttributes(long milliseconds, CacheScope scope) + { + var original = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic), ResultType = "complete" }; + var cacheable = Assert.IsAssignableFrom(original); + cacheable.TimeToLive = TimeSpan.FromMilliseconds(milliseconds); + cacheable.CacheScope = scope; + + var json = JsonSerializer.SerializeToNode(original, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject(); + Assert.Equal(milliseconds, json["ttlMs"]?.GetValue()); + Assert.Equal(scope == CacheScope.Public ? "public" : "private", json["cacheScope"]?.GetValue()); + Assert.Equal("complete", json["resultType"]?.GetValue()); + Assert.False(json.ContainsKey("nextCursor")); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + var deserializedCacheable = Assert.IsAssignableFrom(deserialized); + Assert.Equal(cacheable.TimeToLive, deserializedCacheable.TimeToLive); + Assert.Equal(scope, deserializedCacheable.CacheScope); + } + + [Theory] + [InlineData("\"regional\"")] + [InlineData("42")] + [InlineData("null")] + public void GetSkillResult_ToleratesUnknownCacheScopeOnRead(string cacheScope) + { + var json = $$""" + { + "skill": { + "uri": "skill://alpha/SKILL.md", + "frontmatter": { "name": "alpha", "description": "d" }, + "resources": "dynamic" + }, + "ttlMs": 0, + "cacheScope": {{cacheScope}} + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + var cacheable = Assert.IsAssignableFrom(deserialized); + + Assert.Null(cacheable.CacheScope); + Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive); + } + + [Fact] + public void GetSkillResult_OmitsVersionedFieldsWhenUnset() + { + var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) }; + var json = JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject(); + + Assert.False(json.ContainsKey("ttlMs")); + Assert.False(json.ContainsKey("cacheScope")); + Assert.False(json.ContainsKey("resultType")); + Assert.False(json.ContainsKey("nextCursor")); + } + + [Fact] + public void RequestParams_RoundTrip() + { + string listJson = JsonSerializer.Serialize(new ListSkillsRequestParams { Cursor = "abc" }, McpSkillsJsonContext.Default.ListSkillsRequestParams); + Assert.Equal("abc", JsonSerializer.Deserialize(listJson, McpSkillsJsonContext.Default.ListSkillsRequestParams)?.Cursor); + + string getJson = JsonSerializer.Serialize(new GetSkillRequestParams { Uri = "skill://a/SKILL.md" }, McpSkillsJsonContext.Default.GetSkillRequestParams); + Assert.Equal("skill://a/SKILL.md", JsonSerializer.Deserialize(getJson, McpSkillsJsonContext.Default.GetSkillRequestParams)?.Uri); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs new file mode 100644 index 000000000..25ecbd5e2 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -0,0 +1,478 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : ordering, keyset pagination, lookup, cursor validation, and +/// the structural validation of entries against the specification. +/// +public class InMemoryMcpSkillCatalogTests +{ + private static readonly string s_validDigest = "sha256:" + new string('a', 64); + + private static McpSkillRequestContext Context => new(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }); + + private static Skill CreateSkill(string name, string? description = "A skill", SkillResources? resources = null) + { + string uri = $"skill://{name}/SKILL.md"; + return new() + { + Uri = uri, + Frontmatter = new JsonObject + { + ["name"] = name, + ["description"] = description, + }, + Resources = resources ?? SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 10 }]), + }; + } + + private static InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => + new([.. names.Select(name => CreateSkill(name))], pageSize); + + [Fact] + public async Task ListAsync_ReturnsEntriesOrderedByUri() + { + var catalog = CreateCatalog(10, "zebra", "alpha", "middle"); + + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + + Assert.Collection( + page.Skills, + skill => Assert.Equal("skill://alpha/SKILL.md", skill.Uri), + skill => Assert.Equal("skill://middle/SKILL.md", skill.Uri), + skill => Assert.Equal("skill://zebra/SKILL.md", skill.Uri)); + Assert.Null(page.NextCursor); + Assert.Equal(3, catalog.Count); + } + + [Fact] + public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries() + { + var catalog = CreateCatalog(2, "a", "b", "c", "d", "e"); + + var seen = new List(); + string? cursor = null; + int pages = 0; + do + { + var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); + seen.AddRange(page.Skills.Select(skill => skill.Uri)); + cursor = page.NextCursor; + Assert.True(++pages < 20, "Pagination did not terminate."); + } + while (cursor is not null); + + Assert.Equal(3, pages); + Assert.Equal(5, seen.Count); + Assert.Equal(seen.Count, seen.Distinct().Count()); + Assert.Equal(seen.OrderBy(uri => uri, StringComparer.Ordinal), seen); + } + + [Fact] + public async Task ListAsync_LastPageHasNoNextCursor() + { + var catalog = CreateCatalog(2, "a", "b", "c", "d"); + + var first = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + var second = await catalog.ListAsync(first.NextCursor, Context, TestContext.Current.CancellationToken); + + Assert.NotNull(first.NextCursor); + Assert.Equal(2, second.Skills.Count); + Assert.Null(second.NextCursor); + } + + [Fact] + public async Task ListAsync_WithCursorForUnknownUri_ResumesAfterItsPosition() + { + var catalog = CreateCatalog(10, "a", "c"); + string cursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("skill://b/SKILL.md")); + + var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); + + Assert.Single(page.Skills); + Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri); + } + + [Fact] + public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage() + { + var catalog = CreateCatalog(10); + + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + + Assert.Empty(page.Skills); + Assert.Null(page.NextCursor); + } + + [Theory] + [InlineData("not-base64!!")] + [InlineData("/w==")] + [InlineData("wK8=")] + [InlineData("7aCA")] + public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams(string cursor) + { + var catalog = CreateCatalog(10, "a"); + + var exception = await Assert.ThrowsAsync( + async () => await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task GetAsync_ReturnsSkillByUri() + { + var catalog = CreateCatalog(10, "alpha"); + + var skill = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + + Assert.NotNull(skill); + Assert.Equal("alpha", skill.Name); + } + + [Theory] + [InlineData("skill://missing/SKILL.md")] + [InlineData("SKILL://ALPHA/SKILL.md")] + [InlineData("skill://alpha")] + public async Task GetAsync_WithUnknownUri_ReturnsNull(string uri) + { + var catalog = CreateCatalog(10, "alpha"); + + Assert.Null(await catalog.GetAsync(uri, Context, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Constructor_SnapshotsEntries_SoLaterMutationsAreNotServed() + { + var source = CreateSkill("alpha"); + var catalog = new InMemoryMcpSkillCatalog([source]); + + source.Frontmatter["description"] = "changed"; + source.Resources.Resources![0].Digest = "sha256:" + new string('f', 64); + source.Uri = "skill://renamed/SKILL.md"; + + var served = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + Assert.NotNull(served); + Assert.Equal("A skill", served.Description); + Assert.Equal(s_validDigest, served.Resources.Resources![0].Digest); + } + + [Fact] + public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResponses() + { + var catalog = new InMemoryMcpSkillCatalog([CreateSkill("alpha")]); + + var first = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + first!.Frontmatter["description"] = "tampered"; + first.Resources.Resources![0].Digest = "sha256:" + new string('f', 64); + + var listed = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + listed.Skills[0].Frontmatter["description"] = "tampered again"; + + var again = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + Assert.Equal("A skill", again!.Description); + Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest); + } + + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("données-2024")] + [InlineData("\U00010428")] + [InlineData("\U00020000")] + public void Constructor_AcceptsUnicodeLowercaseNames(string name) + { + Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count); + } + + [Theory] + [InlineData("\u01c5")] + [InlineData("\U00010400")] + public void Constructor_RejectsUppercaseOrTitlecaseNames(string name) + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(0xd801)] + [InlineData(0xdc28)] + public void Constructor_RejectsUnpairedSurrogates(int codeUnit) + { + var name = new string((char)codeUnit, 1); + + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(64, true)] + [InlineData(65, false)] + public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) + { + var name = string.Concat(Enumerable.Repeat("\U00010428", length)); + var skill = CreateSkill(name); + skill.Uri = $"skill://catalog/{name}/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 10 }]); + + if (valid) + { + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + else + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + } + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public void Constructor_RejectsNonStringMetadataValues(string value) + { + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) }; + + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", exception.Message); + } + + [Fact] + public void Constructor_WithDuplicateUris_Throws() + { + var duplicate = new[] { CreateSkill("alpha"), CreateSkill("alpha") }; + + Assert.Throws(() => new InMemoryMcpSkillCatalog(duplicate)); + } + + [Fact] + public void Constructor_WithNullSkills_Throws() => + Assert.Throws(() => new InMemoryMcpSkillCatalog(null!)); + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_WithNonPositivePageSize_Throws(int pageSize) => + Assert.Throws(() => new InMemoryMcpSkillCatalog([], pageSize)); + + [Fact] + public void Constructor_AcceptsFullAgentSkillsFrontmatter() + { + var skill = CreateSkill("alpha"); + skill.Frontmatter["license"] = "MIT"; + skill.Frontmatter["compatibility"] = "Needs git"; + skill.Frontmatter["allowed-tools"] = "Bash(git:*) Read"; + skill.Frontmatter["metadata"] = new JsonObject { ["author"] = "acme", ["version"] = "2.1.0" }; + skill.Frontmatter["description"] = new string('d', 1024); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization() + { + var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter()); + var value = JsonValue.Create(1, typeInfo)!; + Assert.Equal(JsonValueKind.String, value.GetValueKind()); + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value }; + + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", error.Message); + } + + private sealed class ChangingMetadataConverter : JsonConverter + { + private int _writes; + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (++_writes == 1) + { + writer.WriteStringValue("initial-string"); + } + else + { + writer.WriteNumberValue(value); + } + } + } + + [Fact] + public void Constructor_PassesThroughFieldsHostsDoNotActOn() + { + // Unknown fields retain structured values; the known metadata map has its own string-value requirement. + var skill = CreateSkill("alpha"); + skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + skill.Frontmatter["license"] = null; + skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_AcceptsEmptyAuthority() + { + var skill = CreateSkill("alpha"); + skill.Uri = "file:///alpha/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_AcceptsDynamicSkill() + { + var catalog = new InMemoryMcpSkillCatalog([CreateSkill("generated", resources: SkillResources.Dynamic)]); + + Assert.Equal(1, catalog.Count); + } + + [Fact] + public void Constructor_AcceptsNestedSkillPath() + { + var skill = CreateSkill("refunds"); + skill.Uri = "skill://acme/billing/refunds/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + var catalog = new InMemoryMcpSkillCatalog([skill]); + + Assert.Equal(1, catalog.Count); + } + + public static IEnumerable InvalidSkills() + { + static object[] Case(string reason, Action mutate) + { + var skill = CreateSkill("alpha"); + mutate(skill); + return [reason, skill]; + } + + yield return Case("uri not ending in /SKILL.md", s => s.Uri = "skill://alpha/skill.md"); + yield return Case("syntactically invalid uri", s => + { + s.Uri = "1 bad://alpha/SKILL.md"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("metadata present but null", s => s.Frontmatter["metadata"] = null); + yield return Case("compatibility present but null", s => s.Frontmatter["compatibility"] = null); + yield return Case("relative uri", s => + { + s.Uri = "alpha/SKILL.md"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("uri with query", s => + { + s.Uri = "skill://alpha/SKILL.md?x=1"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("resource escapes the skill through '..'", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/../secret.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with empty segment", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha//x.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with fragment", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/x.md#frag", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("sizes that overflow when summed", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = long.MaxValue }, + new SkillResource { Uri = "skill://alpha/x.md", Digest = s_validDigest, Size = long.MaxValue }, + ])); + yield return Case("description over 1024 characters", s => s.Frontmatter["description"] = new string('d', 1025)); + yield return Case("compatibility over 500 characters", s => s.Frontmatter["compatibility"] = new string('c', 501)); + yield return Case("compatibility empty", s => s.Frontmatter["compatibility"] = ""); + yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x"); + yield return Case("name missing", s => s.Frontmatter.Remove("name")); + yield return Case("name not a string", s => s.Frontmatter["name"] = 1); + yield return Case("name does not match uri segment", s => s.Frontmatter["name"] = "beta"); + yield return Case("name with an uppercase letter", s => + { + s.Uri = "skill://Café/SKILL.md"; + s.Frontmatter["name"] = "Café"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("resource escapes the skill through an encoded '..'", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with an encoded slash", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/a%2Fb.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("name violates naming rules", s => + { + s.Uri = "skill://Alpha/SKILL.md"; + s.Frontmatter["name"] = "Alpha"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("description missing", s => s.Frontmatter.Remove("description")); + yield return Case("description empty", s => s.Frontmatter["description"] = ""); + yield return Case("empty manifest", s => s.Resources = SkillResources.FromResources([])); + yield return Case("manifest omits SKILL.md", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = "skill://alpha/other.md", Digest = s_validDigest, Size = 1 }])); + yield return Case("manifest lists file outside the skill", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alphabet/x.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("duplicate manifest entry", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("malformed digest (uppercase)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:" + new string('A', 64), Size = 1 }])); + yield return Case("malformed digest (wrong length)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:abc", Size = 1 }])); + yield return Case("malformed digest (wrong algorithm)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha512:" + new string('a', 64), Size = 1 }])); + yield return Case("negative size", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = -1 }])); + yield return Case("too many resources", s => s.Resources = SkillResources.FromResources( + Enumerable.Range(0, SkillsProtocol.MaxResourcesPerSkill + 1).Select(i => new SkillResource + { + Uri = i == 0 ? s.Uri : $"skill://alpha/f{i}.md", + Digest = s_validDigest, + Size = 1, + }))); + yield return Case("total size over limit", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/big.bin", Digest = s_validDigest, Size = SkillsProtocol.MaxTotalSizeBytes }, + ])); + } + + [Theory] + [MemberData(nameof(InvalidSkills))] + public void Constructor_RejectsInvalidSkill(string reason, Skill skill) + { + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("skill://", exception.Message); + Assert.NotEmpty(reason); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs new file mode 100644 index 000000000..c1304fb77 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Tests.Server; + +public partial class InjectedParameterSchemaTests +{ + private RequestContext? _toolRequest; + private RequestContext? _promptRequest; + private InjectedService? _service; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Tool_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var method = GetMethod(nameof(EchoTool), freshParameters); + var tool = McpServerTool.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options }); + var properties = tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal(["value"], properties); + + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("tool"), Method = RequestMethods.ToolsCall }, + new() + { + Name = tool.ProtocolTool.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }); + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.Same(request, _toolRequest); + Assert.Equal("hello", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Prompt_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var method = GetMethod(nameof(EchoPrompt), freshParameters); + var prompt = McpServerPrompt.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options }); + Assert.Equal(["value"], Assert.IsAssignableFrom>(prompt.ProtocolPrompt.Arguments).Select(argument => argument.Name).ToArray()); + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("prompt"), Method = RequestMethods.PromptsGet }, + new() + { + Name = prompt.ProtocolPrompt.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }); + + await prompt.GetAsync(request, TestContext.Current.CancellationToken); + + Assert.Same(request, _promptRequest); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Tool_RegisteredServiceRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var expected = new InjectedService(); + await using var services = new ServiceCollection().AddSingleton(expected).BuildServiceProvider(); + var tool = McpServerTool.Create(GetMethod(nameof(EchoService), freshParameters), this, + new() { Services = services, SerializerOptions = WireJsonContext.Default.Options }); + Assert.Equal(["value"], tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray()); + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("service"), Method = RequestMethods.ToolsCall }, + new() + { + Name = tool.ProtocolTool.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }) + { + Services = services, + }; + + await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + Assert.Same(expected, _service); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Tool_CustomSchemaFilterIsPreservedWithoutMutatingOptions(bool freshParameters) + { + var schemaOptions = new AIJsonSchemaCreateOptions { IncludeParameter = parameter => parameter.Name != "value" }; + var filter = schemaOptions.IncludeParameter; + var tool = McpServerTool.Create(GetMethod(nameof(EchoTool), freshParameters), this, new() + { + SerializerOptions = WireJsonContext.Default.Options, + SchemaCreateOptions = schemaOptions, + }); + + Assert.Empty(tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject()); + Assert.Same(filter, schemaOptions.IncludeParameter); + } + + private string EchoTool(string value, RequestContext context) + { + _toolRequest = context; + return value; + } + + private string EchoPrompt(string value, RequestContext context) + { + _promptRequest = context; + return value; + } + + private string EchoService(string value, InjectedService service) + { + _service = service; + return value; + } + + private sealed class InjectedService; + + private MethodInfo GetMethod(string name, bool freshParameters) + { + var method = Assert.IsAssignableFrom(GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)); + return freshParameters ? new ReenumeratingMethodInfo(method) : method; + } + + // ParameterInfo identity is not part of the method signature. Return equivalent but distinct + // instances to deterministically exercise separate binding and schema-enumeration passes. + private sealed class ReenumeratingMethodInfo(MethodInfo method) : MethodInfo + { + public override string Name => method.Name; + public override Type? DeclaringType => method.DeclaringType; + public override Type? ReflectedType => method.ReflectedType; + public override Module Module => method.Module; + public override int MetadataToken => method.MetadataToken; + public override MethodAttributes Attributes => method.Attributes; + public override RuntimeMethodHandle MethodHandle => method.MethodHandle; + public override Type ReturnType => method.ReturnType; + public override ParameterInfo ReturnParameter => method.ReturnParameter; + public override ICustomAttributeProvider ReturnTypeCustomAttributes => method.ReturnTypeCustomAttributes; + public override bool ContainsGenericParameters => method.ContainsGenericParameters; + public override bool IsGenericMethod => method.IsGenericMethod; + public override bool IsGenericMethodDefinition => method.IsGenericMethodDefinition; + public override MethodInfo GetBaseDefinition() => method.GetBaseDefinition(); + public override MethodImplAttributes GetMethodImplementationFlags() => method.GetMethodImplementationFlags(); + public override Type[] GetGenericArguments() => method.GetGenericArguments(); + public override object[] GetCustomAttributes(bool inherit) => method.GetCustomAttributes(inherit); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => method.GetCustomAttributes(attributeType, inherit); + public override IList GetCustomAttributesData() => method.GetCustomAttributesData(); + public override bool IsDefined(Type attributeType, bool inherit) => method.IsDefined(attributeType, inherit); + public override object? Invoke(object? obj, BindingFlags invokeAttr, Binder? binder, object?[]? parameters, CultureInfo? culture) => + method.Invoke(obj, invokeAttr, binder, parameters, culture); + public override ParameterInfo[] GetParameters() => + Array.ConvertAll(method.GetParameters(), parameter => new ReenumeratedParameter(parameter)); + } + + private sealed class ReenumeratedParameter(ParameterInfo parameter) : ParameterInfo + { + public override string? Name => parameter.Name; + public override Type ParameterType => parameter.ParameterType; + public override int Position => parameter.Position; + public override MemberInfo Member => parameter.Member; + public override ParameterAttributes Attributes => parameter.Attributes; + public override object? DefaultValue => parameter.DefaultValue; + public override object? RawDefaultValue => parameter.RawDefaultValue; + public override bool HasDefaultValue => parameter.HasDefaultValue; + public override object[] GetCustomAttributes(bool inherit) => parameter.GetCustomAttributes(inherit); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => parameter.GetCustomAttributes(attributeType, inherit); + public override IList GetCustomAttributesData() => parameter.GetCustomAttributesData(); + public override bool IsDefined(Type attributeType, bool inherit) => parameter.IsDefined(attributeType, inherit); + } + + [JsonSerializable(typeof(string))] + private partial class WireJsonContext : JsonSerializerContext; +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs new file mode 100644 index 000000000..54cf43dab --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs @@ -0,0 +1,74 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillAuthoringBoundaryTests +{ + private const string SkillUri = "skill://owned/SKILL.md"; + + [Theory] + [InlineData("---\nname: &name owned\ndescription: d\n---\n")] + [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] + [InlineData("# No frontmatter\n")] + [InlineData("---\nname: owned\ndescription: d\n")] + public void AutomaticAuthoring_RejectsUnsupportedMalformedOrMissingFrontmatter(string markdown) + { + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create(SkillUri, files)); + + WithDirectory(markdown, directory => + { + Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory)); + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + }); + } + + [Fact] + public void ExplicitMetadata_DoesNotOverrideSupportedFrontmatterEquality() + { + const string Markdown = "---\nname: owned\ndescription: From the file\n---\n"; + var supplied = new JsonObject { ["name"] = "owned", ["description"] = "Not from the file" }; + + var filesException = Assert.Throws(() => McpServerSkill.Create( + SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", Markdown)])); + Assert.Contains("does not match", filesException.Message); + + WithDirectory(Markdown, directory => + { + var directoryException = Assert.Throws(() => + McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); + Assert.Contains("does not match", directoryException.Message); + }); + } + + [Theory] + [InlineData("# No frontmatter\n")] + [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] + public void ExplicitMetadata_DoesNotBypassDetectedBoundaryOrFormatErrors(string markdown) + { + var supplied = new JsonObject { ["name"] = "owned", ["description"] = "d" }; + Assert.Throws(() => McpServerSkill.Create( + SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", markdown)])); + WithDirectory(markdown, directory => + { + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); + }); + } + + private void WithDirectory(string markdown, Action action) + { + var directory = Path.Combine(Path.GetTempPath(), "mcp-skill-ownership-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "SKILL.md"), markdown); + action(directory); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs new file mode 100644 index 000000000..1bb510e26 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -0,0 +1,523 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : manifest computation, resource creation, path handling, validation, and +/// loading from a directory. +/// +public class McpServerSkillTests +{ + private const string SkillUri = "skill://git-workflow/SKILL.md"; + private const string SkillMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; + + private static JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new() + { + ["name"] = name, + ["description"] = description, + }; + + [Fact] + public void Create_ComputesManifestFromFileBytes() + { + byte[] guide = Encoding.UTF8.GetBytes("# Guide\n"); + + var frontmatter = Frontmatter(); + var skill = McpServerSkill.Create(SkillUri, frontmatter, + [ + new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + ]); + + var entry = skill.ProtocolSkill; + Assert.Equal(SkillUri, entry.Uri); + Assert.True(JsonNode.DeepEquals(frontmatter, entry.Frontmatter)); + Assert.NotSame(frontmatter, entry.Frontmatter); + Assert.False(entry.Resources.IsDynamic); + + var manifest = entry.Resources.Resources!; + Assert.Equal(2, manifest.Count); + + // SKILL.md is always first, regardless of input order. + Assert.Equal(SkillUri, manifest[0].Uri); + Assert.Equal(Encoding.UTF8.GetByteCount(SkillMarkdown), manifest[0].Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), manifest[0].Digest); + + Assert.Equal("skill://git-workflow/references/GUIDE.md", manifest[1].Uri); + Assert.Equal(guide.Length, manifest[1].Size); + Assert.Equal(SkillVerifier.ComputeDigest(guide), manifest[1].Digest); + } + + [Fact] + public void Create_ProducesOneResourcePerFileWithSpecMetadata() + { + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("scripts/run.py", "print('hi')\n"), + McpServerSkillFile.FromText("data/table.csv", "a,b\n", mimeType: "text/x-custom"), + ]); + + Assert.Equal(3, skill.Resources.Count); + + var skillFile = skill.Resources[0].ProtocolResource; + Assert.NotNull(skillFile); + Assert.Equal(SkillUri, skillFile.Uri); + Assert.Equal("git-workflow", skillFile.Name); + Assert.Equal("Git conventions", skillFile.Description); + Assert.Equal("text/markdown", skillFile.MimeType); + + var script = skill.Resources[2].ProtocolResource; + Assert.NotNull(script); + Assert.Equal("skill://git-workflow/scripts/run.py", script.Uri); + Assert.Equal("scripts/run.py", script.Name); + Assert.Equal("text/x-python", script.MimeType); + + var table = skill.Resources[1].ProtocolResource; + Assert.NotNull(table); + Assert.Equal("text/x-custom", table.MimeType); + } + + [Theory] + [InlineData("./SKILL.md", "SKILL.md")] + [InlineData("references\\GUIDE.md", "references/GUIDE.md")] + public void Create_NormalizesFilePaths(string input, string expectedRelative) + { + var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") }; + if (expectedRelative != "SKILL.md") + { + files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)); + } + + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), files); + + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, r => r.Uri == "skill://git-workflow/" + expectedRelative); + } + + [Theory] + [InlineData("")] + [InlineData("/SKILL.md")] + [InlineData("references/")] + [InlineData("../SKILL.md")] + [InlineData("a/./b.md")] + [InlineData("a//b.md")] + public void Create_RejectsUnsafePaths(string path) + { + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText(path, "x"), + ])); + } + + [Fact] + public void Create_RejectsMissingSkillFile() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("README.md", "x")])); + + Assert.Contains("SKILL.md", exception.Message); + } + + [Fact] + public void Create_RejectsDuplicatePaths() + { + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("./SKILL.md", SkillMarkdown), + ])); + } + + [Fact] + public void Create_RejectsUriNotEndingInSkillFile() + { + Assert.Throws(() => + McpServerSkill.Create("skill://git-workflow", Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + } + + [Fact] + public void Create_RejectsFrontmatterNameMismatch() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(name: "git-flow"), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Equal("frontmatter", exception.ParamName); + Assert.Contains("git-flow", exception.Message); + } + + [Fact] + public void Create_RejectsMissingDescription() + { + Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(description: null), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + } + + [Fact] + public void Create_ReadsFrontmatterFromSkillFile() + { + var skill = McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + + Assert.True(JsonNode.DeepEquals(Frontmatter(), skill.ProtocolSkill.Frontmatter)); + } + + [Fact] + public void Create_DerivesUriFromFrontmatterName() + { + var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), McpServerSkillFile.FromText("a.md", "x")]); + + Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); + Assert.Equal("skill://git-workflow/a.md", skill.ProtocolSkill.Resources.Resources![1].Uri); + } + + [Fact] + public void Create_RejectsUriWhoseNameDiffersFromTheFile() + { + var exception = Assert.Throws(() => + McpServerSkill.Create("skill://other-name/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Contains("git-workflow", exception.Message); + } + + [Fact] + public void Create_WithUnreadableFrontmatter_ExplainsAndPointsAtTheExplicitOverload() + { + var exception = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: &a git-workflow\ndescription: d\n---\n")])); + + Assert.Contains("anchors", exception.Message); + Assert.Contains("JsonObject", exception.Message); + + var noFrontmatter = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "# No frontmatter\n")])); + Assert.Contains("must begin", noFrontmatter.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsMismatchWithTheFile() + { + var mismatched = Frontmatter(); + mismatched["license"] = "MIT"; + + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, mismatched, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Equal("frontmatter", exception.ParamName); + Assert.Contains("does not match", exception.Message); + Assert.Contains("\"license\"", exception.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() + { + // Matches the file: fine. + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + + // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object. + McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]); + + // The caller supplies the complete, prevalidated frontmatter for this unsupported YAML form. + var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), + [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]); + Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name); + + // The caller has also validated this complete multi-line quoted value. + var multiLine = McpServerSkill.Create(SkillUri, Frontmatter(), + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")]); + Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description); + + // So is a flow collection that closes on a later line. + var explicitTools = Frontmatter(); + explicitTools["allowed-tools"] = new JsonArray("Read", "Write"); + var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTools, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\nallowed-tools: [Read,\n Write]\n---\n")]); + Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["allowed-tools"]!.AsArray().Count); + } + + [Theory] + [InlineData("# no frontmatter block\n", "must begin")] + [InlineData("---\nname: git-workflow\n", "not closed")] + [InlineData("---\nname: a: b\n---\n", "cannot contain")] + [InlineData("---\n\tname: x\n---\n", "tabs")] + [InlineData("---\nname: git-workflow\ndescription: \"never closed\n---\n", "unterminated")] + [InlineData("---\nname: git-workflow\ndescription: d\nallowed-tools: [Read, Write\n---\n", "unterminated flow")] + [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")] + public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) + { + // These errors are detected by the bounded reader; explicit metadata does not bypass them. + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", markdown)])); + + Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsNonUtf8SkillFile() + { + var exception = Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [new McpServerSkillFile { Path = "SKILL.md", Content = new byte[] { 0x2D, 0x2D, 0x2D, 0x0A, 0xFF, 0xFE } }])); + + Assert.Contains("UTF-8", exception.Message); + } + + [Fact] + public void Create_RejectsNullArguments() + { + Assert.Throws(() => McpServerSkill.Create(null!, Frontmatter(), [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, null!, [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), null!)); + Assert.Throws(() => McpServerSkill.Create((IEnumerable)null!)); + Assert.Throws(() => McpServerSkill.Create((string)null!, [])); + Assert.Throws(() => McpServerSkill.CreateFromDirectory((string)null!)); + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, (string)null!)); + } + + [Fact] + public void CreateFromDirectory_LoadsFilesRecursively() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(directory, "templates", "regional")); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(directory, "templates", "invoice.md"), "# Invoice\n"); + File.WriteAllBytes(Path.Combine(directory, "templates", "regional", "logo.png"), [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE]); + + // All three overloads agree. + var skill = McpServerSkill.CreateFromDirectory(directory); + Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Uri); + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory).ProtocolSkill.Uri); + + var uris = skill.ProtocolSkill.Resources.Resources!.Select(r => r.Uri).ToList(); + Assert.Equal( + [ + "skill://git-workflow/SKILL.md", + "skill://git-workflow/templates/invoice.md", + "skill://git-workflow/templates/regional/logo.png", + ], uris); + + Assert.Equal("image/png", skill.Resources[2].ProtocolResource!.MimeType); + Assert.Equal(6, skill.ProtocolSkill.Resources.Resources![2].Size); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void Create_EscapesUriSyntaxCharactersInFilePaths() + { + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("templates/{name} v2.md", "template"), + McpServerSkillFile.FromText("notes/a#b?c.md", "note"), + ]); + + var manifest = skill.ProtocolSkill.Resources.Resources!; + Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri); + Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri); + + // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI. + for (int i = 0; i < manifest.Count; i++) + { + Assert.False(skill.Resources[i].IsTemplated); + Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri); + } + + Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name); + } + + [Fact] + public void Create_KeepsItsOwnCopyOfTheEntry() + { + var frontmatter = Frontmatter(); + var skill = McpServerSkill.Create(SkillUri, frontmatter, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + var manifestEntry = skill.ProtocolSkill.Resources.Resources![0]; + + // The caller goes on to edit the frontmatter object it passed in, and an entry it was handed back. + frontmatter["description"] = "changed"; + var returned = skill.ProtocolSkill; + returned.Frontmatter["name"] = "changed"; + returned.Resources.Resources![0].Digest = "sha256:" + new string('0', 64); + returned.Resources = SkillResources.Dynamic; + + // None of that reaches the skill, so what WithSkills registers is what Create computed. (That the served + // bytes are likewise a copy of the caller's buffer is checked end to end in McpServerSkillsIntegrityTests.) + var fresh = skill.ProtocolSkill; + Assert.Equal("git-workflow", fresh.Name); + Assert.Equal("Git conventions", fresh.Description); + Assert.Equal(manifestEntry.Digest, fresh.Resources.Resources![0].Digest); + Assert.NotSame(returned, fresh); + } + + [Fact] + public void Create_RequiresAStringNameToDeriveTheUri() + { + var exception = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: 123\ndescription: d\n---\n")])); + + Assert.Contains("must declare a string 'name'", exception.Message); + } + + [Fact] + public void Create_RejectsNullFileEntries() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), null!])); + + Assert.Contains("null entries", exception.Message); + Assert.Equal("files", exception.ParamName); + } + +#if NET + [Fact] + public void CreateFromDirectory_RejectsSymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside.txt"), "private data"); + + try + { + File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); + Assert.Contains("linked.txt", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsASymbolicLinkAsTheRoot() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-rootlink-" + Guid.NewGuid().ToString("N")); + try + { + string target = Path.Combine(root, "target"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "SKILL.md"), SkillMarkdown); + + string link = Path.Combine(root, "link"); + try + { + Directory.CreateSymbolicLink(link, target); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(link)); + Assert.Equal("directoryPath", exception.ParamName); + + // The target itself is fine. + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(target).ProtocolSkill.Uri); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsDirectorySymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-dirlink-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + Directory.CreateDirectory(Path.Combine(root, "outside")); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside", "secret.txt"), "private data"); + + try + { + Directory.CreateSymbolicLink(Path.Combine(skillDirectory, "linked"), Path.Combine(root, "outside")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +#endif + + [Fact] + public void CreateFromDirectory_RejectsTooManyFilesBeforeReadingThem() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-many-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + for (int i = 0; i < SkillsProtocol.MaxResourcesPerSkill; i++) + { + File.WriteAllText(Path.Combine(directory, $"f{i:D4}.txt"), "x"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + Assert.Contains(SkillsProtocol.MaxResourcesPerSkill.ToString(), exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsOversizedContentBeforeReadingIt() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-big-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + using (var big = new FileStream(Path.Combine(directory, "big.bin"), FileMode.CreateNew)) + { + // A sparse file: the size check must trip without the bytes ever being read. + big.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + Assert.Contains(SkillsProtocol.MaxTotalSizeBytes.ToString(), exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_WithMissingDirectory_Throws() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-missing-" + Guid.NewGuid().ToString("N")); + + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs new file mode 100644 index 000000000..deba3df28 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -0,0 +1,321 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for skills served through a custom : pagination, skills that +/// are served but not listed, dynamic skills, the error contract, and the protocol-version gating of +/// resultType, ttlMs, and cacheScope (issue #1721). +/// +public class McpServerSkillsCatalogTests : ClientServerTestBase +{ + private const string TamperedUri = "skill://tampered/SKILL.md"; + private const string UnlistedUri = "skill://hidden/SKILL.md"; + private const string DynamicUri = "skill://generated/SKILL.md"; + private const string BrokenUri = "skill://broken/SKILL.md"; + private const string SwappedUri = "skill://swapped/SKILL.md"; + + public McpServerSkillsCatalogTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var listed = new[] { "alpha", "beta", "gamma" }.Select(name => CreateEntry($"skill://{name}/SKILL.md", name)).ToList(); + + // The manifest declares one content, the resource serves another. + var tampered = CreateEntry(TamperedUri, "tampered", Encoding.UTF8.GetBytes("the content the host approved")); + + // Served and answerable through skills/get, but withheld from skills/list. + var unlisted = CreateEntry(UnlistedUri, "hidden"); + + var dynamic = new Skill + { + Uri = DynamicUri, + Frontmatter = new JsonObject { ["name"] = "generated", ["description"] = "Assembled from live data" }, + Resources = SkillResources.Dynamic, + }; + + // A catalog bug: an entry whose manifest omits its own SKILL.md. The handler must not publish it. + var broken = new Skill + { + Uri = BrokenUri, + Frontmatter = new JsonObject { ["name"] = "broken", ["description"] = "d" }, + Resources = SkillResources.FromResources([new SkillResource { Uri = "skill://broken/other.md", Digest = "sha256:" + new string('a', 64), Size = 1 }]), + }; + + var catalog = new PartialCatalog( + listed: new InMemoryMcpSkillCatalog([.. listed, tampered, dynamic], pageSize: 2), + unlisted: unlisted, + broken: broken); + + mcpServerBuilder + .WithSkills(catalog, options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }) + .WithResources([McpServerResource.Create(() => "the content the server actually serves", new() { UriTemplate = TamperedUri })]); + } + + private static Skill CreateEntry(string uri, string name, byte[]? content = null) + { + content ??= Encoding.UTF8.GetBytes($"---\nname: {name}\n---\n"); + return new Skill + { + Uri = uri, + Frontmatter = new JsonObject { ["name"] = name, ["description"] = $"The {name} skill" }, + Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }]), + }; + } + + [Fact] + public async Task ListSkillsAsync_FollowsPaginationToTheEnd() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(5, skills.Count); + Assert.Equal(skills.Select(s => s.Uri).OrderBy(u => u, StringComparer.Ordinal), skills.Select(s => s.Uri)); + Assert.DoesNotContain(skills, s => s.Uri == UnlistedUri); + } + + [Fact] + public async Task ListSkillsAsync_PerPage_ExposesCursorAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer(); + + var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(2, first.Skills.Count); + Assert.NotNull(first.NextCursor); + Assert.Equal(TimeSpan.FromMinutes(5), first.TimeToLive); + Assert.Equal(CacheScope.Public, first.CacheScope); + + var second = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = first.NextCursor }, TestContext.Current.CancellationToken); + Assert.Equal(2, second.Skills.Count); + Assert.NotEqual(first.Skills[0].Uri, second.Skills[0].Uri); + } + + [Fact] + public async Task GetSkillAsync_AnswersForSkillAbsentFromListing() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skill = await client.GetSkillAsync(UnlistedUri, TestContext.Current.CancellationToken); + + Assert.Equal("hidden", skill.Name); + } + + [Fact] + public async Task SkillsGet_WithCatalogAnsweringForADifferentUri_ReturnsInternalError() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SwappedUri, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Contains(SwappedUri, exception.Message); + } + + [Fact] + public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(BrokenUri, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Contains("invalid entry", exception.Message); + } + + [Fact] + public async Task SkillsList_WithInvalidCustomCatalogEntry_ReturnsInternalError() + { + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync( + new ListSkillsRequestParams { Cursor = "broken-entry" }, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Contains("invalid entry", error.Message); + } + + [Fact] + public async Task DynamicSkill_IsListedWithTheDynamicMarker() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var dynamic = Assert.Single(skills, s => s.Uri == DynamicUri); + + Assert.True(dynamic.Resources.IsDynamic); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(dynamic, DynamicUri, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsContentThatDoesNotMatchTheManifest() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(TamperedUri, TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, TamperedUri, TestContext.Current.CancellationToken)); + + Assert.Contains(TamperedUri, exception.Message); + + // The unverified read itself still works through the base API; only the verified path refuses it. + var raw = await client.ReadResourceAsync(TamperedUri, cancellationToken: TestContext.Current.CancellationToken); + Assert.Single(raw.Contents); + } + + [Theory] + [InlineData("""{ "uri": 42 }""")] + [InlineData("""{ }""")] + [InlineData("""{ "uri": "" }""")] + public async Task SkillsGet_WithInvalidParams_ReturnsInvalidParams(string paramsJson) + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = JsonNode.Parse(paramsJson) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Theory] + [InlineData("""{ "cursor": "not-base64!!" }""")] + [InlineData("""{ "cursor": 42 }""")] + public async Task SkillsList_WithInvalidCursor_ReturnsInvalidParams(string paramsJson) + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList, Params = JsonNode.Parse(paramsJson) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SkillsList_WithoutParams_Succeeds() + { + await using McpClient client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken); + + Assert.Equal(2, response.Result!["skills"]!.AsArray().Count); + } + + [Fact] + public async Task SkillsMethods_On2026_07_28Session_IncludeResultTypeAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var list = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("complete", list["resultType"]?.GetValue()); + Assert.Equal(300_000, list["ttlMs"]?.GetValue()); + Assert.Equal("public", list["cacheScope"]?.GetValue()); + + var get = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("complete", get["resultType"]?.GetValue()); + Assert.False(get.ContainsKey("nextCursor")); + Assert.Equal(300_000, get["ttlMs"]?.GetValue()); + Assert.Equal("public", get["cacheScope"]?.GetValue()); + + var typed = await client.GetSkillAsync(new GetSkillRequestParams { Uri = UnlistedUri }, TestContext.Current.CancellationToken); + var cacheable = Assert.IsAssignableFrom(typed); + Assert.Equal(TimeSpan.FromMinutes(5), cacheable.TimeToLive); + Assert.Equal(CacheScope.Public, cacheable.CacheScope); + } + + [Fact] + public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var list = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.False(list.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/list result."); + Assert.False(list.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/list result."); + Assert.False(list.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/list result."); + Assert.Equal(2, list["skills"]!.AsArray().Count); + + var get = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.False(get.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/get result."); + Assert.False(get.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/get result."); + Assert.False(get.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/get result."); + Assert.Equal(UnlistedUri, get["skill"]!["uri"]!.GetValue()); + } + + /// + /// A catalog that lists some skills and serves one more by URI only. + /// + private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted, Skill broken) : IMcpSkillCatalog + { + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + AssertContext(context, SkillsProtocol.MethodSkillsList); + if (string.Equals(cursor, "broken-entry", StringComparison.Ordinal)) + { + return new ValueTask(new McpSkillPage { Skills = [broken] }); + } + return listed.ListAsync(cursor, context, cancellationToken); + } + + public async ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) + { + AssertContext(context, SkillsProtocol.MethodSkillsGet); + if (string.Equals(uri, unlisted.Uri, StringComparison.Ordinal)) + { + return unlisted; + } + + if (string.Equals(uri, broken.Uri, StringComparison.Ordinal)) + { + return broken; + } + + if (string.Equals(uri, SwappedUri, StringComparison.Ordinal)) + { + // A catalog bug: a valid entry, for the wrong skill. + return await listed.GetAsync("skill://alpha/SKILL.md", context, cancellationToken); + } + + return await listed.GetAsync(uri, context, cancellationToken); + } + + // The catalog receives the request it is answering, so a per-caller catalog can decide from it. + private static void AssertContext(McpSkillRequestContext context, string method) + { + Assert.Equal(method, context.JsonRpcRequest.Method); + Assert.Null(context.User); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs new file mode 100644 index 000000000..22211794c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs @@ -0,0 +1,101 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that a server configured with WithSkills and no options still satisfies the 2026-07-28 +/// requirement that skills/list and skills/get carry ttlMs and cacheScope, using the same conservative +/// defaults the SDK applies to the built-in list methods. +/// +public class McpServerSkillsDefaultsTests : ClientServerTestBase +{ + public McpServerSkillsDefaultsTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithSkills( + [ + McpServerSkill.Create( + "skill://alpha/SKILL.md", + new JsonObject { ["name"] = "alpha", ["description"] = "d" }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: d\n---\n")]), + ]); + } + + [Fact] + public async Task SkillsList_WithDefaultOptions_On2026_07_28_CarriesConservativeCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + var result = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + + Assert.Equal("complete", result["resultType"]?.GetValue()); + Assert.Equal(0, result["ttlMs"]?.GetValue()); + Assert.Equal("private", result["cacheScope"]?.GetValue()); + + var typed = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(TimeSpan.Zero, typed.TimeToLive); + Assert.Equal(CacheScope.Private, typed.CacheScope); + } + + [Theory] + [InlineData(McpProtocolVersions.July2026ProtocolVersion, true)] + [InlineData(McpProtocolVersions.November2025ProtocolVersion, false)] + public async Task SkillsGet_WithDefaultOptions_GatesCacheHintsByProtocolVersion(string protocolVersion, bool includesCacheHints) + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion }); + var result = (await client.SendRequestAsync( + new JsonRpcRequest + { + Method = SkillsProtocol.MethodSkillsGet, + Params = new JsonObject { ["uri"] = "skill://alpha/SKILL.md" }, + }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + var typed = await client.GetSkillAsync( + new GetSkillRequestParams { Uri = "skill://alpha/SKILL.md" }, TestContext.Current.CancellationToken); + var cacheable = Assert.IsAssignableFrom(typed); + + Assert.Equal("skill://alpha/SKILL.md", result["skill"]!["uri"]!.GetValue()); + Assert.False(result.ContainsKey("nextCursor")); + if (includesCacheHints) + { + Assert.Equal("complete", result["resultType"]?.GetValue()); + Assert.Equal(0, result["ttlMs"]?.GetValue()); + Assert.Equal("private", result["cacheScope"]?.GetValue()); + Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive); + Assert.Equal(CacheScope.Private, cacheable.CacheScope); + } + else + { + Assert.False(result.ContainsKey("resultType")); + Assert.False(result.ContainsKey("ttlMs")); + Assert.False(result.ContainsKey("cacheScope")); + Assert.Null(typed.ResultType); + Assert.Null(cacheable.TimeToLive); + Assert.Null(cacheable.CacheScope); + } + } + + [Fact] + public async Task ClientExtensions_ThrowWhenServerDoesNotDeclareSkills() + { + // A client connected to this server sees the extension; fake its absence to exercise the guard. + await using McpClient client = await CreateMcpClientForServer(); + client.ServerCapabilities.Extensions!.Remove(SkillsProtocol.ExtensionId); + + Assert.False(client.SupportsSkills()); + await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetSkillAsync("skill://alpha/SKILL.md", TestContext.Current.CancellationToken)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs new file mode 100644 index 000000000..9fb27bbe3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -0,0 +1,230 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for WithSkillsFromDirectory: a directory of skill folders becomes a served catalog with +/// frontmatter read from each SKILL.md, URIs derived from the frontmatter names under the given prefix, and +/// non-skill subdirectories ignored. +/// +public class McpServerSkillsFromDirectoryTests : ClientServerTestBase +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-skills-root-" + Guid.NewGuid().ToString("N")); + + public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + Directory.CreateDirectory(Path.Combine(_root, "git-workflow", "references")); + File.WriteAllText(Path.Combine(_root, "git-workflow", "SKILL.md"), "---\nname: git-workflow\ndescription: Git conventions\nlicense: MIT\n---\n\n# Git\n"); + File.WriteAllText(Path.Combine(_root, "git-workflow", "references", "STYLE.md"), "# Style\n"); + File.WriteAllText(Path.Combine(_root, "git-workflow", ".env"), "synthetic-private-value"); + Directory.CreateDirectory(Path.Combine(_root, "git-workflow", ".git")); + File.WriteAllText(Path.Combine(_root, "git-workflow", ".git", "config"), "synthetic-config"); + + Directory.CreateDirectory(Path.Combine(_root, "refunds")); + File.WriteAllText(Path.Combine(_root, "refunds", "SKILL.md"), "---\nname: refunds\ndescription: >\n Process refunds\n per policy.\nmetadata:\n version: \"2.1.0\"\n---\n"); + + // Not a skill: no SKILL.md. Must be ignored. + Directory.CreateDirectory(Path.Combine(_root, "shared")); + File.WriteAllText(Path.Combine(_root, "shared", "README.md"), "not a skill"); + + // A file at the root is ignored too. + File.WriteAllText(Path.Combine(_root, "README.md"), "about these skills"); + Directory.CreateDirectory(Path.Combine(_root, ".private-skill")); + File.WriteAllText(Path.Combine(_root, ".private-skill", "SKILL.md"), "---\nname: private-skill\ndescription: d\n---\n"); + + McpServerBuilder.WithSkillsFromDirectory(_root, uriPrefix: "skill://acme/billing"); + StartServer(); + } + + [Fact] + public async Task ServesEverySkillDirectory_WithFrontmatterFromTheFile() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, skills.Count); + + var gitWorkflow = Assert.Single(skills, s => s.Uri == "skill://acme/billing/git-workflow/SKILL.md"); + Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); + Assert.Equal(2, gitWorkflow.Resources.Resources!.Count); + Assert.Contains(gitWorkflow.Resources.Resources, r => r.Uri == "skill://acme/billing/git-workflow/references/STYLE.md"); + + var refunds = Assert.Single(skills, s => s.Uri == "skill://acme/billing/refunds/SKILL.md"); + Assert.Equal("Process refunds per policy.\n", refunds.Description); + Assert.Equal("2.1.0", refunds.Frontmatter["metadata"]?["version"]?.GetValue()); + } + + [Fact] + public async Task ServedFiles_VerifyAgainstTheirManifest() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync("skill://acme/billing/git-workflow/SKILL.md", TestContext.Current.CancellationToken); + + foreach (var file in skill.Resources.Resources!) + { + var result = await client.ReadSkillResourceAsync(skill, file.Uri, TestContext.Current.CancellationToken); + Assert.Single(result.Contents); + } + } + + [Fact] + public async Task HiddenEntriesAreNeitherListedNorReadable() + { + await using var client = await CreateMcpClientForServer(); + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(skills, skill => skill.Name == "private-skill"); + Assert.DoesNotContain(skills.SelectMany(skill => skill.Resources.Resources!), resource => resource.Uri.Contains("/.")); + + await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( + "skill://acme/billing/git-workflow/.env", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public void BuilderCanIncludeHiddenEntriesExplicitly() + { + var services = new ServiceCollection(); + services.AddMcpServer().WithSkillsFromDirectory(_root, includeHiddenAndSystemFiles: true); + using var provider = services.BuildServiceProvider(); + var resources = provider.GetServices().Select(resource => resource.ProtocolResource!.Uri).ToList(); + + Assert.Contains("skill://private-skill/SKILL.md", resources); + Assert.Contains("skill://git-workflow/.env", resources); + Assert.Contains("skill://git-workflow/.git/config", resources); + } + + [Fact] + public void WithSkills_DetectsFilesThatCollideUnderUriEquivalence() + { + // The resource collection compares URIs case-insensitively in scheme and authority, so these two skills + // would silently share one registered resource. With different content that must be an error. + var upper = McpServerSkill.Create("skill://Acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + var lower = McpServerSkill.Create("skill://acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: lower\n---\n")]); + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, lower])); + Assert.Contains("equivalent", exception.Message); + + // Identical content does not help: the collection would register one resource whose contents carry the + // first URI, so a verified read of the alias would fail. Only the identical URI text may be shared. + var same = McpServerSkill.Create("skill://ACME/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, same])); + + // The legitimate shared-file case: a nested skill's SKILL.md is also a file of the enclosing skill, under + // the identical URI text and with identical content. That registers once and is fine. + const string InnerMarkdown = "---\nname: inner\ndescription: nested\n---\n"; + var outer = McpServerSkill.Create("skill://acme/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: acme\ndescription: outer\n---\n"), + McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), + ]); + var inner = McpServerSkill.Create("skill://acme/inner/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)]); + new ServiceCollection().AddMcpServer().WithSkills([outer, inner]); + } + + [Fact] + public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() + { + string empty = Path.Combine(Path.GetTempPath(), "mcp-skills-empty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(empty, "not-a-skill")); + try + { + var services = new ServiceCollection(); + var exception = Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty)); + Assert.Contains("No skill directories", exception.Message); + + Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(Path.Combine(empty, "missing"))); + Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty, uriPrefix: "")); + } + finally + { + Directory.Delete(empty, recursive: true); + } + } + + [Fact] + public void WithSkillsFromDirectory_RejectsAnInvalidSkillRatherThanSkippingIt() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-invalid-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(root, "good")); + File.WriteAllText(Path.Combine(root, "good", "SKILL.md"), "---\nname: good\ndescription: d\n---\n"); + Directory.CreateDirectory(Path.Combine(root, "broken")); + File.WriteAllText(Path.Combine(root, "broken", "SKILL.md"), "---\nname: broken\n---\n"); + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); + Assert.Contains("description", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void WithSkillsFromDirectory_RejectsTwoDirectoriesDeclaringTheSameName() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-samename-" + Guid.NewGuid().ToString("N")); + try + { + // The URI is derived from the frontmatter name, not the directory name, so these collide. + foreach (string directory in new[] { "first", "second" }) + { + Directory.CreateDirectory(Path.Combine(root, directory)); + File.WriteAllText(Path.Combine(root, directory, "SKILL.md"), "---\nname: same\ndescription: d\n---\n"); + } + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); + Assert.Contains("skill://same/SKILL.md", exception.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + +#if NET + [Fact] + public void WithSkillsFromDirectory_RejectsSymbolicLinkSkillDirectories() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-linkroot-" + Guid.NewGuid().ToString("N")); + try + { + string skills = Path.Combine(root, "skills"); + string outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(skills); + Directory.CreateDirectory(outside); + File.WriteAllText(Path.Combine(outside, "SKILL.md"), "---\nname: alpha\ndescription: d\n---\n"); + File.WriteAllText(Path.Combine(outside, "private.txt"), "private data"); + + try + { + Directory.CreateSymbolicLink(Path.Combine(skills, "alpha"), outside); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(skills)); + Assert.Contains("symbolic link", exception.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +#endif + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(_root, recursive: true); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs new file mode 100644 index 000000000..05902deb0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end regression tests for two integrity properties of the Skills extension: the bytes a +/// serves are the bytes its manifest describes, even if the caller mutates its +/// buffer afterwards; and a verified read is bound to the file that was requested, not merely to files of the +/// same skill. +/// +public class McpServerSkillsIntegrityTests : ClientServerTestBase +{ + private const string SnapshotUri = "skill://snapshot/SKILL.md"; + private const string SwapUri = "skill://swap/SKILL.md"; + private const string SwapOtherUri = "skill://swap/other.md"; + private const string OriginalMarkdown = "---\nname: snapshot\ndescription: d\n---\noriginal\n"; + + public McpServerSkillsIntegrityTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // A fresh buffer per fixture instance: ConfigureServices runs once per test, and the mutation below must + // not leak into the next test's skill. + byte[] callerOwnedBuffer = Encoding.UTF8.GetBytes(OriginalMarkdown); + var snapshot = McpServerSkill.Create( + SnapshotUri, + new JsonObject { ["name"] = "snapshot", ["description"] = "d" }, + [new McpServerSkillFile { Path = "SKILL.md", Content = callerOwnedBuffer }]); + + // Mutate the caller's buffer after the skill was created. The served content must not change. + callerOwnedBuffer[callerOwnedBuffer.Length - 2] = (byte)'X'; + + // A misbehaving server: the entry for "swap" is correct, but a read of its SKILL.md is answered with the + // skill's other file, correctly digested and correctly labelled with that other file's URI. + const string SwapMarkdown = "---\nname: swap\ndescription: d\n---\n"; + const string SwapOther = "other content"; + var swapEntry = new Skill + { + Uri = SwapUri, + Frontmatter = new JsonObject { ["name"] = "swap", ["description"] = "d" }, + Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = SwapUri, Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SwapMarkdown)), Size = SwapMarkdown.Length }, + new SkillResource { Uri = SwapOtherUri, Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SwapOther)), Size = SwapOther.Length }, + ]), + }; + + mcpServerBuilder + .WithSkills(new InMemoryMcpSkillCatalog([snapshot.ProtocolSkill, swapEntry])) + .WithResources(snapshot.Resources) + .WithResources( + [ + McpServerResource.Create( + () => new TextResourceContents { Uri = SwapOtherUri, MimeType = "text/markdown", Text = SwapOther }, + new McpServerResourceCreateOptions { UriTemplate = SwapUri }), + McpServerResource.Create( + () => new TextResourceContents { Uri = SwapOtherUri, MimeType = "text/markdown", Text = SwapOther }, + new McpServerResourceCreateOptions { UriTemplate = SwapOtherUri }), + ]); + } + + [Fact] + public async Task ServedContent_IsTheContentTheManifestDescribes_EvenAfterCallerMutatesItsBuffer() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(SnapshotUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, SnapshotUri, TestContext.Current.CancellationToken); + + Assert.Equal(OriginalMarkdown, Assert.IsType(Assert.Single(result.Contents)).Text); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsAResponseContainingOnlyADifferentFileOfTheSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(SwapUri, TestContext.Current.CancellationToken); + + // Sanity check: the substituted response is for a listed, correctly digested file. + var raw = await client.ReadResourceAsync(SwapUri, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(SwapOtherUri, Assert.Single(raw.Contents).Uri); + + var exception = await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, SwapUri, TestContext.Current.CancellationToken)); + Assert.Contains(SwapUri, exception.Message); + + // Reading the other file directly still verifies. + await client.ReadSkillResourceAsync(skill, SwapOtherUri, TestContext.Current.CancellationToken); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs new file mode 100644 index 000000000..847f22bc8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillsOwnershipTests : ClientServerTestBase +{ + private const string CatalogUri = "skill://catalog/SKILL.md"; + private const string CatalogMarkdown = "---\nname: catalog\ndescription: From the file\n---\n# Instructions\n"; + private int _catalogReadCount; + + public McpServerSkillsOwnershipTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var files = CreatePrevalidatedSkill("provided-files", fromDirectory: false); + var directory = CreatePrevalidatedSkill("provided-directory", fromDirectory: true); + var catalog = new Skill + { + Uri = CatalogUri, + Frontmatter = new JsonObject { ["name"] = "catalog", ["description"] = "Advertised metadata" }, + Resources = SkillResources.FromResources( + [ + new SkillResource + { + Uri = CatalogUri, + Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(CatalogMarkdown)), + Size = Encoding.UTF8.GetByteCount(CatalogMarkdown), + }, + ]), + }; + + mcpServerBuilder + .WithSkills(new InMemoryMcpSkillCatalog([files.ProtocolSkill, directory.ProtocolSkill, catalog])) + .WithResources(files.Resources) + .WithResources(directory.Resources) + .WithResources( + [ + McpServerResource.Create(() => + { + Interlocked.Increment(ref _catalogReadCount); + return CatalogMarkdown; + }, new McpServerResourceCreateOptions { UriTemplate = CatalogUri, MimeType = "text/markdown" }), + ]); + } + + [Fact] + public async Task CatalogListAndGet_TransportStructuredMetadataWithoutReadingFiles() + { + await using var client = await CreateMcpClientForServer(); + + var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var retrieved = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); + + Assert.Equal("Advertised metadata", Assert.Single(listed, skill => skill.Uri == CatalogUri).Description); + Assert.Equal("Advertised metadata", retrieved.Description); + Assert.Equal(0, _catalogReadCount); + } + + [Theory] + [InlineData("provided-files")] + [InlineData("provided-directory")] + public async Task ExplicitAuthoring_PreservesPrevalidatedUnsupportedMetadataAndBytes(string name) + { + await using var client = await CreateMcpClientForServer(); + var uri = $"skill://{name}/SKILL.md"; + var entry = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var contents = await client.ReadSkillResourceAsync(entry, uri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(contents.Contents)).Text; + + Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter(name), entry.Frontmatter)); + Assert.Equal(UnsupportedMarkdown(name), text); + Assert.Equal(Encoding.UTF8.GetByteCount(text), Assert.Single(entry.Resources.Resources!).Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(text)), Assert.Single(entry.Resources.Resources!).Digest); + } + + [Fact] + public async Task DigestVerifiedRead_DoesNotEstablishFrontmatterEquality() + { + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, CatalogUri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(result.Contents)).Text; + + Assert.Equal(CatalogMarkdown, text); + Assert.Equal(1, _catalogReadCount); + Assert.Throws(() => VerifyHostFrontmatter(entry, text)); + } + + [Fact] + public async Task HostRejectsUnsupportedFetchedYaml_InsteadOfTrustingServerJson() + { + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync("skill://provided-files/SKILL.md", TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, entry.Uri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(result.Contents)).Text; + + Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter("provided-files"), entry.Frontmatter)); + var exception = Assert.Throws(() => VerifyHostFrontmatter(entry, text)); + Assert.IsAssignableFrom(exception.InnerException); + } + + private McpServerSkill CreatePrevalidatedSkill(string name, bool fromDirectory) + { + var markdown = UnsupportedMarkdown(name); + var bytes = Encoding.UTF8.GetBytes(markdown); + var supplied = PrevalidatedFrontmatter(name); + var uri = $"skill://{name}/SKILL.md"; + if (fromDirectory) + { + var path = Path.Combine(Path.GetTempPath(), "mcp-skill-prevalidated-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + try + { + File.WriteAllBytes(Path.Combine(path, "SKILL.md"), bytes); + var skill = McpServerSkill.CreateFromDirectory(uri, supplied, path); + File.WriteAllText(Path.Combine(path, "SKILL.md"), "changed after creation"); + supplied["description"] = "changed after creation"; + return skill; + } + finally + { + Directory.Delete(path, recursive: true); + } + } + + var result = McpServerSkill.Create(uri, supplied, [new McpServerSkillFile { Path = "SKILL.md", Content = bytes }]); + bytes[bytes.Length - 2] = (byte)'X'; + supplied["description"] = "changed after creation"; + return result; + } + + private string UnsupportedMarkdown(string name) => + $"---\nname: &name {name}\ndescription: Provided\nmetadata:\n owner: &owner team\n reviewer: *owner\n---\n# Instructions\n"; + + private JsonObject PrevalidatedFrontmatter(string name) => new() + { + ["name"] = name, + ["description"] = "Provided", + ["metadata"] = new JsonObject { ["owner"] = "team", ["reviewer"] = "team" }, + }; + + private void VerifyHostFrontmatter(Skill entry, string markdown) + { + try + { + var parsed = SkillFrontmatter.Parse(markdown); + if (!JsonNode.DeepEquals(parsed, entry.Frontmatter)) + { + throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); + } + } + catch (FormatException exception) + { + throw new SkillVerificationException("The fetched frontmatter could not be verified.", exception); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs new file mode 100644 index 000000000..864450afc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for skills registered through : capability declaration, listing, +/// retrieval, automatic resource registration, and verified reads through the client extensions. +/// +public class McpServerSkillsTests : ClientServerTestBase +{ + private const string GitWorkflowUri = "skill://git-workflow/SKILL.md"; + private const string GitWorkflowMarkdown = "---\nname: git-workflow\ndescription: Git conventions\nlicense: MIT\n---\n\n# Git workflow\n"; + private const string RefundsUri = "skill://acme/billing/refunds/SKILL.md"; + private static readonly byte[] s_binaryFile = [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00]; + + public McpServerSkillsTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var gitWorkflow = McpServerSkill.Create( + GitWorkflowUri, + new JsonObject { ["name"] = "git-workflow", ["description"] = "Git conventions", ["license"] = "MIT" }, + [ + McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown), + McpServerSkillFile.FromText("references/GUIDE.md", "# Guide\n"), + new McpServerSkillFile { Path = "assets/logo.png", Content = s_binaryFile }, + ]); + + var refunds = McpServerSkill.Create( + RefundsUri, + new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds" }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: Process refunds\n---\n")]); + + mcpServerBuilder.WithSkills([gitWorkflow, refunds]); + } + + [Fact] + public async Task Server_DeclaresSkillsExtensionAndResourcesCapability() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.True(client.SupportsSkills()); + Assert.NotNull(client.ServerCapabilities.Resources); + + var settings = System.Text.Json.JsonSerializer.SerializeToNode(client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId], McpJsonUtilities.DefaultOptions)?.AsObject(); + Assert.NotNull(settings); + Assert.Empty(settings); + } + + [Fact] + public async Task ListSkillsAsync_ReturnsEveryRegisteredSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, skills.Count); + var gitWorkflow = Assert.Single(skills, s => s.Uri == GitWorkflowUri); + Assert.Equal("git-workflow", gitWorkflow.Name); + Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); + Assert.Equal(3, gitWorkflow.Resources.Resources!.Count); + Assert.Contains(skills, s => s.Uri == RefundsUri && s.Name == "refunds"); + } + + [Fact] + public async Task GetSkillAsync_ReturnsTheRequestedEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skill = await client.GetSkillAsync(RefundsUri, TestContext.Current.CancellationToken); + + Assert.Equal(RefundsUri, skill.Uri); + Assert.Equal("refunds", skill.Name); + Assert.Single(skill.Resources.Resources!); + } + + [Fact] + public async Task GetSkillAsync_WithUnknownUri_ReturnsInvalidParams() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://missing/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SkillFiles_AreListedAsResources() + { + await using McpClient client = await CreateMcpClientForServer(); + + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + var skillFile = Assert.Single(resources, r => r.Uri == GitWorkflowUri); + Assert.Equal("git-workflow", skillFile.Name); + Assert.Equal("Git conventions", skillFile.Description); + Assert.Equal("text/markdown", skillFile.MimeType); + Assert.Contains(resources, r => r.Uri == "skill://git-workflow/references/GUIDE.md"); + Assert.Contains(resources, r => r.Uri == "skill://git-workflow/assets/logo.png" && r.MimeType == "image/png"); + Assert.Contains(resources, r => r.Uri == RefundsUri); + } + + [Fact] + public async Task ReadSkillResourceAsync_ReturnsVerifiedText() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(GitWorkflowMarkdown, text.Text); + Assert.Equal("text/markdown", text.MimeType); + } + + [Fact] + public async Task ReadSkillResourceAsync_ReturnsVerifiedBlobForBinaryFile() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, "skill://git-workflow/assets/logo.png", TestContext.Current.CancellationToken); + + var blob = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(s_binaryFile, blob.DecodedData.ToArray()); + Assert.Equal("image/png", blob.MimeType); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsUnlistedFileWithoutContactingServer() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/NEW.md", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadSkillResourceAsync_DetectsContentThatNoLongerMatchesTheHeldEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + // Simulate a stale held entry: the host approved the skill against a different manifest. + var stale = skill.Resources.Resources!.Select(r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size }).ToList(); + stale[0].Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes("previous content")); + stale[0].Size = Encoding.UTF8.GetByteCount("previous content"); + skill.Resources = SkillResources.FromResources(stale); + + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs new file mode 100644 index 000000000..62c960662 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs @@ -0,0 +1,107 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpSkillDirectorySelectionTests : IDisposable +{ + private const string SkillUri = "skill://selected/SKILL.md"; + private const string Markdown = "---\nname: selected\ndescription: d\n---\n"; + private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-selection-" + Guid.NewGuid().ToString("N")); + + public McpSkillDirectorySelectionTests() + { + Directory.CreateDirectory(Path.Combine(_root, ".git")); + File.WriteAllText(Path.Combine(_root, "SKILL.md"), Markdown); + File.WriteAllText(Path.Combine(_root, "README.md"), "visible"); + File.WriteAllText(Path.Combine(_root, ".env"), "synthetic-private-value"); + File.WriteAllText(Path.Combine(_root, ".git", "config"), "synthetic-config"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void ExistingDirectoryOverloadsExcludeDotEntries(int overload) + { + using var held = new FileStream(Path.Combine(_root, ".git", "config"), FileMode.Open, FileAccess.Read, FileShare.None); + + var skill = Create(overload, includeHidden: false); + + Assert.Equal(2, skill.Resources.Count); + Assert.DoesNotContain(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.Contains("/.")); + Assert.Equal( + skill.ProtocolSkill.Resources.Resources!.Select(resource => resource.Uri), + skill.Resources.Select(resource => resource.ProtocolResource!.Uri)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void HiddenEntriesCanBeIncludedExplicitly(int overload) + { + var skill = Create(overload, includeHidden: true); + + Assert.Equal(4, skill.Resources.Count); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.git/config", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(FileAttributes.Hidden)] + [InlineData(FileAttributes.System)] + public void AttributeMarkedFilesAreExcludedUnlessExplicitlyIncluded(FileAttributes attribute) + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows file attribute semantics."); + var path = Path.Combine(_root, "marked.txt"); + File.WriteAllText(path, "synthetic"); + File.SetAttributes(path, File.GetAttributes(path) | attribute); + + Assert.DoesNotContain(Create(0, false).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + Assert.Contains(Create(0, true).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + } + + [Fact] + public void ExcludedFilesAreNotReadOrCountedAgainstThePublishedSizeLimit() + { + using (var file = new FileStream(Path.Combine(_root, ".large-cache"), FileMode.Create)) + { + file.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); + } + + Assert.Equal(2, Create(0, false).Resources.Count); + Assert.Throws(() => Create(0, true)); + } + + [Fact] + public void ExplicitFileAuthoringRetainsTheCallersFileSelection() + { + var skill = McpServerSkill.Create( + [ + McpServerSkillFile.FromText("SKILL.md", Markdown), + McpServerSkillFile.FromText(".env", "synthetic-explicit-value"), + ]); + + Assert.Equal(2, skill.Resources.Count); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + } + + private McpServerSkill Create(int overload, bool includeHidden) + { + var metadata = new JsonObject { ["name"] = "selected", ["description"] = "d" }; + return (overload, includeHidden) switch + { + (0, false) => McpServerSkill.CreateFromDirectory(_root), + (1, false) => McpServerSkill.CreateFromDirectory(SkillUri, _root), + (2, false) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root), + (0, true) => McpServerSkill.CreateFromDirectory(_root, includeHiddenAndSystemFiles: true), + (1, true) => McpServerSkill.CreateFromDirectory(SkillUri, _root, includeHiddenAndSystemFiles: true), + (2, true) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root, includeHiddenAndSystemFiles: true), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } + + public void Dispose() => Directory.Delete(_root, recursive: true); +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs new file mode 100644 index 000000000..da9632d1a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs @@ -0,0 +1,91 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Differential corpus for . Each expected value was produced by the yaml npm +/// package (2.8.x) parsing the same text with its core schema, which is the resolution the YAML libraries used by +/// other MCP SDKs and hosts apply. A host verifies a skill by parsing the fetched SKILL.md with such a +/// library and comparing it field by field against the published entry, so agreement on these cases is the +/// property that makes entries produced by this reader verifiable. +/// +public class SkillFrontmatterCorpusTests +{ + [Theory] + [InlineData("name: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\nlicense: Apache-2.0\nmetadata:\n author: example-org\n version: \"1.0\"\nallowed-tools: Bash(git:*) Read", "{\"name\": \"pdf-processing\", \"description\": \"Extract, fill, and assemble PDF documents\", \"license\": \"Apache-2.0\", \"metadata\": {\"author\": \"example-org\", \"version\": \"1.0\"}, \"allowed-tools\": \"Bash(git:*) Read\"}")] + [InlineData("description: Follow the team's workflow # author note", "{\"description\": \"Follow the team's workflow\"}")] + [InlineData("description: She said \"go\" # and left", "{\"description\": \"She said \\\"go\\\"\"}")] + [InlineData("description: has#hash inside # but this is a comment", "{\"description\": \"has#hash inside\"}")] + [InlineData("tags: [\"a # b\", 'c # d', e] # trailing", "{\"tags\": [\"a # b\", \"c # d\", \"e\"]}")] + [InlineData("map: { k: \"v # w\", n: 2 } # trailing", "{\"map\": {\"k\": \"v # w\", \"n\": 2}}")] + [InlineData("value: >\n\n Text", "{\"value\": \"\\nText\\n\"}")] + [InlineData("value: |\n\n Text", "{\"value\": \"\\nText\\n\"}")] + [InlineData("value: >\n Folded text\n on two lines.\n\n New paragraph.", "{\"value\": \"Folded text on two lines.\\nNew paragraph.\\n\"}")] + [InlineData("value: |\n Line one.\n Line two.\n\n Indented line.\nnext: 1", "{\"value\": \"Line one.\\nLine two.\\n\\n Indented line.\\n\", \"next\": 1}")] + [InlineData("value: >\n para one\n more indented\n back", "{\"value\": \"para one\\n more indented\\nback\\n\"}")] + [InlineData("value: |-\n a\n b\n\nnext: 1", "{\"value\": \"a\\nb\", \"next\": 1}")] + [InlineData("value: |+\n a\n b\n\nnext: 1", "{\"value\": \"a\\nb\\n\\n\", \"next\": 1}")] + [InlineData("value: >-\n a\n b", "{\"value\": \"a b\"}")] + [InlineData("value: |2\n two extra\n one extra", "{\"value\": \" two extra\\n one extra\\n\"}")] + [InlineData("description: This description\n continues on the next line\n and the one after.\nname: demo", "{\"description\": \"This description continues on the next line and the one after.\", \"name\": \"demo\"}")] + [InlineData("description: first line\n\n after blank\nname: demo", "{\"description\": \"first line\\nafter blank\", \"name\": \"demo\"}")] + [InlineData("a: 0x1F\nb: 0o17\nc: 0xffffffffffffffff\nd: +0x10\ne: -0o10\nf: 007\ng: +42\nh: -7\ni: 1.5\nj: .5\nk: 1e3\nl: -2.5E-1\nm: 1.0.0\nn: 2026-09-09\no: 1_000\np: yes\nq: on\nr: ~\ns: null\nt: Null\nu: true\nv: FALSE\nw:\nx: \"\"\ny: ''\nz: 12345678901234567890123", "{\"a\": 31, \"b\": 15, \"c\": 18446744073709551615, \"d\": \"+0x10\", \"e\": \"-0o10\", \"f\": 7, \"g\": 42, \"h\": -7, \"i\": 1.5, \"j\": 0.5, \"k\": 1000, \"l\": -0.25, \"m\": \"1.0.0\", \"n\": \"2026-09-09\", \"o\": \"1_000\", \"p\": \"yes\", \"q\": \"on\", \"r\": null, \"s\": null, \"t\": null, \"u\": true, \"v\": false, \"w\": null, \"x\": \"\", \"y\": \"\", \"z\": 12345678901234567890123}")] + [InlineData("a: 1.\nb: +.5\nc: 1e\nd: .\nf: 0b101\ng: 1e+3\nh: 5.e2", "{\"a\": 1, \"b\": 0.5, \"c\": \"1e\", \"d\": \".\", \"f\": \"0b101\", \"g\": 1000, \"h\": 500}")] + [InlineData("e: -a", "{\"e\": \"-a\"}")] + [InlineData("name: 'it''s'\nq: \"tab\\there\"\nn: \"new\\nline\"\ne: \"é\\x41\"\ns: 'key: value'\nu: \"\\/slash\"", "{\"name\": \"it's\", \"q\": \"tab\\there\", \"n\": \"new\\nline\", \"e\": \"éA\", \"s\": \"key: value\", \"u\": \"/slash\"}")] + [InlineData("tags:\n - one\n - \"two\"\n - 3\nsame-indent:\n- a\n- b\nobjects:\n - name: x\n value: 1\n - name: y\nnested:\n - - a\n - b\n - - c", "{\"tags\": [\"one\", \"two\", 3], \"same-indent\": [\"a\", \"b\"], \"objects\": [{\"name\": \"x\", \"value\": 1}, {\"name\": \"y\"}], \"nested\": [[\"a\", \"b\"], [\"c\"]]}")] + [InlineData("a:\n b:\n c:\n d: deep\n e: 1\nf: 2", "{\"a\": {\"b\": {\"c\": {\"d\": \"deep\"}}, \"e\": 1}, \"f\": 2}")] + [InlineData("empty: []\nemptymap: {}\nflow: [a, b c, 'd', 4, true, null]\nfm: { k: v, n: 2, q: 'x y' }", "{\"empty\": [], \"emptymap\": {}, \"flow\": [\"a\", \"b c\", \"d\", 4, true, null], \"fm\": {\"k\": \"v\", \"n\": 2, \"q\": \"x y\"}}")] + [InlineData("\"quoted key\": 1\n'single key': 2\nkey with spaces: 3", "{\"quoted key\": 1, \"single key\": 2, \"key with spaces\": 3}")] + [InlineData("list:\n- a\n\n- b", "{\"list\": [\"a\", \"b\"]}")] + [InlineData("key:\n # comment only\n sub: 1", "{\"key\": {\"sub\": 1}}")] + [InlineData("key: value\n # indented comment\nother: 2", "{\"key\": \"value\", \"other\": 2}")] + [InlineData("seq:\n - a\n -\n - c", "{\"seq\": [\"a\", null, \"c\"]}")] + [InlineData("seq:\n - spaced: 1\n other: 2\n - plain", "{\"seq\": [{\"spaced\": 1, \"other\": 2}, \"plain\"]}")] + [InlineData("key: value with leading spaces ", "{\"key\": \"value with leading spaces\"}")] + [InlineData("description: >\n Line with trailing spaces \n next", "{\"description\": \"Line with trailing spaces next\\n\"}")] + [InlineData("metadata:\n version: 2.1\n major: 2\n flag: true\n ratio: 0.75\n", "{\"metadata\": {\"version\": 2.1, \"major\": 2, \"flag\": true, \"ratio\": 0.75}}")] + [InlineData("value: |+\n a\n\n\nnext: 1", "{\"value\": \"a\\n\\n\\n\", \"next\": 1}")] + [InlineData("value: >\n normal\n indented one\n indented two\n normal again", "{\"value\": \"normal\\n indented one\\n indented two\\nnormal again\\n\"}")] + [InlineData("value: |2\n\n leading blank then text", "{\"value\": \"\\nleading blank then text\\n\"}")] + [InlineData("value: |\nnext: 1", "{\"value\": \"\", \"next\": 1}")] + [InlineData("value: >-\nnext: 1", "{\"value\": \"\", \"next\": 1}")] + [InlineData("\"key: colon\": 1", "{\"key: colon\": 1}")] + [InlineData("value: a,b", "{\"value\": \"a,b\"}")] + [InlineData("value: [a, b, ]", "{\"value\": [\"a\", \"b\"]}")] + [InlineData("value: { a: 1, }", "{\"value\": {\"a\": 1}}")] + [InlineData("seq:\n- a\n- # comment item\n- c", "{\"seq\": [\"a\", null, \"c\"]}")] + [InlineData("seq:\n - key: v\n # comment\n other: w", "{\"seq\": [{\"key\": \"v\", \"other\": \"w\"}]}")] + [InlineData("value: \nnext: 1", "{\"value\": null, \"next\": 1}")] + [InlineData("value: \"smart “quotes” inside\"", "{\"value\": \"smart “quotes” inside\"}")] + [InlineData("value: -1", "{\"value\": -1}")] + [InlineData("value: 1 2", "{\"value\": \"1 2\"}")] + [InlineData("value: 1,000", "{\"value\": \"1,000\"}")] + [InlineData("value: 0.", "{\"value\": 0}")] + [InlineData("value: 00", "{\"value\": 0}")] + [InlineData("value: 08", "{\"value\": 8}")] + [InlineData("value: -0", "{\"value\": 0}")] + [InlineData("value: 3.14e-2", "{\"value\": 0.0314}")] + [InlineData("value: 1E5", "{\"value\": 100000}")] + [InlineData("value: TRUE", "{\"value\": true}")] + [InlineData("value: NULL", "{\"value\": null}")] + [InlineData("value: True dat", "{\"value\": \"True dat\"}")] + [InlineData("value: null value", "{\"value\": \"null value\"}")] + [InlineData("value: spaced ", "{\"value\": \"spaced\"}")] + [InlineData("value: a:b", "{\"value\": \"a:b\"}")] + [InlineData("value: \"esc \\\\ back\"", "{\"value\": \"esc \\\\ back\"}")] + [InlineData("value: '#not a comment'", "{\"value\": \"#not a comment\"}")] + [InlineData("value: x#y", "{\"value\": \"x#y\"}")] + [InlineData("value: x #y", "{\"value\": \"x\"}")] + [InlineData("value: x\t#tab\n", "{\"value\": \"x\"}")] + public void MatchesReferenceParser(string frontmatterBody, string expectedJson) + { + var actual = SkillFrontmatter.Parse("---\n" + frontmatterBody + "\n---\n"); + + var expected = JsonNode.Parse(expectedJson); + Assert.True( + JsonNode.DeepEquals(expected, actual), + $"Expected {expected?.ToJsonString()} but got {actual.ToJsonString()}."); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs new file mode 100644 index 000000000..8041ae7ee --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs @@ -0,0 +1,98 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillFrontmatterNodeClassificationTests +{ + [Theory] + [InlineData("extra:\n [x]: value", """{"[ x ]":"value"}""")] + [InlineData("extra:\n {a: b}: value", """{"{ a: b }":"value"}""")] + [InlineData("extra:\n -\n [x]: value", """[{"[ x ]":"value"}]""")] + [InlineData("extra:\n [.inf]: value", """{"[ .inf ]":"value"}""")] + [InlineData("extra:\n [team's]: value", """{"[ team's ]":"value"}""")] + [InlineData("extra:\n [\"a]: b\"]: value", """{"[ \"a]: b\" ]":"value"}""")] + public void FlowCollectionKeys_KeepTheExplicitFrontmatterFallback(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + McpServerSkill? skill = null; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.Contains("flow collections are not supported as mapping keys", parseException!.Message); + Assert.IsType(automaticException); + Assert.Null(explicitException); + Assert.NotNull(skill); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("extra:\n {version: 1}", """{"extra":{"version":1}}""")] + [InlineData("tags:\n [read]", """{"tags":["read"]}""")] + [InlineData("metadata:\n \"note\": value", """{"metadata":{"note":"value"}}""")] + [InlineData("extra:\n [\"a]: b\"]", """{"extra":["a]: b"]}""")] + [InlineData("metadata:\n {note: \"}: not a key\"}", """{"metadata":{"note":"}: not a key"}}""")] + [InlineData("extra:\n [team's]", """{"extra":["team's"]}""")] + [InlineData("extra:\n -\n [read]", """{"extra":[["read"]]}""")] + [InlineData("note:\n Key:value#hash", """{"note":"Key:value#hash"}""")] + [InlineData("note:\n |2\n text", """{"note":" text\n"}""")] + public void NextLineValuesAndQuotedKeys_RemainSupported(string body, string expectedFields) + { + var markdown = CreateMarkdown(body); + var expected = JsonNode.Parse(expectedFields)!.AsObject(); + expected["name"] = "alpha"; + expected["description"] = "d"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, + McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("extra:\n &anchor value\nbroken: [never", "\"value\"")] + [InlineData("extra:\n -\n &anchor value\nbroken: [never", "[\"value\"]")] + [InlineData("extra:\n &anchor value", "\"value\"")] + [InlineData("extra:\n !!str value\nbroken: [never", "\"value\"")] + [InlineData("extra:\n *undefined\nbroken: [never", "\"value\"")] + public void UnsupportedNextLineNodeProperties_KeepTheImportedRejection(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + [Theory] + [InlineData("extra:\n [x]#comment", "[\"x\"]")] + [InlineData("extra:\n {version: 1}#comment", """{"version":1}""")] + [InlineData("extra:\n [x]:value", "[\"x\"]")] + public void MalformedFlowValueSuffixes_DoNotBecomeUnsupportedKeys(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + private string CreateMarkdown(string body) => "---\nname: alpha\ndescription: d\n" + body + "\n---\n"; + + private JsonObject CreateFrontmatter(string extraJson) => new() + { + ["name"] = "alpha", + ["description"] = "d", + ["extra"] = JsonNode.Parse(extraJson), + }; +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs new file mode 100644 index 000000000..f61e27231 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs @@ -0,0 +1,142 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillFrontmatterRegressionTests +{ + [Theory] + [InlineData("description:\n Builds reusable components with isolated tables.\n Use for new components.", + "Builds reusable components with isolated tables. Use for new components.")] + [InlineData("description:\n One line on the next line.", "One line on the next line.")] + [InlineData("description:\n \"Quoted on the next line\"", "Quoted on the next line")] + [InlineData("description:\n 'Quoted on the next line'", "Quoted on the next line")] + [InlineData("description: # note\n Text on the next line", "Text on the next line")] + [InlineData("description:\n # note\n\n Text on the next line", "Text on the next line")] + [InlineData("description:\n First line\n continued with less indentation", "First line continued with less indentation")] + public void NextLineScalar_IsParsedAndPublishedWithoutChangingItsValue(string body, string expectedDescription) + { + var markdown = "---\nname: alpha\n" + body + "\n---\n"; + var expected = new JsonObject { ["name"] = "alpha", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("tags:\n -\n text on next line", """{"tags":["text on next line"]}""")] + [InlineData("tags:\n - # note\n \"text on next line\"", """{"tags":["text on next line"]}""")] + [InlineData("metadata:\n note:\n first\n second", """{"metadata":{"note":"first second"}}""")] + public void NestedNextLineScalar_IsParsedAndPublished(string body, string expectedFields) + { + var markdown = "---\nname: alpha\ndescription: d\n" + body + "\n---\n\n# Body\n"; + var expected = JsonNode.Parse(expectedFields)!.AsObject(); + expected["name"] = "alpha"; + expected["description"] = "d"; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData(">", " First\n \tCode\n Last\n", "First\n\tCode\nLast\n")] + [InlineData(">-", " First\n \tCode\n Last\n", "First\n\tCode\nLast")] + [InlineData(">+", " First\n \tCode\n Last\n\n", "First\n\tCode\nLast\n\n")] + [InlineData(">", " First\n\n \tCode\n\n Last\n", "First\n\n\tCode\n\nLast\n")] + [InlineData(">2", " First\n \tCode\n Last\n", "First\n\tCode\nLast\n")] + public void FoldedScalar_PreservesLineBreaksAroundTabLedContent(string header, string content, string expectedDescription) + { + var markdown = "---\nname: comparison\ndescription: " + header + "\n" + content + "---\n"; + var expected = new JsonObject { ["name"] = "comparison", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("|+", "Kept\n\n")] + [InlineData("|", "Kept\n")] + [InlineData("|-", "Kept")] + public void BlockScalarAtClosingFence_PreservesAuthoredLineBreaks(string header, string expectedDescription) + { + var markdown = "---\nname: alpha\ndescription: " + header + "\n Kept\n\n---\n"; + var expected = new JsonObject { ["name"] = "alpha", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("\"Useful\"#comment", "{version: '1'}", "[read]")] + [InlineData("'Useful'#comment", "{version: '1'}", "[read]")] + [InlineData("Useful", "{version: '1'}#comment", "[read]")] + [InlineData("Useful", "{version: '1'}", "[read]#comment")] + public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloads( + string description, string metadata, string tags) + { + var markdown = $"---\nname: comparison\ndescription: {description}\nmetadata: {metadata}\ntags: {tags}\n---\n"; + var expected = new JsonObject + { + ["name"] = "comparison", + ["description"] = "Useful", + ["metadata"] = new JsonObject { ["version"] = "1" }, + ["tags"] = new JsonArray("read"), + }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => McpServerSkill.Create("skill://comparison/SKILL.md", expected, files)); + + Assert.IsType(parseException); + Assert.IsType(automaticException); + Assert.IsType(explicitException); + } + + [Theory] + [InlineData("Useful#hash", "{version: '1'}", "[read]", "Useful#hash")] + [InlineData("\"Useful\" #comment", "{version: '1'}", "[read]", "Useful")] + [InlineData("'Useful'\t#comment", "{version: '1'}", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'} #comment", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'}\t#comment", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'}", "[read] #comment", "Useful")] + [InlineData("Useful", "{version: '1'}", "[read]\t#comment", "Useful")] + public void SeparatedCommentsAndPlainHashes_RemainValid( + string description, string metadata, string tags, string expectedDescription) + { + var markdown = $"---\nname: comparison\ndescription: {description}\nmetadata: {metadata}\ntags: {tags}\n---\n"; + var expected = new JsonObject + { + ["name"] = "comparison", + ["description"] = expectedDescription, + ["metadata"] = new JsonObject { ["version"] = "1" }, + ["tags"] = new JsonArray("read"), + }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData(".inf")] + [InlineData("-.Inf")] + [InlineData(".nan")] + public void NonFiniteScalar_CannotBeReplacedWithJsonNullThroughExplicitFrontmatter(string value) + { + var markdown = $"---\nname: alpha\ndescription: d\nextra: {value}\n---\n"; + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["extra"] = null }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Contains("cannot be represented in JSON", exception.Message); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + private void AssertParsedAndPublished(string markdown, JsonObject expected) + { + var parsed = SkillFrontmatter.Parse(markdown); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var automatic = McpServerSkill.Create(files); + var explicitMetadata = McpServerSkill.Create(automatic.ProtocolSkill.Uri, expected, files); + + Assert.True(JsonNode.DeepEquals(expected, parsed), $"Expected {expected}, parsed {parsed}."); + Assert.True(JsonNode.DeepEquals(expected, automatic.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, explicitMetadata.ProtocolSkill.Frontmatter)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs new file mode 100644 index 000000000..85bf07427 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -0,0 +1,479 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : the YAML subset it accepts, YAML 1.2 core-schema scalar resolution, +/// and the constructs it rejects. +/// +public class SkillFrontmatterTests +{ + private static JsonObject Parse(string yamlBody, string suffix = "\n# Body\n") => + SkillFrontmatter.Parse("---\n" + yamlBody + "\n---" + suffix); + + private static void AssertJson(string expectedJson, JsonNode? actual) => + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedJson), actual), $"Expected {expectedJson} but got {actual?.ToJsonString() ?? "null"}."); + + [Fact] + public void Parses_TheAgentSkillsReferenceShape() + { + var frontmatter = Parse(""" + name: pdf-processing + description: Extract, fill, and assemble PDF documents + license: Apache-2.0 + compatibility: Designed for Claude Code + metadata: + author: example-org + version: "1.0" + allowed-tools: Bash(git:*) Read + """); + + AssertJson(""" + { + "name": "pdf-processing", + "description": "Extract, fill, and assemble PDF documents", + "license": "Apache-2.0", + "compatibility": "Designed for Claude Code", + "metadata": { "author": "example-org", "version": "1.0" }, + "allowed-tools": "Bash(git:*) Read" + } + """, frontmatter); + } + + [Fact] + public void PreservesKeyOrder() + { + var frontmatter = Parse("zeta: 1\nalpha: 2\nmid: 3"); + + Assert.Equal(["zeta", "alpha", "mid"], frontmatter.Select(p => p.Key)); + } + + [Theory] + [InlineData("null", "null")] + [InlineData("~", "null")] + [InlineData("", "null")] + [InlineData("Null", "null")] + [InlineData("true", "true")] + [InlineData("False", "false")] + [InlineData("42", "42")] + [InlineData("-7", "-7")] + [InlineData("007", "7")] + [InlineData("0x1F", "31")] + [InlineData("0o17", "15")] + [InlineData("0xffffffffffffffff", "18446744073709551615")] + [InlineData("0o777777777777777777777777", "4722366482869645213695")] + [InlineData("+0x10", "\"+0x10\"")] + [InlineData("-0o10", "\"-0o10\"")] + [InlineData("0x", "\"0x\"")] + [InlineData("0xG", "\"0xG\"")] + [InlineData("1.5", "1.5")] + [InlineData(".5", "0.5")] + [InlineData("1e3", "1000")] + [InlineData("-2.5E-1", "-0.25")] + [InlineData("123456789012345678901234567890", "123456789012345678901234567890")] + [InlineData("yes", "\"yes\"")] + [InlineData("on", "\"on\"")] + [InlineData("1.0.0", "\"1.0.0\"")] + [InlineData("2026-09-09", "\"2026-09-09\"")] + [InlineData("1_000", "\"1_000\"")] + [InlineData("+", "\"+\"")] + [InlineData("Bash(git:*)", "\"Bash(git:*)\"")] + [InlineData("https://example.com/a:b", "\"https://example.com/a:b\"")] + public void ResolvesPlainScalarsPerCoreSchema(string yaml, string expectedJson) + { + var frontmatter = Parse("value: " + yaml); + + AssertJson("{ \"value\": " + expectedJson + " }", frontmatter); + } + + [Theory] + [InlineData("'single quoted'", "single quoted")] + [InlineData("'it''s'", "it's")] + [InlineData("\"double quoted\"", "double quoted")] + [InlineData("\"tab\\there\"", "tab\there")] + [InlineData("\"new\\nline\"", "new\nline")] + [InlineData("\"quote \\\" inside\"", "quote \" inside")] + [InlineData("\"\\u00e9\\x41\"", "éA")] + [InlineData("\"\\U0001F600\"", "😀")] + [InlineData("\"\\uD83D\\uDE00\"", "😀")] + [InlineData("\"1.0\"", "1.0")] + [InlineData("'true'", "true")] + [InlineData("\"a # not a comment\"", "a # not a comment")] + [InlineData("'key: value'", "key: value")] + public void ParsesQuotedScalars(string yaml, string expected) + { + var frontmatter = Parse("value: " + yaml); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + } + + [Fact] + public void StripsCommentsOutsideQuotes() + { + var frontmatter = Parse(""" + # leading comment + name: demo # trailing comment + description: has#hash inside # but this is a comment + # an indented comment line + license: 'MIT' # after quotes + apostrophe: Follow the team's workflow # author note + quote: She said "go" # and left + tags: ["a # b", 'c # d', e] # trailing + map: { k: "v # w" } # trailing + block: | # header comment + text # kept + tab: x #tab before hash + dash: -a + """); + + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + Assert.Equal("has#hash inside", frontmatter["description"]?.GetValue()); + Assert.Equal("MIT", frontmatter["license"]?.GetValue()); + Assert.Equal("Follow the team's workflow", frontmatter["apostrophe"]?.GetValue()); + Assert.Equal("She said \"go\"", frontmatter["quote"]?.GetValue()); + AssertJson("""["a # b", "c # d", "e"]""", frontmatter["tags"]); + Assert.Equal("v # w", frontmatter["map"]?["k"]?.GetValue()); + Assert.Equal("text # kept\n", frontmatter["block"]?.GetValue()); + Assert.Equal("x", frontmatter["tab"]?.GetValue()); + Assert.Equal("-a", frontmatter["dash"]?.GetValue()); + } + + [Fact] + public void FoldsPlainMultiLineScalars() + { + var frontmatter = Parse(""" + description: This description + continues on the next line + and the one after. + name: demo + """); + + Assert.Equal("This description continues on the next line and the one after.", frontmatter["description"]?.GetValue()); + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void ParsesLiteralBlockScalar() + { + var frontmatter = Parse(""" + description: | + Line one. + Line two. + + Indented line. + name: demo + """); + + Assert.Equal("Line one.\nLine two.\n\n Indented line.\n", frontmatter["description"]?.GetValue()); + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void ParsesFoldedBlockScalar() + { + var frontmatter = Parse(""" + description: > + Folded text + on two lines. + + New paragraph. + """); + + Assert.Equal("Folded text on two lines.\nNew paragraph.\n", frontmatter["description"]?.GetValue()); + } + + [Theory] + [InlineData(">", "\nText\n")] + [InlineData(">-", "\nText")] + [InlineData("|", "\nText\n")] + public void PreservesLeadingEmptyLinesInBlockScalars(string header, string expected) + { + var frontmatter = Parse($"value: {header}\n\n Text\nnext: 1"); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + Assert.Equal(1, frontmatter["next"]?.GetValue()); + } + + [Theory] + [InlineData("|-", "a\nb")] + [InlineData("|", "a\nb\n")] + [InlineData("|+", "a\nb\n\n")] + [InlineData(">-", "a b")] + [InlineData("|- # comment", "a\nb")] + [InlineData("|2-", "a\nb")] + [InlineData("|-2", "a\nb")] + public void HonorsChompingIndicators(string header, string expected) + { + var frontmatter = Parse($"value: {header}\n a\n b\n\nnext: 1"); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + Assert.Equal(1, frontmatter["next"]?.GetValue()); + } + + [Fact] + public void ParsesBlockSequences() + { + var frontmatter = Parse(""" + tags: + - one + - "two" + - 3 + same-indent: + - a + - b + objects: + - name: x + value: 1 + - name: y + """); + + AssertJson(""" + { + "tags": ["one", "two", 3], + "same-indent": ["a", "b"], + "objects": [{ "name": "x", "value": 1 }, { "name": "y" }] + } + """, frontmatter); + } + + [Fact] + public void ResolvesPlainMappingKeysLikeReferenceParsers() + { + var frontmatter = Parse("TRUE: a\n0x10: b\n~: c\n1.5: d\nplain: e"); + + Assert.Equal(["true", "16", "", "1.5", "plain"], frontmatter.Select(p => p.Key)); + } + + [Fact] + public void PreservesNonBreakingSpacesInScalars() + { + var frontmatter = Parse("value: \u00A0padded\u00A0\nother: a\u00A0b"); + + Assert.Equal("\u00A0padded\u00A0", frontmatter["value"]?.GetValue()); + Assert.Equal("a\u00A0b", frontmatter["other"]?.GetValue()); + } + + [Fact] + public void FlowMappingHonorsSeparatorRules() + { + var frontmatter = Parse(""" + a: {version:1} + b: {version: 1} + c: {"version":1} + d: {k, m: 2} + e: {TRUE: x} + """); + + AssertJson("""{ "a": { "version:1": null }, "b": { "version": 1 }, "c": { "version": 1 }, "d": { "k": null, "m": 2 }, "e": { "true": "x" } }""", frontmatter); + } + + [Fact] + public void RejectsNestingBeyondTheDepthLimit() + { + string deep = string.Join("\n", Enumerable.Range(0, 40).Select(i => new string(' ', i) + $"k{i}:")) + " v"; + + var exception = Assert.Throws(() => Parse(deep)); + Assert.Contains("nested more than", exception.Message); + + string shallow = string.Join("\n", Enumerable.Range(0, 20).Select(i => new string(' ', i) + $"k{i}:")) + " v"; + Assert.NotNull(Parse(shallow)); + } + + [Fact] + public void ParsesFlowCollections() + { + var frontmatter = Parse(""" + tags: [a, "b c", 'd', 4, true] + empty: [] + map: { k: v, n: 2 } + emptymap: {} + """); + + AssertJson("""{ "tags": ["a", "b c", "d", 4, true], "empty": [], "map": { "k": "v", "n": 2 }, "emptymap": {} }""", frontmatter); + } + + [Fact] + public void ParsesNestedMappingsToAnyDepth() + { + var frontmatter = Parse(""" + a: + b: + c: + d: deep + e: 1 + f: 2 + """); + + AssertJson("""{ "a": { "b": { "c": { "d": "deep" } }, "e": 1 }, "f": 2 }""", frontmatter); + } + + [Fact] + public void HandlesCrlfAndBom() + { + var frontmatter = SkillFrontmatter.Parse("\uFEFF---\r\nname: demo\r\ndescription: d\r\n---\r\n\r\n# Body\r\n"); + + AssertJson("""{ "name": "demo", "description": "d" }""", frontmatter); + } + + [Fact] + public void AcceptsDocumentEndMarkerAsCloser() + { + var frontmatter = SkillFrontmatter.Parse("---\nname: demo\n...\nbody"); + + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void EmptyFrontmatterIsAnEmptyObject() + { + Assert.Empty(SkillFrontmatter.Parse("---\n---\n# Body")); + } + + [Theory] + [InlineData("-0", "0")] + [InlineData("-00", "0")] + [InlineData("-0.0", "0")] + [InlineData("+0", "0")] + public void NegativeZeroIsZero(string yaml, string expectedJson) + { + // Reference parsers render -0 as 0 (JavaScript has no distinct -0 in JSON; Python's int has none at all). + Assert.Equal("{\"value\":" + expectedJson + "}", Parse("value: " + yaml).ToJsonString()); + Assert.Equal("{\"" + expectedJson + "\":1}", Parse(yaml + ": 1").ToJsonString()); + } + + [Fact] + public void TabsSeparateValuesButDoNotIndent() + { + var frontmatter = Parse("a:\tone\nb:\n\t\nc: {d:\t2, \"e\"\t: 3}\nitems:\n -\tx\nblock: |\n \tindented by a tab\n \t\ntail: 'a\tb'"); + + AssertJson("""{ "a": "one", "b": null, "c": { "d": 2, "e": 3 }, "items": ["x"], "block": "\tindented by a tab\n\t\n", "tail": "a\tb" }""", frontmatter); + } + + [Fact] + public void BlockScalarsKeepWhiteSpaceOnLinesWiderThanTheirIndentation() + { + var frontmatter = Parse("lit: |\n a\n \n b\n \nfold: >\n a\n \n b\n"); + + AssertJson("""{ "lit": "a\n \nb\n \n", "fold": "a\n \nb\n" }""", frontmatter); + } + + [Fact] + public void SequenceItemsMayBeMappingsWithQuotedKeys() + { + var frontmatter = Parse("items:\n - \"k\": v\n w: 1\n - 'q': r\n - \"plain\" # not a key\n"); + + AssertJson("""{ "items": [ { "k": "v", "w": 1 }, { "q": "r" }, "plain" ] }""", frontmatter); + } + + [Fact] + public void KeysMayStartWithIndicatorsNotFollowedByWhiteSpace() + { + var frontmatter = Parse("?a: 1\n-a: 2\n-: 3\n:a: 4"); + + AssertJson("""{ "?a": 1, "-a": 2, "-": 3, ":a": 4 }""", frontmatter); + } + + [Fact] + public void ACommentEndsAPlainScalarOnAnyOfItsLines() + { + var frontmatter = Parse("a: one\n two # note\nb: 2"); + + AssertJson("""{ "a": "one two", "b": 2 }""", frontmatter); + } + + [Theory] + [InlineData("# no frontmatter\nname: x", "must begin")] + [InlineData("---\nname: x\n", "not closed")] + [InlineData("---\nname: x\nname: y\n---", "duplicate key")] + [InlineData("---\n\tname: x\n---", "tabs")] + [InlineData("---\nname: &anchor x\n---", "anchors")] + [InlineData("---\nname: *alias\n---", "anchors")] + [InlineData("---\nname: !!str x\n---", "anchors")] + [InlineData("---\n? complex\n: key\n---", "complex")] + [InlineData("---\nname: \"unterminated\n---", "unterminated")] + [InlineData("---\nname: [a, [b]]\n---", "nested flow")] + [InlineData("---\nname: [a,\n b]\n---", "multi-line flow collections")] + [InlineData("---\nname: {a: 1,\n b: 2}\n---", "multi-line flow collections")] + [InlineData("---\nname: [a, b\nother: c\n---", "unterminated flow sequence")] + [InlineData("---\nname: {a: b\nother: c\n---", "unterminated flow mapping")] + [InlineData("---\nname: x\n\tother: y\n---", "tabs")] + [InlineData("---\nitems:\n -\tk: v\n---", "tabs")] + [InlineData("---\nvalue: |\n a\n\t\n b\n---", "tabs")] + [InlineData("---\nvalue: a\n b # comment\n c\n---", "a comment ended that value")] + [InlineData("---\nvalue: ]x\n---", "flow indicator")] + [InlineData("---\nvalue: }x\n---", "flow indicator")] + [InlineData("---\nvalue: ,x\n---", "flow indicator")] + [InlineData("---\nvalue: ?\n---", "block indicator")] + [InlineData("---\nvalue: ? a\n---", "block indicator")] + [InlineData("---\n&a: x\n---", "anchors")] + [InlineData("---\n*a: x\n---", "anchors")] + [InlineData("---\n!t: x\n---", "anchors")] + [InlineData("---\n@a: x\n---", "reserves")] + [InlineData("---\n`a: x\n---", "reserves")] + [InlineData("---\n%a: x\n---", "reserves")] + [InlineData("---\n]a: x\n---", "flow indicator")] + [InlineData("---\n,a: x\n---", "flow indicator")] + [InlineData("---\n|: x\n---", "block scalar header")] + [InlineData("---\n[a]: x\n---", "flow collections are not supported as mapping keys")] + [InlineData("---\n{a: 1}: x\n---", "flow collections are not supported as mapping keys")] + [InlineData("---\nvalue: [,]\n---", "unexpected ','")] + [InlineData("---\nvalue: [a,,b]\n---", "unexpected ','")] + [InlineData("---\nvalue: [,a]\n---", "unexpected ','")] + [InlineData("---\nvalue: {,}\n---", "unexpected ','")] + [InlineData("---\nvalue: {a: 1,,b: 2}\n---", "unexpected ','")] + [InlineData("---\nvalue: [- b]\n---", "block indicator")] + [InlineData("---\nvalue: [}]\n---", "flow indicator")] + [InlineData("---\nvalue: {]: 1}\n---", "flow indicator")] + [InlineData("---\nvalue: [\"a\": b]\n---", "compact mappings inside flow sequences")] + [InlineData("---\nvalue: |\n \n a\n---", "leading empty line")] + [InlineData("---\nvalue: .inf\n---", "cannot be represented")] + [InlineData("---\nvalue: @handle\n---", "reserves")] + [InlineData("---\nvalue: `tick\n---", "reserves")] + [InlineData("---\nvalue: %pct\n---", "reserves")] + [InlineData("---\nvalue: [@a]\n---", "reserves")] + [InlineData("---\nvalue: - a\n---", "same line as its key")] + [InlineData("---\nvalue: |--\n a\n---", "repeated chomping")] + [InlineData("---\nvalue: |2-2\n a\n---", "repeated indentation")] + [InlineData("---\nvalue: | garbage\n a\n---", "only a comment may follow")] + [InlineData("---\nvalue: |x\n a\n---", "invalid block scalar header")] + [InlineData("---\nvalue: [a # comment, b]\n---", "comment inside a flow collection")] + [InlineData("---\nvalue: { a: b # c }\n---", "comment inside a flow collection")] + [InlineData("---\nvalue: \"\\U0000D800\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\U00110000\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\UFFFFFFFF\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\uD800\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: \"\\uDE00x\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: [a: b]\n---", "compact mappings inside flow sequences")] + [InlineData("---\ndescription: first # note\n second\n---", "a comment ended that value")] + [InlineData("---\nitems:\n - first # note\n second\n---", "a comment ended that value")] + [InlineData("---\nname: \"never closed\ndescription: d\n---", "unterminated quoted scalar")] + [InlineData("---\nvalue: {a: b # c}\n---", "comment inside a flow collection")] + [InlineData("---\nvalue: [a, b: c]\n---", "compact mappings inside flow sequences")] + [InlineData("---\nvalue: -\n---", "same line as its key")] + [InlineData("---\nvalue: \"\\q\"\n---", "unsupported escape")] + [InlineData("---\njust a scalar\n---", "key: value")] + [InlineData("---\n- item\n---", "must be a YAML mapping")] + [InlineData("---\nname: a: b\n---", "cannot contain ': '")] + [InlineData("---\ndescription: text\n other: mis-indented key\n---", "cannot contain ': '")] + [InlineData("---\nname: x\n extra: indented\n---", "cannot contain ': '")] + public void RejectsUnsupportedOrMalformedInput(string markdown, string messageFragment) + { + // Unsupported-but-valid YAML throws a FormatException subclass, so match by assignability. + var exception = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); + + Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RoundTripsThroughSkillEntryValidation() + { + const string Markdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; + + var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]); + + Assert.Equal("skill://git-workflow/SKILL.md", skill.ProtocolSkill.Uri); + Assert.Equal("git-workflow", skill.ProtocolSkill.Name); + Assert.Equal("Git conventions", skill.ProtocolSkill.Description); + } +} diff --git a/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs new file mode 100644 index 000000000..c13181163 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace ModelContextProtocol.Tests.Utils; + +public class ConformanceProcessTests +{ + [Theory] + [InlineData(0, true, true)] + [InlineData(17, true, false)] + [InlineData(3, false, false)] + public async Task ExitCodeCannotBeOverriddenByPrintedSummary(int exitCode, bool passingSummary, bool expectedSuccess) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + var startInfo = CreateStartInfo(exitCode, passingSummary); + + var result = await NodeHelpers.RunServerConformanceAsync( + string.Empty, + cancellationToken: TestContext.Current.CancellationToken, + processStartInfo: startInfo); + + Assert.Equal(expectedSuccess, result.Success); + Assert.Contains(passingSummary ? "Passed: 3/3" : "Passed: 2/3", result.Output); + Assert.Contains("synthetic stderr", result.Error); + if (exitCode != 0) + { + Assert.Contains($"exited with code {exitCode}", result.Error); + } + } + + [Fact] + public async Task InstalledRunnerListsAllRequiredSkillsScenarios() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + + var result = await NodeHelpers.RunServerConformanceAsync( + "list --server", + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success, $"Runner failed.\n{result.Output}\n{result.Error}"); + Assert.Contains("sep-2640-skills-enumeration", result.Output); + Assert.Contains("sep-2640-skills-manifest", result.Output); + Assert.Contains("sep-2640-skills-directory", result.Output); + } + + private ProcessStartInfo CreateStartInfo(int exitCode, bool passingSummary) + { + var summary = passingSummary + ? "Passed: 3/3, 0 failed, 0 warnings" + : "Passed: 2/3, 1 failed, 0 warnings"; + var script = $"console.log('Test Results:'); console.log('{summary}'); console.error('synthetic stderr'); process.exitCode = {exitCode};"; + return new ProcessStartInfo + { + FileName = "node", + Arguments = $"-e \"{script}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + } +}