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..c9d0e3042 --- /dev/null +++ b/docs/concepts/skills/skills.md @@ -0,0 +1,223 @@ +--- +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. 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. + +### 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; + +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); +``` + +Every 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. + +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 the skill against the specification 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 shape of `metadata` 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 accepts the subset of YAML that Agent Skills +frontmatter uses: block mappings nested to any depth, 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), matching the YAML libraries used by other SDKs and by hosts. 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 flow collections, and multi-line quoted scalars are valid YAML the +reader does not support; it rejects them with a naming the construct. For such a +file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the frontmatter +directly. That escape hatch covers only valid-but-unsupported YAML: a file with no frontmatter block, malformed +YAML, or invalid UTF-8 is rejected regardless, since no host could parse it either. When the reader can parse the +file, an explicit object must match it exactly, or the skill is rejected at construction rather than by every host. + +#### 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`. 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. + +#### Protocol versions and caching hints + +`skills/list` results carry the base protocol's `ttlMs` and `cacheScope` list-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. + +### Consuming skills + +The client extension methods in cover the +host's side of the protocol: + +```csharp +using ModelContextProtocol.Extensions.Skills; + +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); +``` + +`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. + +### 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. +- 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. A host can do so with + on the fetched text and + +against the entry's frontmatter, treating a parse failure or a difference as a verification failure. + +### 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](../../../samples/SkillsServer/README.md): a Streamable + HTTP server serving two skills from directories on disk. +- [SkillsClient](../../../samples/SkillsClient/README.md): 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/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs new file mode 100644 index 000000000..4f80b1829 --- /dev/null +++ b/samples/SkillsClient/Program.cs @@ -0,0 +1,90 @@ +// 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. Read the skill's files with resources/read and verify each one against the manifest's digest and size. +// 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; + +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. Verified reads. ReadSkillResourceAsync issues resources/read and checks size and digest against the held +// entry; a mismatch throws SkillVerificationException and the content must not be used. +Console.WriteLine("=== resources/read (verified) ==="); +ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); +Console.WriteLine($" {SkillUri} verified:"); +Console.WriteLine(Indent(((TextResourceContents)skillFile.Contents[0]).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..a92591154 --- /dev/null +++ b/samples/SkillsClient/README.md @@ -0,0 +1,69 @@ +# 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 pagination; 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 each against the manifest's digest and size + (`client.ReadSkillResourceAsync(skill, uri)`). +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 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. +- The SDK does not verify frontmatter automatically. A host can re-parse the fetched `SKILL.md` with + `SkillFrontmatter.Parse` and compare it to the entry with `JsonNode.DeepEquals` before loading a skill. 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..6ec5bb535 --- /dev/null +++ b/samples/SkillsServer/README.md @@ -0,0 +1,43 @@ +# 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/`. + +## 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/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/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.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs new file mode 100644 index 000000000..19c649102 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -0,0 +1,267 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to discover, retrieve, and verify skills served under the +/// MCP Skills extension (SEP-2640). +/// +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 entries of every skill the server lists, following pagination to the end. + /// + /// 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 or the server returned an error response. + /// + /// + /// 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. + /// + /// + public static async ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) + { + List? skills = null; + ListSkillsRequestParams requestParams = new(); + do + { + var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); + skills ??= new(page.Skills.Count); + skills.AddRange(page.Skills); + requestParams.Cursor = page.NextCursor; + } + while (requestParams.Cursor is not null); + + return skills; + } + + /// + /// 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, as returned by the server, with every entry validated against the specification. + /// 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}'."); + + foreach (var skill in result.Skills) + { + SkillValidation.ValidateReceived(skill, SkillsProtocol.MethodSkillsList); + } + + return result; + } + + /// + /// 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 skill's entry, validated against the specification. + /// 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, as returned by the server, with the entry validated against the specification. + /// 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 the content against the skill's 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 verified contents. + /// An argument is . + /// + /// 's manifest is . Such a skill offers no content + /// integrity; read its files with + /// only if the host has decided to load unverifiable skills. + /// + /// + /// 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. + /// + 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 + + if (skill.Resources is { IsDynamic: true }) + { + 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."); + } + + 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/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs new file mode 100644 index 000000000..7f1c153d7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs @@ -0,0 +1,39 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// The exception thrown when a skill file's content does not match its manifest entry, or when a file is read +/// that the manifest does not list. +/// +/// +/// 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..59aa04144 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -0,0 +1,265 @@ +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. Frontmatter verification (re-parsing the fetched SKILL.md and comparing +/// its YAML frontmatter against the entry) is not performed automatically; a host can do it with +/// and . +/// +/// +/// 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: + 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 can be 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."); + } + } + + 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..2c9432fc3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs @@ -0,0 +1,26 @@ +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 +{ + /// + /// 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; } +} 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..bc18bbcb9 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -0,0 +1,40 @@ +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")] + 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..c604df30d --- /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. + /// + [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..c6bcd6377 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -0,0 +1,62 @@ +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. +/// +/// +/// 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..0abbc47b3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -0,0 +1,137 @@ +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, +/// so a server cannot publish an entry a conforming host would refuse to load. +/// +/// +/// The catalog keeps its own copy of every entry, so later changes to the objects passed to the constructor do +/// not 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 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)); + + if (_byUri.ContainsKey(skill.Uri)) + { + throw new ArgumentException($"Duplicate skill URI '{skill.Uri}'.", nameof(skills)); + } + + _byUri.Add(skill.Uri, SkillValidation.Snapshot(skill)); + } + + _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]; + Array.Copy(_ordered, start, page, 0, count); + + 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(); + + _byUri.TryGetValue(uri, out var skill); + return new ValueTask(skill); + } + + 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 Encoding.UTF8.GetString(Convert.FromBase64String(cursor!)); + } + catch (FormatException) + { + 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..af3d1a280 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -0,0 +1,613 @@ +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. +/// +/// +/// The skill's frontmatter is read from its SKILL.md by . Hosts re-parse the +/// fetched SKILL.md and compare it field by field against the published entry, treating any discrepancy as +/// a verification failure, so the two must agree. The overloads that accept an explicit +/// exist for frontmatter the reader cannot handle; when it can read the file, an explicit object that differs from +/// it is rejected. +/// +/// +/// 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 McpServerSkill(Skill protocolSkill, IReadOnlyList resources) + { + ProtocolSkill = protocolSkill; + Resources = resources; + } + + /// + /// Gets the skill's entry, as returned by skills/list and skills/get. + /// + /// + /// The catalog that WithSkills creates keeps its own copy of this entry, so changes made to this object + /// after registration do not affect what is served. + /// + public Skill ProtocolSkill { get; } + + /// + /// 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 the frontmatter in + /// SKILL.md; or omits SKILL.md, contains a duplicate or unsafe path, or + /// exceeds the specification's per-skill limits. + /// + /// + /// Prefer , which reads the frontmatter from the + /// file. This overload exists for frontmatter that cannot read. When it can read + /// the file, the supplied object must match it exactly, since hosts compare the two field by field and refuse + /// the skill on any difference. + /// + 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 every file 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. + /// + /// + /// 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) + { +#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), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file 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) + { +#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), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file 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 , which reads the frontmatter from the file. This + /// overload exists for frontmatter that cannot read. When it can read the file, + /// the supplied object must match it exactly. See for how files and + /// links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + { +#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), 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) => + CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath), paramName); + + private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName) + { + // 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 the frontmatter from SKILL.md (always first after sorting). When the caller supplied frontmatter, + // the file is still read so that the two can be checked against each other. The caller's frontmatter + // stands on its own only when the file uses valid YAML the reader does not support; a file that is not + // UTF-8, has no frontmatter block, or is malformed cannot be reproduced by any host and is rejected. + 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) + { + // Explicit frontmatter covers this case. + } + 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, nameof(frontmatter)); + + 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) + { + 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); + 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) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + FileAttributes attributes = File.GetAttributes(entry); + 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); + 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 = File.ReadAllBytes(entry), + }); + } + } + + /// + /// 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 + { + text = s_strictUtf8.GetString(bytes.ToArray()); + 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..927ac2ad6 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -0,0 +1,390 @@ +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. + /// + /// + /// 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)); + } + + entries.Add(skill.ProtocolSkill); + + var manifest = skill.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.Digest, entry.Digest, StringComparison.Ordinal)) + { + throw new ArgumentException( + string.Equals(existing.Uri, entry.Uri, StringComparison.Ordinal) + ? $"The file '{entry.Uri}' is listed by more than one skill with different content." + : $"The file '{entry.Uri}' is equivalent to '{existing.Uri}', which another skill lists with different content. Resource URIs are compared case-insensitively in their scheme and authority.", + 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. + /// + /// The server builder. + /// + /// A directory whose immediate subdirectories are skills. Each 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, and symbolic links are rejected. 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) + { +#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) + { + // 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 ((File.GetAttributes(skillDirectory) & 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))); + } + + 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); + 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); + 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"; + } + + 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..c0f2ca926 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs @@ -0,0 +1,41 @@ +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 results. + /// + /// + /// + /// This is the base protocol's ttlMs list-caching attribute, with the same semantics as on + /// tools/list. It is a hint about the listing, 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 results. + /// + /// + /// + /// Set this to only when the catalog is identical for every caller. + /// A listing 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..4a462d0de --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -0,0 +1,1035 @@ +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Reads the YAML frontmatter of a SKILL.md into a , without a YAML library. +/// Servers use it to publish a skill's entry from its file; hosts can use it to compare a fetched SKILL.md against the entry. +/// +/// +/// +/// Agent Skills frontmatter is a small, regular subset of YAML: a block mapping of scalars, with at most a nested +/// mapping (metadata) and the occasional sequence. This reader accepts that subset deliberately and +/// rejects everything else with a naming the construct, so that a skill whose +/// frontmatter it cannot represent faithfully is never published with a guessed rendering. Anchors, aliases, +/// tags, multi-document streams, complex keys, and tab indentation are rejected. +/// +/// +/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, +/// otherwise strings), which is what the YAML libraries used by other MCP SDKs and hosts do. A host verifies a +/// skill by parsing the fetched SKILL.md with its own YAML library and comparing field by field against the +/// entry, so matching that resolution is what makes the published frontmatter verifiable. +/// +/// +public static class SkillFrontmatter +{ + private const string Delimiter = "---"; + + /// + /// Thrown for YAML that is valid but outside the subset this reader supports, as opposed to malformed + /// frontmatter. Callers that accept explicitly supplied frontmatter may fall back on it for this case only. + /// + internal sealed class UnsupportedYamlException(string message) : FormatException(message); + + /// + /// 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() != 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(); + 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++; + } + + if (indent < raw.Length && raw[indent] == '\t') + { + throw new FormatException($"Line {number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + Indent = indent; + Content = raw.Substring(indent); + IsBlank = Content.Length == 0 || Content[0] == '#'; + } + + public string Raw { get; } + public int Number { get; } + public int Indent { get; } + public string Content { 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; + + 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) + { + SkipBlank(); + var line = _lines[_pos]; + return IsSequenceEntry(line.Content) ? ParseSequence(indent) : ParseMapping(indent); + } + + private static bool IsSequenceEntry(string content) => + content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] == ' '); + + 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."); + } + + 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] == '?') + { + throw new UnsupportedYamlException($"Line {line.Number}: complex mapping keys ('? ') are not supported 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] != ' ')) + { + 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."); + } + + key = content.Substring(0, separator).TrimEnd(); + if (key.Length == 0) + { + throw new FormatException($"Line {line.Number}: empty mapping key."); + } + + consumed = separator + 1; + } + + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {line.Number}: duplicate key '{key}'."); + } + + string rest = PrepareValue(content.Substring(consumed)); + _pos++; + result[key] = ParseValue(rest, 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."); + } + + string item = line.Content.Length > 1 ? line.Content.Substring(2) : string.Empty; + int itemIndent = indent + 2; + _pos++; + + string trimmedItem = item.TrimStart(); + 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) : null); + continue; + } + + itemIndent = indent + 2 + (item.Length - trimmedItem.Length); + if (IsSequenceEntry(trimmedItem) || (trimmedItem[0] is not ('"' or '\'' or '[' or '{' or '|' or '>' or '&' or '*' or '!') && FindKeySeparator(trimmedItem) >= 0)) + { + // 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; + } + + result.Add(ParseValue(PrepareValue(trimmedItem), 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, 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); + } + + 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 '[': + return ParseFlowSequence(rest, lineNumber); + + case '{': + return ParseFlowMapping(rest, lineNumber); + + case '"' or '\'': + string quoted = ParseQuotedScalar(rest, 0, lineNumber, out int consumed); + if (StripComment(rest.Substring(consumed)).Trim().Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value."); + } + + return JsonValue.Create(quoted); + + default: + if (rest[0] is '@' or '`' or '%') + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{rest[0]}', which YAML reserves. Quote the value."); + } + + 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."); + } + + if (FindKeySeparator(rest) >= 0) + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally."); + } + + return ResolvePlainScalar(CollectPlainContinuation(rest, parentIndent, lineNumber), lineNumber); + } + } + + /// Folds the more-indented continuation lines of a plain multi-line scalar into it. + private string CollectPlainContinuation(string first, int parentIndent, int lineNumber) + { + 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 text = StripComment(line.Content).Trim(); + 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++; + } + + // 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().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; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + raw.Add(string.Empty); + _pos++; + continue; + } + + if (line.Indent <= parentIndent) + { + break; + } + + if (contentIndent < 0) + { + contentIndent = line.Indent; + } + 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] == ' '; + 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 FormatException($"Line {lineNumber}: unterminated flow sequence; multi-line flow collections are not supported."); + } + + if (text[pos] == ']') + { + pos++; + break; + } + + result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]")); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ',') + { + pos++; + } + else if (pos >= text.Length || text[pos] != ']') + { + throw new FormatException($"Line {lineNumber}: expected ',' or ']' in flow sequence."); + } + } + + if (StripComment(text.Substring(pos)).Trim().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 FormatException($"Line {lineNumber}: unterminated flow mapping; multi-line flow collections are not supported."); + } + + if (text[pos] == '}') + { + pos++; + break; + } + + var keyNode = ParseFlowScalar(text, ref pos, lineNumber, ":"); + string key = keyNode is JsonValue v && v.TryGetValue(out string? s) ? s : keyNode?.ToJsonString() ?? "null"; + pos = SkipSpaces(text, pos); + if (pos >= text.Length || text[pos] != ':') + { + throw new FormatException($"Line {lineNumber}: expected ':' in flow mapping."); + } + + pos++; + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {lineNumber}: duplicate key '{key}'."); + } + + result[key] = ParseFlowScalar(text, ref pos, lineNumber, ",}"); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ',') + { + pos++; + } + else if (pos >= text.Length || text[pos] != '}') + { + throw new FormatException($"Line {lineNumber}: expected ',' or '}}' in flow mapping."); + } + } + + if (StripComment(text.Substring(pos)).Trim().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(); + if (FindKeySeparator(plain) >= 0) + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + + if (plain.Length > 0 && plain[0] is '&' or '*' or '!') + { + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + } + + if (plain.Length > 0 && plain[0] is '@' or '`' or '%') + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{plain[0]}', which YAML reserves. Quote the value."); + } + + 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 UnsupportedYamlException($"Line {lineNumber}: unterminated quoted scalar; multi-line quoted scalars are not supported."); + } + + 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; + } + + /// 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)) + { + return JsonNode.Parse((negative ? "-" : string.Empty) + body.TrimStart('0').PadLeft(1, '0')); + } + + // 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."); + } + + return JsonValue.Create(negative ? -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] == ' ') + { + pos++; + } + + return pos; + } + + /// Finds the ':' that separates a plain key from its value: the first ':' followed by a 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] == ' ')) + { + 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) + { + rest = rest.TrimStart(); + if (rest.Length > 0 && rest[0] is '"' or '\'' or '[' or '{') + { + return rest.TrimEnd(); + } + + return StripComment(rest).Trim(); + } + + /// + /// Removes a trailing comment from plain text. A comment starts at a '#' that begins the text or follows + /// whitespace; quotes inside plain text are ordinary characters, so an apostrophe never suppresses a comment. + /// + private static string StripComment(string text) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '#' && (i == 0 || text[i - 1] is ' ' or '\t')) + { + 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..9e8f2eefb --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -0,0 +1,336 @@ +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, + /// lowercase letters, digits, and hyphens, with no leading, trailing, or consecutive hyphens. + /// + public static bool IsValidSkillName(string name) + { + if (name.Length is 0 or > 64) + { + return false; + } + + char previous = '-'; + foreach (char c in name) + { + bool isAlphanumeric = c is (>= 'a' and <= 'z') or (>= '0' and <= '9'); + if (!isAlphanumeric && c != '-') + { + return false; + } + + if (c == '-' && previous == '-') + { + return false; + } + + previous = c; + } + + return previous != '-'; + } + + /// + /// 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 compacts dot segments. 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) || segment == "." || segment == "..") + { + throw new ArgumentException($"{what} '{uri}' must not contain empty, '.', or '..' path segments.", 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 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); + } + + // Beyond name and description, only the constraints the Agent Skills specification states as requirements + // are enforced: compatibility is 1 to 500 characters if provided, and metadata is a mapping. Other fields, + // and the values inside metadata, pass through verbatim. Hosts compare frontmatter field by field against + // the file and ignore fields such as allowed-tools for MCP-origin skills, so rejecting a whole skill over + // the shape of a field no host acts on would help nobody. + ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); + + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not JsonObject) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it key-value entries, or remove the key if it is not needed.", + 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/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66edf..db48715d2 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd9361..d4057e4c0 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,14 @@ services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithMcpApps(); + .WithMcpApps() + .WithSkills( + [ + McpServerSkill.Create( + "skill://aot/SKILL.md", + new System.Text.Json.Nodes.JsonObject { ["name"] = "aot", ["description"] = "An AOT-published skill." }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription: An AOT-published skill.\n---\n")]), + ]); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -41,6 +49,20 @@ 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 skill = await client.GetSkillAsync("skill://aot/SKILL.md"); +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.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..328b523de 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; @@ -53,6 +55,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/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs new file mode 100644 index 000000000..d6f25478e --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -0,0 +1,105 @@ +#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 +{ + 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 = (_, _) => new ValueTask(JsonNode.Parse(""" + { "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 } ] } ] } + """)), + }); + 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); + } + + [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); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs new file mode 100644 index 000000000..0f3501248 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -0,0 +1,178 @@ +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_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..d0826dc71 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -0,0 +1,265 @@ +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); + } + + [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..9d45e9029 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -0,0 +1,339 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; + +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); + } + + [Fact] + public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams() + { + var catalog = CreateCatalog(10, "a"); + + var exception = await Assert.ThrowsAsync( + async () => await catalog.ListAsync("not-base64!!", 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 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_PassesThroughFieldsHostsDoNotActOn() + { + // allowed-tools is experimental and hosts ignore it for MCP-origin skills; license is free-form; metadata + // values are whatever the author wrote. None of these should cost a skill its listing. + var skill = CreateSkill("alpha"); + skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + skill.Frontmatter["license"] = null; + skill.Frontmatter["metadata"] = 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 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/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs new file mode 100644 index 000000000..94138ddf4 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -0,0 +1,479 @@ +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.Same(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")]); + + // File uses valid YAML the reader does not support: the explicit object stands on its own. + 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); + } + + [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")] + public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) + { + // Only valid-but-unsupported YAML may be bypassed. A file no host can parse must not be published. + 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_SnapshotsCallerOwnedContent() + { + byte[] bytes = Encoding.UTF8.GetBytes(SkillMarkdown); + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), [new McpServerSkillFile { Path = "SKILL.md", Content = bytes }]); + string digestBefore = skill.ProtocolSkill.Resources.Resources![0].Digest; + + bytes[0] = (byte)'X'; + + // The manifest was computed from the original bytes; the served bytes must be those same bytes. The + // end-to-end check that the served content still verifies lives in McpServerSkillsSnapshotTests. + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), digestBefore); + } + +#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..2d6d85acb --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -0,0 +1,296 @@ +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 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.False(get.ContainsKey("ttlMs")); + } + + [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.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); + 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..a5956df96 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs @@ -0,0 +1,63 @@ +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 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); + } + + [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..680392ec3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -0,0 +1,145 @@ +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"); + + 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"); + + 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 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 is the nested-skill case and is allowed: the file is registered once. + var same = McpServerSkill.Create("skill://ACME/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + new ServiceCollection().AddMcpServer().WithSkills([upper, same]); + } + + [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); + } + } + +#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/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs new file mode 100644 index 000000000..053015a50 --- /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])?.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/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/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs new file mode 100644 index 000000000..b6595c460 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -0,0 +1,347 @@ +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 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("# 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---", "unterminated flow")] + [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("---\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); + } +}