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)** [](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)** [](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)** [](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)** [](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)** [](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)** [](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