diff --git a/.github/scripts/prepare_markdown_links.py b/.github/scripts/prepare_markdown_links.py
new file mode 100644
index 000000000..6bda0c192
--- /dev/null
+++ b/.github/scripts/prepare_markdown_links.py
@@ -0,0 +1,41 @@
+import argparse
+from pathlib import Path
+
+
+class RepositoryLinks:
+ def __init__(self, root):
+ self.root = Path(root).resolve()
+
+ def prepare(self):
+ docs = self.root / "docs"
+ if not docs.is_dir():
+ raise ValueError(f"Missing documentation directory: {docs}")
+ if docs.is_symlink():
+ raise ValueError(f"Refusing to rewrite a documentation symlink: {docs}")
+
+ changed = 0
+ prefixes = (
+ "https://github.com/modelcontextprotocol/csharp-sdk/tree/main/",
+ "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/",
+ )
+ for path in docs.rglob("*.md"):
+ if path.is_symlink():
+ raise ValueError(f"Refusing to rewrite a documentation symlink: {path}")
+ text = path.read_text(encoding="utf-8")
+ updated = text
+ for prefix in prefixes:
+ updated = updated.replace(prefix, "/")
+ if updated != text:
+ path.write_text(updated, encoding="utf-8")
+ changed += 1
+ return changed
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Resolve this repository's main-branch links against the proposed checkout for mlc."
+ )
+ parser.add_argument("root")
+ arguments = parser.parse_args()
+ count = RepositoryLinks(arguments.root).prepare()
+ print(f"Prepared repository-local links in {count} Markdown files; mlc still validates each target.")
diff --git a/.github/scripts/test_prepare_markdown_links.py b/.github/scripts/test_prepare_markdown_links.py
new file mode 100644
index 000000000..edfc1ee33
--- /dev/null
+++ b/.github/scripts/test_prepare_markdown_links.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+import tempfile
+import unittest
+
+from prepare_markdown_links import RepositoryLinks
+
+
+class RepositoryLinksTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.root = Path(self.directory.name)
+ (self.root / "docs").mkdir()
+
+ def test_main_links_become_checkout_paths_without_ignoring_missing_targets(self):
+ path = self.root / "docs" / "example.md"
+ path.write_text(
+ "[sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer)\n"
+ "[source](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/missing.cs)\n",
+ encoding="utf-8",
+ )
+
+ self.assertEqual(RepositoryLinks(self.root).prepare(), 1)
+ self.assertEqual(
+ path.read_text(encoding="utf-8"),
+ "[sample](/samples/SkillsServer)\n[source](/src/missing.cs)\n",
+ )
+
+ def test_other_repositories_and_pinned_revisions_remain_web_links(self):
+ path = self.root / "docs" / "example.md"
+ original = (
+ "[other](https://github.com/other/project/tree/main/sample)\n"
+ "[pinned](https://github.com/modelcontextprotocol/csharp-sdk/tree/1234567/sample)\n"
+ "[relative](other.md)\n"
+ )
+ path.write_text(original, encoding="utf-8")
+
+ self.assertEqual(RepositoryLinks(self.root).prepare(), 0)
+ self.assertEqual(path.read_text(encoding="utf-8"), original)
+
+ def test_missing_documentation_is_not_a_success(self):
+ with self.assertRaises(ValueError):
+ RepositoryLinks(self.root / "absent").prepare()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 1dbf83b0e..73e01738d 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -3,10 +3,18 @@ name: Check Markdown links
on:
push:
branches: [ "main" ]
- paths: "**.md"
+ paths:
+ - "**.md"
+ - ".github/scripts/prepare_markdown_links.py"
+ - ".github/scripts/test_prepare_markdown_links.py"
+ - ".github/workflows/markdown-link-check.yml"
pull_request:
branches: [ "main" ]
- paths: "**.md"
+ paths:
+ - "**.md"
+ - ".github/scripts/prepare_markdown_links.py"
+ - ".github/scripts/test_prepare_markdown_links.py"
+ - ".github/workflows/markdown-link-check.yml"
permissions:
contents: read
@@ -18,6 +26,11 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Resolve main-branch links against the proposed checkout
+ run: |
+ python3 -m unittest discover -s .github/scripts -p test_prepare_markdown_links.py
+ python3 .github/scripts/prepare_markdown_links.py .
+
- name: Markup Link Checker (mlc)
uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0
with:
@@ -26,6 +39,7 @@ jobs:
# Do not warn for learn.microsoft.com locale redirects.
# Do not warn for modelcontextprotocol.io homepage/spec redirects.
args: >-
+ --root-dir .
--ignore-links "xref:*"
--ignore-links "*samples/*?name=snippet_*"
--do-not-warn-for-redirect-to "https://learn.microsoft.com/en-us/*"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3b67c7093..c8f4191be 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -97,4 +97,9 @@
+
+
+
+
+
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..a2e66b872
--- /dev/null
+++ b/docs/concepts/skills/skills.md
@@ -0,0 +1,364 @@
+---
+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. The
+ , ,
+ , ,
+ and properties read the string fields the Agent Skills
+ specification defines.
+- `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size
+ (), or the string `"dynamic"` for generated content
+ that cannot be digested.
+
+A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each
+one against the manifest. Before loading a skill, it also parses the fetched `SKILL.md` and compares its
+frontmatter with the entry's; digest verification does not cover that. That verification, and the rule that an
+unlisted file is a change to the skill, is what lets a user's approval bind to specific content.
+
+Keeping the entry and the file in agreement is the server's job, and checking that they agree is the host's:
+
+| Who | Responsibility |
+|---|---|
+| Server, using `McpServerSkill` | Frontmatter is read from `SKILL.md` and the manifest computed from the served bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. |
+| Server, using a custom catalog | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. |
+| Host | Verifies each file against the manifest, then parses the fetched `SKILL.md` and compares its frontmatter with the entry before loading. |
+
+### Serving skills
+
+The simplest way to serve skills is to keep each one in its own directory, as the Agent Skills specification
+lays them out, and point `WithSkillsFromDirectory` at the parent:
+
+```csharp
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+
+builder.Services
+ .AddMcpServer()
+ .WithHttpTransport()
+ .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options =>
+ {
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ });
+```
+
+Every included immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each
+`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources,
+and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix`
+such as `skill://acme/billing/` to place the skills under an organizational path.
+Names used as URI path segments are percent-encoded. The SDK decodes the final name segment once
+when comparing it with frontmatter; the supplied URI text remains the skill's identifier. Nested skills
+sharing a file must use the identical URI text, including its encoding, so verified reads name the same resource.
+Native absolute resource URIs such as `repo:/skills/refunds/SKILL.md` and `repo:skills/refunds/SKILL.md`
+are also supported; an absolute resource URI does not need an authority introduced by `://`.
+
+Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents
+or counting them toward the published manifest's limits. The `includeHiddenAndSystemFiles: true` overloads of
+`WithSkillsFromDirectory` and `McpServerSkill.CreateFromDirectory` provide an explicit opt-in. For a narrower
+selection, use `McpServerSkill.Create` with explicit files; that API keeps the files the caller selected.
+Included symbolic links remain errors rather than being followed.
+
+For finer control, build skills individually with and
+register them with `WithSkills`:
+
+```csharp
+var gitWorkflow = McpServerSkill.CreateFromDirectory(Path.Combine(skillsRoot, "git-workflow"));
+
+var refunds = McpServerSkill.Create(
+ "skill://acme/billing/refunds/SKILL.md",
+ [
+ McpServerSkillFile.FromText("SKILL.md", skillMarkdown),
+ McpServerSkillFile.FromText("examples/approved.md", approvedTemplate),
+ new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes },
+ ]);
+
+builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]);
+```
+
+Register skills once per server. `WithSkills` and `WithSkillsFromDirectory` install the `skills/list` and `skills/get`
+handlers, so a second call for the same service collection throws before it
+registers anything. To serve skills from several directories or sources, pass every `McpServerSkill` to one
+`WithSkills` call, as above, or implement one `IMcpSkillCatalog` that combines them.
+
+Each skill file must be the only resource at its URI. If another resource is registered at a URI that the server's
+resource collection treats as the same (scheme and authority are compared without case), building the server's
+options throws naming the URI, whether that resource was registered before or
+after the skills. Without this check, one of the two would be served in place of the other.
+
+All of these validate structural entry and file constraints and throw with a
+specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the
+Agent Skills limit of 1024 Unicode scalar values, `compatibility` exceeds 500 Unicode scalar values,
+`license` or `allowed-tools` is not a string, 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. Every field the Agent Skills specification defines is
+type-checked: `name`, `description`, `license`, `compatibility`, and `allowed-tools` must be strings, and `metadata` a
+string-to-string map. Other frontmatter fields pass through verbatim, as the specification requires; hosts compare
+them against the file. Hosts still ignore `allowed-tools` for MCP-origin skills unless the user has approved that grant. 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.
+
+The optional property carries entry-level `_meta`.
+It is separate from the frontmatter's string-to-string `metadata` map and may contain arbitrary JSON values.
+Catalog snapshots preserve it without sharing mutable objects with callers.
+
+#### Frontmatter
+
+The `Create` and `CreateFromDirectory` overloads that do not take a `JsonObject` read the YAML frontmatter of
+`SKILL.md` with the SDK's built-in reader, which needs no YAML library. It reads a bounded subset of YAML: block
+mappings (nested up to 32 levels), block sequences, single-line flow collections, plain, quoted, and block scalars,
+and comments. The root must be a mapping. Unquoted scalars are resolved per
+the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml`
+package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by
+parsing the fetched `SKILL.md` itself with a full YAML parser 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. Hexadecimal (`0x`) and octal (`0o`) integers keep their exact
+value up to 64 bits, leading zeros included. Wider ones are reported as unsupported, because parsers disagree on
+them: `yaml.v3` reads them as strings and the `yaml` package as rounded floating-point numbers.
+
+Anchors, aliases, tags, complex (`? `) and empty mapping keys, nested and multi-line flow collections (including
+`{items:[one]}`, and any flow collection with a comment inside, since a comment runs to the end of its line),
+multi-line quoted scalars, nesting deeper than 32 levels, and hexadecimal or octal integers wider than 64 bits are
+not supported; these overloads reject them with an naming the construct rather than
+guess. For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the
+frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader
+detects, and when the reader can parse the whole block, the supplied object must match it exactly. Every overload
+also rejects, anywhere in the frontmatter, comments and quoted strings included, a raw character outside YAML's
+printable set, such as a NUL, and a raw carriage return without a line feed, NEL, LS or PS. YAML 1.2.2 allows some of
+the first kind in quoted strings, but libyaml, PyYAML and Go's `yaml.v3` reject them anywhere, and YAML parsers
+disagree on whether the second kind ends a line. Write such a character as an escape in a double-quoted string, such
+as `\0` or `\L`.
+
+The reader can stop at the first unsupported construct, though, so for such a file it may not check the rest of the
+block, and it cannot compare the block with the supplied object. The application must parse the entire frontmatter
+with a full YAML parser and supply the identical JSON; the SDK accepting it is not proof that it matches.
+
+#### Custom catalogs
+
+When skills come from a database, a file share, or a large or generated catalog, implement
+ and pass it to `WithSkills`:
+
+```csharp
+public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog
+{
+ public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ string tenant = GetTenant(context.User);
+ var (skills, nextCursor) = await repository.GetPageAsync(tenant, cursor, pageSize: 50, cancellationToken);
+ return new McpSkillPage { Skills = skills, NextCursor = nextCursor };
+ }
+
+ public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ repository.FindAsync(GetTenant(context.User), uri, cancellationToken);
+}
+```
+
+Both methods receive an with the JSON-RPC request,
+the caller's when the transport supplies one (the ASP.NET Core
+transport does), and any items that incoming-message filters attached to the request.
+
+The catalog passed to `WithSkills` is one instance shared by every request, so it must not capture scoped services,
+such as a database context, directly. A catalog that needs scoped services can create its own scope for each call
+with `IServiceScopeFactory`; such a scope is independent of the request's scope and does not share its instances.
+
+A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with
+`resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not
+read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as
+`GetAsync` answers for every skill the server serves to the caller. Throw
+ with for a
+cursor the catalog cannot interpret. is the
+built-in implementation over a fixed set of entries and can be composed into a custom one.
+
+To show different callers different skills, filter the entries first and paginate only the filtered set. Every
+page and continuation cursor then covers only skills the caller may read. Filtering a finished page while passing
+on its cursor is not enough, because the cursor can still lead to hidden skills. Building an
+`InMemoryMcpSkillCatalog` validates and copies its entries, so keep one filtered view per role or user instead of
+building a new one on every request when that is expensive:
+
+```csharp
+public sealed class RoleSkillCatalog(IReadOnlyList skills, SkillAccessPolicy policy) : IMcpSkillCatalog
+{
+ private readonly ConcurrentDictionary _viewsByRole = new();
+
+ public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ GetView(context.User).ListAsync(cursor, context, cancellationToken);
+
+ public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ GetView(context.User).GetAsync(uri, context, cancellationToken);
+
+ // Authorize before paginating: the view holds only the skills this role may read.
+ private InMemoryMcpSkillCatalog GetView(ClaimsPrincipal? user) =>
+ _viewsByRole.GetOrAdd(policy.GetRole(user), role =>
+ new InMemoryMcpSkillCatalog(skills.Where(skill => policy.CanRead(role, skill.Uri))));
+}
+```
+
+Guard `resources/read` for the skills' files with the same policy, for example with a read-resource request filter.
+
+#### Dynamic skills
+
+A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to
+ and serve it through a catalog. Such a skill
+offers no content integrity, and hosts may decline to load it. A host that does load one still verifies its
+frontmatter.
+
+#### Protocol versions and caching hints
+
+`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes,
+configured through . Those attributes, and
+`resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later,
+where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in
+list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use
+the same configured hints, so use `public` only when the listing and every entry are identical for every caller.
+
+### Consuming skills
+
+The client extension methods in cover the
+host's side of the protocol:
+
+```csharp
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Nodes;
+
+if (!client.SupportsSkills())
+{
+ return; // Clients issue skills/list and skills/get only after observing the declaration.
+}
+
+// Enumerate, following pagination. The listing may be empty or partial.
+IList skills = await client.ListSkillsAsync();
+
+// Retrieve one skill by URI, for example one referenced from server instructions.
+Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md");
+
+// Read a file and verify it against the held entry's digest and size.
+ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri);
+
+// This example loads text SKILL.md resources and rejects other content forms.
+if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text)
+{
+ throw new SkillVerificationException("A text SKILL.md is required for this example.");
+}
+
+// Parse the fetched frontmatter into JSON with a full YAML parser of your choice; the SkillsClient sample uses
+// YamlDotNet. A parse failure rejects this load. Do not fall back to skill.Frontmatter.
+JsonObject fetchedFrontmatter = ParseFrontmatter(text.Text);
+if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
+{
+ throw new SkillVerificationException("The fetched frontmatter differs from the held entry.");
+}
+```
+
+`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural
+requirements and throw for an entry a host
+must not load, such as one whose manifest omits its own `SKILL.md`, carries a malformed digest, or lists a file
+outside the skill. `ReadSkillResourceAsync` throws the same exception when the
+content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the
+content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest;
+because the manifest changed, any approval bound to the previous one is revoked and must be obtained again.
+
+The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`,
+return one page or the raw result and expose the caching hints.
+ exposes the verification and digest helpers for hosts
+that read resources through other means.
+
+#### Listing limits and rejected entries
+
+The aggregating `ListSkillsAsync()` overload stops at 100 pages or 1,000 entries by default. Repeated cursors
+and exceeded limits throw `McpException`; no silently truncated list is returned. Empty cursors remain opaque
+values, not an end marker. Use explicit positive limits when appropriate:
+
+```csharp
+var skills = await client.ListSkillsAsync(maxPages: 200, maxSkills: 5000);
+```
+
+Use the paged overload for application-controlled iteration. Strict listing still rejects a page containing
+an invalid entry. To retain valid entries while reporting the others, opt into
+`ListSkillsWithDiagnosticsAsync`:
+
+```csharp
+var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams());
+foreach (var rejected in listing.RejectedEntries)
+{
+ Console.Error.WriteLine($"Skill entry {rejected.Index} was rejected: {rejected.Error.Message}");
+}
+foreach (var listedSkill in listing.Page.Skills)
+{
+ Console.WriteLine(listedSkill.Uri);
+}
+```
+
+`Page` preserves the original cursor and cache hints. `RejectedEntries` identifies each invalid entry by its
+position in the original page and retains the parsing/validation error. An empty `Page.Skills` can mean every
+entry was rejected, so callers must inspect the errors. A malformed page envelope or missing `skills` array
+still fails the request. This API does not load rejected entries or replace file/frontmatter verification.
+
+### Security considerations
+
+Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The
+specification places most of the burden on hosts. In particular:
+
+- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request
+ filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing
+ therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some
+ callers must not see some skills, implement and
+ decide from the request context's user, and guard the corresponding resources the same way. The built-in
+ in-memory catalog serves the same entries to every caller. Use one shared application policy for both
+ metadata and file access, and filter before paginating as shown in [Custom catalogs](#custom-catalogs), so
+ continuation cursors cannot disclose hidden skill URIs. Filtering a completed page while forwarding its
+ unfiltered cursor is not sufficient. Do not derive authorization from `resources/list` membership:
+ listings may intentionally be partial while an authorized direct read remains valid.
+- Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters
+ the model's context.
+- Digests are unsigned and come from the same server as the content. A match proves consistency between the entry
+ and what was fetched, not that either is trustworthy.
+- Bind any persisted per-skill approval to the entry's manifest. A later entry with a different manifest revokes it.
+- Do not honor frontmatter fields that widen the model's permissions, such as `allowed-tools`, for MCP-origin skills
+ without explicit user approval.
+- Skill names are labels, not identifiers, and are not unique across servers. Identify a skill by the pair of server
+ identity and URI.
+
+The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against
+the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows: parse
+the fetched text with a full YAML parser of its choice, convert the frontmatter to JSON, and compare it with the
+entry's frontmatter using
+,
+treating a parse failure or a difference as a verification failure. The
+[SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient) sample shows one way,
+with YamlDotNet. A host never uses the entry's frontmatter in place of parsing the file.
+
+### Not implemented
+
+The optional `resources/directory/read` method and its `directoryRead` capability setting are not implemented.
+Servers built with this package do not declare `directoryRead`, and hosts must not call the method against them.
+
+### Samples
+
+- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer): a Streamable
+ HTTP server serving two skills from directories on disk.
+- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient): a client that
+ connects to it and discovers, retrieves, and verifies them.
diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml
index 276415e13..58570780e 100644
--- a/docs/concepts/toc.yml
+++ b/docs/concepts/toc.yml
@@ -55,6 +55,8 @@ items:
uid: apps
- name: Tasks
uid: tasks
+ - name: Skills
+ uid: skills
- name: Identity and Roles
uid: identity
- name: API Reference
diff --git a/package-lock.json b/package-lock.json
index 774becc10..bf045e389 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
- "@modelcontextprotocol/conformance": "0.2.0-alpha.9",
+ "@modelcontextprotocol/conformance": "0.2.0-alpha.12",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
@@ -23,13 +23,15 @@
}
},
"node_modules/@modelcontextprotocol/conformance": {
- "version": "0.2.0-alpha.9",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz",
- "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==",
+ "version": "0.2.0-alpha.12",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.12.tgz",
+ "integrity": "sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.0",
+ "ajv": "^8.20.0",
+ "ajv-formats": "^3.0.1",
"commander": "^14.0.2",
"eventsource-parser": "^3.0.8",
"express": "^5.1.0",
@@ -134,6 +136,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.3",
@@ -303,9 +306,9 @@
}
},
"node_modules/ajv": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -628,6 +631,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -848,6 +852,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1537,6 +1542,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 577d3e1f8..abe1fc863 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
- "@modelcontextprotocol/conformance": "0.2.0-alpha.9",
+ "@modelcontextprotocol/conformance": "0.2.0-alpha.12",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs
new file mode 100644
index 000000000..f5703065d
--- /dev/null
+++ b/samples/SkillsClient/Program.cs
@@ -0,0 +1,147 @@
+// 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, page by page, showing each page's caching hints with its entries.
+// 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions.
+// 4. Verify fetched bytes against the manifest, then parse SKILL.md frontmatter with YamlDotNet and compare it.
+// 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;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Nodes;
+using YamlDotNet.Core;
+
+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,
+}));
+
+// A server using server/discover may omit its optional identity, in which case ServerInfo is null.
+string serverName = client.ServerInfo is { } serverInfo ? $"{serverInfo.Name} {serverInfo.Version}" : "the server";
+Console.WriteLine($"Connected to {serverName} 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.
+// The per-page overload returns each page's caching hints with its entries. Only a missing cursor ends the
+// listing, so bound the number of pages and refuse a repeated cursor, as client.ListSkillsAsync() does when
+// the hints are not needed.
+Console.WriteLine("=== skills/list ===");
+const int MaxPages = 100;
+var requestParams = new ListSkillsRequestParams();
+var seenCursors = new HashSet(StringComparer.Ordinal);
+for (int pageNumber = 1; ; pageNumber++)
+{
+ if (pageNumber > MaxPages)
+ {
+ throw new McpException($"The skill listing exceeded the limit of {MaxPages} pages.");
+ }
+
+ ListSkillsResult page = await client.ListSkillsAsync(requestParams);
+ Console.WriteLine($" page {pageNumber} caching hints: ttlMs={(page.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={page.CacheScope?.ToString() ?? "(none)"}");
+
+ foreach (Skill listed in page.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}");
+ }
+
+ if (page.NextCursor is null)
+ {
+ break;
+ }
+
+ if (!seenCursors.Add(page.NextCursor))
+ {
+ throw new McpException("The server repeated a skills/list pagination cursor.");
+ }
+
+ requestParams = new ListSkillsRequestParams { Cursor = page.NextCursor };
+}
+
+Console.WriteLine();
+
+// 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly.
+Console.WriteLine("=== skills/get ===");
+Console.WriteLine($" server instructions: {client.ServerInstructions}");
+const string SkillUri = "skill://git-workflow/SKILL.md";
+Skill skill = await client.GetSkillAsync(SkillUri);
+Console.WriteLine($" {skill.Uri} ({skill.Name})");
+foreach (SkillResource file in skill.Resources.Resources!)
+{
+ Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}");
+}
+
+Console.WriteLine();
+
+// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter.
+// The host must independently verify frontmatter before loading, regardless of the server's authoring path.
+// This sample parses it with YamlDotNet, a full YAML parser (see YamlFrontmatterReader.cs).
+Console.WriteLine("=== resources/read (verified) ===");
+ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri);
+if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText)
+{
+ throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification.");
+}
+
+try
+{
+ var fetchedFrontmatter = new YamlFrontmatterReader().Read(skillText.Text);
+ if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
+ {
+ throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry.");
+ }
+}
+catch (Exception exception) when (exception is YamlException or FormatException)
+{
+ // Invalid YAML, or frontmatter without a JSON form, rejects this load. The server-provided JSON is never a
+ // substitute for parsing the fetched file.
+ throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception);
+}
+
+Console.WriteLine($" {SkillUri} manifest and frontmatter verified:");
+Console.WriteLine(Indent(skillText.Text));
+
+string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri;
+ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri);
+string supportingSize = supportingFile.Contents.Single(content => content.Uri == supportingFileUri) switch
+{
+ TextResourceContents text => $"{text.Text.Length} chars",
+ BlobResourceContents blob => $"{blob.DecodedData.Length} bytes",
+ _ => "content of an unknown kind",
+};
+Console.WriteLine($" {supportingFileUri} verified ({supportingSize})");
+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..08e716b72
--- /dev/null
+++ b/samples/SkillsClient/README.md
@@ -0,0 +1,80 @@
+# 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. Pages through the catalog with `skills/list` using the per-page overload (`client.ListSkillsAsync(requestParams)`),
+ so each page's `ttlMs` and `cacheScope` hints are shown with that page's entries. Only a missing cursor ends the
+ listing; the sample stops after 100 pages and refuses a repeated cursor. When the hints are not needed,
+ `client.ListSkillsAsync()` follows the pages itself, within 100 pages and 1,000 entries by default.
+3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it
+ found in server instructions.
+4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size
+ (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, parses its frontmatter with
+ YamlDotNet and compares every field with the held entry.
+5. Shows that a file the manifest does not list is refused before any request is sent.
+
+## Run
+
+Start the [SkillsServer](../SkillsServer) sample in one terminal:
+
+```bash
+dotnet run --project samples/SkillsServer/SkillsServer.csproj
+```
+
+Then run the client in another:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj
+```
+
+To run against another Streamable HTTP server, pass its endpoint:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp
+```
+
+Expected output (abridged):
+
+```
+=== skills/list ===
+ page 1 caching hints: ttlMs=300000 cacheScope=Public
+ skill://git-workflow/SKILL.md
+ name: git-workflow
+ manifest: 3 file(s), 1523 bytes
+ skill://refunds/SKILL.md
+ name: refunds
+ ...
+
+=== skills/get ===
+ skill://git-workflow/SKILL.md (git-workflow)
+ skill://git-workflow/SKILL.md 724 bytes sha256:…
+ ...
+
+=== resources/read (verified) ===
+ skill://git-workflow/SKILL.md manifest and frontmatter verified:
+ | ---
+ | name: git-workflow
+ ...
+
+=== unlisted file ===
+ refused: 'skill://git-workflow/references/UNLISTED.md' is not listed in the manifest of skill ...
+```
+
+## Notes
+
+- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come
+ from the same server. Treat skill content as untrusted model input and tag it with its originating server.
+- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` frontmatter
+ with YamlDotNet, a full YAML parser (`YamlFrontmatterReader.cs`), converts it to JSON with YAML 1.2 core-schema
+ typing, and compares it to the entry. It rejects the skill on a difference, on invalid YAML, on a raw character the
+ server also rejects (one outside YAML's printable set, or a CR without a line feed, NEL, LS or PS, which YamlDotNet
+ would accept, drop or read as a line break), on a tag it does not convert, on frontmatter JSON cannot represent, or
+ on frontmatter past its limits on nesting depth and alias expansion, including an alias cycle. A host can use any
+ full YAML parser, but never the entry's own frontmatter.
+- This is not a complete host: approval binding, origin isolation, and permission decisions are left out.
+- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative
+ for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable.
diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj
new file mode 100644
index 000000000..7428e384c
--- /dev/null
+++ b/samples/SkillsClient/SkillsClient.csproj
@@ -0,0 +1,20 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ $(NoWarn);MCPEXP001
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/SkillsClient/YamlFrontmatterReader.cs b/samples/SkillsClient/YamlFrontmatterReader.cs
new file mode 100644
index 000000000..9a25da9fc
--- /dev/null
+++ b/samples/SkillsClient/YamlFrontmatterReader.cs
@@ -0,0 +1,390 @@
+using System.Globalization;
+using System.Text.Json.Nodes;
+using System.Text.RegularExpressions;
+using YamlDotNet.Core;
+using YamlDotNet.Core.Events;
+
+///
+/// Reads the YAML frontmatter of a fetched SKILL.md into JSON with YamlDotNet, a full YAML parser, so that the
+/// host can compare it with the frontmatter of the entry it holds.
+///
+///
+///
+/// Plain scalars are typed per the YAML 1.2 core schema (null, booleans, integers, finite floats, otherwise
+/// strings), the typing the server used for the entry. Frontmatter that JSON cannot represent, such as .inf or
+/// a key that is a collection, is rejected, and so is a tag this sample does not convert, on a scalar or a collection.
+///
+///
+/// The same server supplies the file and its manifest, so the content is untrusted. Before parsing, the reader rejects
+/// the raw characters the server's reader rejects: one outside YAML's printable set, and a lone carriage return, NEL,
+/// LS or PS. It works from YamlDotNet's parsing events rather than its recursive document model, and rejects
+/// frontmatter nested more than 32 levels deep, frontmatter that aliases expand to more than 10,000 nodes, and an
+/// alias inside the node it refers to.
+///
+///
+internal sealed class YamlFrontmatterReader
+{
+ private const string StringTag = "tag:yaml.org,2002:str";
+ private const string SequenceTag = "tag:yaml.org,2002:seq";
+ private const string MappingTag = "tag:yaml.org,2002:map";
+ private const string NotAMappingMessage = "The frontmatter must be a YAML mapping of keys to values.";
+
+ /// The deepest nesting of collections read, the same limit as the SDK's frontmatter reader.
+ private const int MaxDepth = 32;
+
+ /// The most nodes read, counting each alias as the nodes it expands to.
+ private const int MaxNodes = 10_000;
+
+ private readonly Regex _decimalInteger = new("^[-+]?[0-9]+$", RegexOptions.CultureInvariant);
+ private readonly Regex _hexInteger = new("^0x[0-9a-fA-F]+$", RegexOptions.CultureInvariant);
+ private readonly Regex _octalInteger = new("^0o[0-7]+$", RegexOptions.CultureInvariant);
+ private readonly Regex _float = new(@"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$", RegexOptions.CultureInvariant);
+ private readonly Regex _infinityOrNaN = new(@"^([-+]?\.(inf|Inf|INF)|\.(nan|NaN|NAN))$", RegexOptions.CultureInvariant);
+
+ /// The latest node defined for each anchor name; an alias refers to it.
+ private readonly Dictionary _anchors = new(StringComparer.Ordinal);
+
+ private int _nodeCount;
+
+ /// Reads the frontmatter at the start of .
+ /// The frontmatter is not valid YAML.
+ ///
+ /// The frontmatter is missing, has a raw character the server's reader also rejects, is not a mapping, has no JSON
+ /// form, is beyond this reader's limits, or cannot be read by YamlDotNet.
+ ///
+ public JsonObject Read(string skillMarkdown)
+ {
+ _anchors.Clear();
+ _nodeCount = 0;
+
+ string frontmatter = ExtractFrontmatter(skillMarkdown);
+ ThrowIfDisallowedCharacter(frontmatter);
+ try
+ {
+ return ReadDocument(new Parser(new StringReader(frontmatter)));
+ }
+ catch (InvalidOperationException e)
+ {
+ // YamlDotNet's scanner reports some input this way rather than with a YamlException, including some valid
+ // YAML, such as a comment inside a multi-line flow collection.
+ throw new FormatException("YamlDotNet could not read the frontmatter.", e);
+ }
+ }
+
+ private JsonObject ReadDocument(IParser parser)
+ {
+ parser.Consume();
+ if (!parser.TryConsume(out _) ||
+ ReadNode(parser, depth: 0).Node is not JsonObject root)
+ {
+ throw new FormatException(NotAMappingMessage);
+ }
+
+ parser.Consume();
+ parser.Consume();
+ return root;
+ }
+
+ ///
+ /// Returns the lines between the opening --- line and the next --- or ... line, each with its
+ /// line break. Keeping the last one matters: a final block scalar that keeps its trailing line breaks (|+)
+ /// reads the same as on the server.
+ ///
+ private string ExtractFrontmatter(string skillMarkdown)
+ {
+ // Like the server, drop one byte order mark; a second one is content, so the '---' check below fails.
+ string text = skillMarkdown.StartsWith('\uFEFF') ? skillMarkdown.Substring(1) : skillMarkdown;
+ string[] lines = text.Replace("\r\n", "\n").Split('\n');
+ if (lines[0].TrimEnd(' ', '\t') != "---")
+ {
+ throw new FormatException("SKILL.md must begin with a '---' line that opens the YAML frontmatter.");
+ }
+
+ for (int i = 1; i < lines.Length; i++)
+ {
+ if (lines[i].TrimEnd(' ', '\t') is "---" or "...")
+ {
+ return string.Join("\n", lines, 1, i - 1) + "\n";
+ }
+ }
+
+ throw new FormatException("The YAML frontmatter of SKILL.md is not closed by a '---' line.");
+ }
+
+ ///
+ /// Rejects the raw characters the server's reader rejects: one outside YAML's printable set (YAML 1.2.2
+ /// c-printable), and a lone carriage return, NEL, LS or PS, which YamlDotNet reads as a line break and the
+ /// server would not. Line feeds after carriage returns are already normalized, so any carriage return left is a lone
+ /// one. YamlDotNet accepts most of the others and can skip a NUL, so the check runs before parsing.
+ ///
+ private void ThrowIfDisallowedCharacter(string frontmatter)
+ {
+ for (int i = 0; i < frontmatter.Length; i++)
+ {
+ char c = frontmatter[i];
+ if (char.IsHighSurrogate(c) &&
+ i + 1 < frontmatter.Length &&
+ char.IsLowSurrogate(frontmatter[i + 1]))
+ {
+ i++;
+ }
+ else if (c is '\r' or '\u0085' or '\u2028' or '\u2029')
+ {
+ throw new FormatException($"The frontmatter contains U+{(int)c:X4}, which some YAML parsers read as a line break and others as text.");
+ }
+ else if (!IsPrintable(c))
+ {
+ throw new FormatException($"The frontmatter contains U+{(int)c:X4}, which is outside YAML's printable character set.");
+ }
+ }
+ }
+
+ /// Whether YAML's printable set contains , a character that is not a surrogate.
+ private bool IsPrintable(char c) =>
+ c is '\t' or '\n' or '\r' or (>= ' ' and <= '~') or '\u0085' or (>= '\u00A0' and <= '\uD7FF') or (>= '\uE000' and <= '\uFFFD');
+
+ ///
+ /// Reads the node at the parser's position, inside collections. Returns it with the
+ /// number of nodes it holds and how many collection levels it spans.
+ ///
+ private ReadResult ReadNode(IParser parser, int depth)
+ {
+ if (parser.TryConsume(out var alias))
+ {
+ return ExpandAlias(alias.Value.Value, depth);
+ }
+
+ if (parser.TryConsume(out var scalar))
+ {
+ CountNodes(1);
+ var value = new ReadResult(ToJsonScalar(scalar), Count: 1, Height: 0);
+ if (!scalar.Anchor.IsEmpty)
+ {
+ _anchors[scalar.Anchor.Value] = new AnchoredNode { Value = value };
+ }
+
+ return value;
+ }
+
+ if (depth == MaxDepth)
+ {
+ throw new FormatException($"The frontmatter is nested more than {MaxDepth} levels deep, which this sample does not read.");
+ }
+
+ if (parser.TryConsume(out var sequenceStart))
+ {
+ ThrowIfTagChangesCollection(sequenceStart.Tag, SequenceTag);
+ var anchored = Define(sequenceStart.Anchor);
+ CountNodes(1);
+ var array = new JsonArray();
+ int count = 1;
+ int height = 1;
+ while (!parser.TryConsume(out _))
+ {
+ var item = ReadNode(parser, depth + 1);
+ array.Add(item.Node);
+ count += item.Count;
+ height = Math.Max(height, item.Height + 1);
+ }
+
+ return Complete(anchored, new ReadResult(array, count, height));
+ }
+
+ if (parser.TryConsume(out var mappingStart))
+ {
+ ThrowIfTagChangesCollection(mappingStart.Tag, MappingTag);
+ var anchored = Define(mappingStart.Anchor);
+ CountNodes(1);
+ var mapping = new JsonObject();
+ int count = 1;
+ int height = 1;
+ while (!parser.TryConsume(out _))
+ {
+ var key = ReadNode(parser, depth + 1);
+ string name = ToJsonKey(key.Node);
+ if (mapping.ContainsKey(name))
+ {
+ throw new FormatException($"The frontmatter key '{name}' appears more than once.");
+ }
+
+ var value = ReadNode(parser, depth + 1);
+ mapping[name] = value.Node;
+ count += key.Count + value.Count;
+ height = Math.Max(height, value.Height + 1);
+ }
+
+ return Complete(anchored, new ReadResult(mapping, count, height));
+ }
+
+ throw new FormatException("The frontmatter contains a YAML node this sample does not convert.");
+ }
+
+ ///
+ /// Rejects a tag on a collection unless converting the collection keeps its meaning: the non-specific ! or
+ /// the tag for that kind of collection. !!str [one], for example, is not a list of strings.
+ ///
+ private void ThrowIfTagChangesCollection(TagName tag, string collectionTag)
+ {
+ if (tag.IsEmpty ||
+ tag.IsNonSpecific ||
+ tag.Value == collectionTag)
+ {
+ return;
+ }
+
+ throw new FormatException($"A frontmatter collection has the tag '{tag.Value}', which this sample does not convert.");
+ }
+
+ /// Copies the node an alias refers to, within the depth and node limits.
+ private ReadResult ExpandAlias(string anchor, int depth)
+ {
+ if (!_anchors.TryGetValue(anchor, out var anchored))
+ {
+ throw new FormatException($"The frontmatter alias '*{anchor}' has no anchor before it.");
+ }
+
+ // The node is still being read, so the alias is inside it and the frontmatter would refer to itself.
+ var target = anchored.Value ??
+ throw new FormatException($"The frontmatter alias '*{anchor}' is inside the node it refers to.");
+ if (depth + target.Height > MaxDepth)
+ {
+ throw new FormatException($"The frontmatter is nested more than {MaxDepth} levels deep, which this sample does not read.");
+ }
+
+ CountNodes(target.Count);
+ return target with { Node = target.Node?.DeepClone() };
+ }
+
+ /// Registers an anchor when its collection starts, so an alias inside the collection is recognized.
+ private AnchoredNode? Define(AnchorName anchor)
+ {
+ if (anchor.IsEmpty)
+ {
+ return null;
+ }
+
+ var anchored = new AnchoredNode();
+ _anchors[anchor.Value] = anchored;
+ return anchored;
+ }
+
+ private ReadResult Complete(AnchoredNode? anchored, ReadResult result)
+ {
+ if (anchored is not null)
+ {
+ anchored.Value = result;
+ }
+
+ return result;
+ }
+
+ private void CountNodes(int count)
+ {
+ _nodeCount += count;
+ if (_nodeCount > MaxNodes)
+ {
+ throw new FormatException($"The frontmatter expands to more than {MaxNodes} nodes, which this sample does not read.");
+ }
+ }
+
+ /// JSON keys are strings: null becomes the empty string, and booleans and numbers their JSON text.
+ private string ToJsonKey(JsonNode? key) => key switch
+ {
+ null => string.Empty,
+ JsonValue value when value.TryGetValue(out string? text) => text,
+ JsonValue value => value.ToJsonString(),
+ _ => throw new FormatException("A frontmatter key is a collection, which JSON cannot represent."),
+ };
+
+ private JsonNode? ToJsonScalar(Scalar scalar)
+ {
+ string text = scalar.Value;
+ if (!scalar.Tag.IsEmpty)
+ {
+ // '!' and '!!str' make the value a string; other tags are left to a host that knows them.
+ if (scalar.Tag.IsNonSpecific ||
+ scalar.Tag.Value == StringTag)
+ {
+ return JsonValue.Create(text);
+ }
+
+ throw new FormatException($"The frontmatter value '{text}' has the tag '{scalar.Tag.Value}', which this sample does not convert.");
+ }
+
+ return scalar.Style == ScalarStyle.Plain ? ResolvePlainScalar(text) : JsonValue.Create(text);
+ }
+
+ /// Types an unquoted scalar per the YAML 1.2 core schema.
+ private JsonNode? ResolvePlainScalar(string text)
+ {
+ 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 (_infinityOrNaN.IsMatch(text))
+ {
+ throw new FormatException($"The frontmatter value '{text}' cannot be represented in JSON.");
+ }
+
+ if (_decimalInteger.IsMatch(text))
+ {
+ // Exact at any size, as JSON allows; "-0" is 0.
+ bool negative = text[0] == '-';
+ string digits = text.TrimStart('-', '+').TrimStart('0');
+ return JsonNode.Parse(digits.Length == 0 ? "0" : negative ? "-" + digits : digits);
+ }
+
+ if (_hexInteger.IsMatch(text) ||
+ _octalInteger.IsMatch(text))
+ {
+ return JsonNode.Parse(ParseUnsigned(text).ToString(CultureInfo.InvariantCulture));
+ }
+
+ if (_float.IsMatch(text))
+ {
+ double value = double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture);
+ if (double.IsInfinity(value))
+ {
+ throw new FormatException($"The frontmatter value '{text}' cannot be represented as a JSON number.");
+ }
+
+ // JSON has no negative zero worth keeping.
+ return JsonValue.Create(value == 0 ? 0d : value);
+ }
+
+ return JsonValue.Create(text);
+ }
+
+ /// Reads a 0x or 0o integer exactly, up to 64 bits.
+ private ulong ParseUnsigned(string text)
+ {
+ int bitsPerDigit = text[1] == 'x' ? 4 : 3;
+ ulong value = 0;
+ foreach (char c in text.Substring(2))
+ {
+ if (value >> (64 - bitsPerDigit) != 0)
+ {
+ throw new FormatException($"The frontmatter value '{text}' is wider than 64 bits, which this sample does not convert.");
+ }
+
+ value = (value << bitsPerDigit) + (ulong)Convert.ToInt32(c.ToString(), 16);
+ }
+
+ return value;
+ }
+
+ /// A node read from the frontmatter, with the number of nodes it holds and the collection levels it spans.
+ private readonly record struct ReadResult(JsonNode? Node, int Count, int Height);
+
+ /// The node defined for an anchor; is null while its collection is still being read.
+ private sealed class AnchoredNode
+ {
+ public ReadResult? Value { get; set; }
+ }
+}
diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs
new file mode 100644
index 000000000..f16c7c0c1
--- /dev/null
+++ b/samples/SkillsServer/Program.cs
@@ -0,0 +1,40 @@
+// Demonstrates serving Agent Skills over MCP with the Skills extension (SEP-2640) from a Streamable HTTP server.
+//
+// Each skill is a directory under ./Skills containing a SKILL.md and, optionally, supporting files.
+// WithSkillsFromDirectory reads every skill directory, parses the YAML frontmatter of each SKILL.md, computes the
+// SHA-256 digest and size of every file, and registers both the skills' entries (what skills/list and skills/get
+// return) and the resources that serve their files (what resources/read returns).
+
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Every immediate subdirectory of ./Skills that contains a SKILL.md becomes a skill. The frontmatter is read from
+// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkill.CreateFromDirectory or
+// McpServerSkill.Create with WithSkills for finer control, for example an organizational URI prefix per skill.
+string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills");
+
+builder.Services
+ .AddMcpServer(options =>
+ {
+ options.ServerInfo = new Implementation { Name = "SkillsServer", Version = "1.0.0" };
+
+ // A server may point the agent at a skill directly from its instructions. A host confirms the URI with
+ // skills/get and reads it with resources/read; no listing is required.
+ options.ServerInstructions =
+ "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md.";
+ })
+ .WithHttpTransport()
+ .WithSkillsFromDirectory(skillsRoot, configure: options =>
+ {
+ // Every caller sees the same catalog, so the listing may be shared by intermediaries for a while.
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ });
+
+var app = builder.Build();
+
+app.MapMcp();
+
+app.Run();
diff --git a/samples/SkillsServer/Properties/launchSettings.json b/samples/SkillsServer/Properties/launchSettings.json
new file mode 100644
index 000000000..bd31121a4
--- /dev/null
+++ b/samples/SkillsServer/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md
new file mode 100644
index 000000000..48e247d9f
--- /dev/null
+++ b/samples/SkillsServer/README.md
@@ -0,0 +1,45 @@
+# Skills Server Sample
+
+A Streamable HTTP MCP server that serves two [Agent Skills](https://agentskills.io/) through the MCP Skills
+extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)).
+
+Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and supporting files:
+
+| Skill URI | Files |
+| ---------------------------------------- | -------------------------------------------------------------- |
+| `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` |
+| `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` |
+
+`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the
+SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the
+resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from
+its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`.
+Directory discovery excludes dot-prefixed, hidden and system entries by default. The explicit
+`includeHiddenAndSystemFiles: true` overload opts in; included links are still rejected.
+
+## Run
+
+```bash
+dotnet run --project samples/SkillsServer/SkillsServer.csproj
+```
+
+The server listens on `http://localhost:3001` (see `Properties/launchSettings.json`). Then, in another terminal,
+run the [SkillsClient](../SkillsClient) sample, which connects to it and walks through discovery, retrieval, and
+verified reads:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj
+```
+
+Any MCP host that supports Streamable HTTP can also be pointed at `http://localhost:3001`.
+
+## Notes
+
+- Skill URIs are scoped to the server that serves them. A URI may carry an organizational prefix, as in
+ `skill://acme/billing/refunds/SKILL.md`; only the final segment must equal the skill's `name`.
+- The server's instructions point the agent at `skill://git-workflow/SKILL.md` directly. A host can confirm a
+ URI with `skills/get` and read it with `resources/read` without ever listing the catalog.
+- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`.
+ A catalog that varies by principal must not do that.
+- See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough,
+ including how to implement a custom `IMcpSkillCatalog`.
diff --git a/samples/SkillsServer/Skills/git-workflow/SKILL.md b/samples/SkillsServer/Skills/git-workflow/SKILL.md
new file mode 100644
index 000000000..663f1a173
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/SKILL.md
@@ -0,0 +1,25 @@
+---
+name: git-workflow
+description: Follow this team's Git conventions for branching, commit messages, and pull requests.
+license: MIT
+---
+
+# Git workflow
+
+Use this skill whenever you create branches, commits, or pull requests in this repository.
+
+## Branches
+
+- Branch from `main`.
+- Name branches `/`, where `` is one of `feat`, `fix`, `docs`, or `chore`.
+
+## Commits
+
+Write commit messages in the imperative mood, with a subject line of at most 72 characters. See
+`references/COMMIT_STYLE.md` for the full style guide and examples.
+
+## Pull requests
+
+- Open pull requests against `main`.
+- Fill in the template in `templates/PULL_REQUEST.md`.
+- Request review from at least one code owner.
diff --git a/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md
new file mode 100644
index 000000000..f4505cf9b
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md
@@ -0,0 +1,24 @@
+# Commit message style
+
+```
+:
+
+
+```
+
+- `` is one of `feat`, `fix`, `docs`, `refactor`, `test`, or `chore`.
+- `` is imperative ("Add", not "Added" or "Adds") and does not end with a period.
+- The body explains what changed and why, wrapped at 72 columns. Reference issues as `Fixes #123`.
+
+## Examples
+
+```
+feat: Add keyset pagination to the skills catalog
+
+Offset cursors skipped entries when a skill was removed between pages.
+Encode the last URI of each page instead. Fixes #42.
+```
+
+```
+docs: Clarify that frontmatter must match SKILL.md verbatim
+```
diff --git a/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md
new file mode 100644
index 000000000..c0947267b
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md
@@ -0,0 +1,16 @@
+## Summary
+
+
+
+## Changes
+
+-
+
+## Testing
+
+
+
+## Checklist
+
+- [ ] Tests added or updated
+- [ ] Documentation updated
diff --git a/samples/SkillsServer/Skills/refunds/SKILL.md b/samples/SkillsServer/Skills/refunds/SKILL.md
new file mode 100644
index 000000000..50853ef59
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/SKILL.md
@@ -0,0 +1,18 @@
+---
+name: refunds
+description: Process customer refund requests per company policy.
+metadata:
+ owner: billing-team
+ version: 2.1.0
+---
+
+# Refunds
+
+Use this skill when a customer asks for a refund.
+
+1. Confirm the order is within the 30-day refund window.
+2. Check whether the item is refundable under `policy/REFUND_POLICY.md`.
+3. If it is, issue the refund and reply using `examples/approved.md`.
+4. If it is not, reply using `examples/declined.md` and offer store credit where the policy allows it.
+
+Never issue a refund above 500 USD without a second approval.
diff --git a/samples/SkillsServer/Skills/refunds/examples/approved.md b/samples/SkillsServer/Skills/refunds/examples/approved.md
new file mode 100644
index 000000000..a7b966133
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/examples/approved.md
@@ -0,0 +1,9 @@
+Subject: Your refund has been issued
+
+Hi {customer_name},
+
+We've issued a refund of {amount} for order {order_id}. It should appear on your original payment method within
+5 to 10 business days.
+
+Thanks for your patience,
+{agent_name}
diff --git a/samples/SkillsServer/Skills/refunds/examples/declined.md b/samples/SkillsServer/Skills/refunds/examples/declined.md
new file mode 100644
index 000000000..a8b53f7cc
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/examples/declined.md
@@ -0,0 +1,9 @@
+Subject: About your refund request
+
+Hi {customer_name},
+
+Unfortunately order {order_id} isn't eligible for a refund because {reason}. As an alternative, we'd be glad to
+offer you store credit of {credit_amount}.
+
+Let us know how you'd like to proceed,
+{agent_name}
diff --git a/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md
new file mode 100644
index 000000000..f88ee1afa
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md
@@ -0,0 +1,8 @@
+# Refund policy
+
+| Category | Refundable | Window | Notes |
+| ------------------- | ---------- | ------- | -------------------------------------- |
+| Physical goods | Yes | 30 days | Must be unused and in original packaging |
+| Digital downloads | No | | Store credit may be offered |
+| Subscriptions | Prorated | Anytime | Refund the unused portion |
+| Gift cards | No | | |
diff --git a/samples/SkillsServer/SkillsServer.csproj b/samples/SkillsServer/SkillsServer.csproj
new file mode 100644
index 000000000..2833a3dd7
--- /dev/null
+++ b/samples/SkillsServer/SkillsServer.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+ $(NoWarn);MCPEXP001
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index 0c3c50923..daa2f43b3 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -5,6 +5,7 @@
AfterTargets="_GetProjectReferenceVersions"
Condition="'$(MSBuildProjectName)' == 'ModelContextProtocol'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.AspNetCore'
+ Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Skills'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Tasks'">
<_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)">
diff --git a/src/ModelContextProtocol.Core/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..2bd3fc792
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs
@@ -0,0 +1,451 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Extension methods for to discover skills and verify their file manifests under the
+/// MCP Skills extension (SEP-2640).
+///
+///
+/// These methods validate entries and verify file content against manifests. They do not verify a skill's
+/// frontmatter, which a host must still do before loading it; see .
+///
+public static class McpSkillsClientExtensions
+{
+ ///
+ /// Gets whether the server declared the MCP Skills extension in its capabilities.
+ ///
+ /// The client.
+ /// if the server serves skills/list and skills/get; otherwise, .
+ /// is .
+ ///
+ /// Clients must issue skills/list and skills/get only after observing the server's declaration.
+ /// The other methods in this class throw when it is absent.
+ ///
+ public static bool SupportsSkills(this McpClient client)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+#endif
+
+ return client.ServerCapabilities?.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true;
+ }
+
+ ///
+ /// Retrieves the listed skills, following at most 100 pages and collecting at most 1,000 entries.
+ ///
+ /// The client.
+ /// The to monitor for cancellation requests. The default is .
+ /// The listed skills.
+ /// is .
+ /// The server did not declare the MCP Skills extension.
+ ///
+ /// The server returned an entry that violates the specification (for example, a name that does not match its
+ /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries.
+ ///
+ ///
+ /// The request failed, a pagination cursor repeated, or the listing exceeded the page or entry limit.
+ ///
+ ///
+ ///
+ /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than
+ /// it serves. Do not treat an empty listing as proof that a server has no skills; a skill's entry can always be
+ /// retrieved by URI with .
+ ///
+ ///
+ /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use
+ /// , which returns one page at a time.
+ /// Use to choose different limits.
+ /// Exceeding a limit fails the operation rather than returning a silently truncated listing.
+ ///
+ ///
+ public static ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) =>
+ ListSkillsAsync(client, maxPages: 100, maxSkills: 1000, cancellationToken);
+
+ ///
+ /// Retrieves the listed skills with explicit page and entry limits.
+ ///
+ /// The client.
+ /// The maximum number of pages to request. Must be positive.
+ /// The maximum number of entries to collect. Must be positive.
+ /// The token used to cancel the operation.
+ /// The complete listing within the supplied limits.
+ /// is .
+ /// A limit is not positive.
+ /// The server did not declare the Skills extension.
+ /// An entry is invalid.
+ /// The request failed, a cursor repeated, or a limit was exceeded.
+ ///
+ /// Cursors are opaque, including an empty string. Only a null cursor ends the listing.
+ /// Use the paged overload when the application needs to stream a larger listing.
+ ///
+ public static async ValueTask> ListSkillsAsync(
+ this McpClient client,
+ int maxPages,
+ int maxSkills,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+#endif
+ if (maxPages <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxPages), "The page limit must be positive.");
+ }
+ if (maxSkills <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxSkills), "The skill limit must be positive.");
+ }
+
+ var skills = new List();
+ var requestParams = new ListSkillsRequestParams();
+ var cursors = new HashSet(StringComparer.Ordinal);
+ for (int pageNumber = 0; pageNumber < maxPages; pageNumber++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false);
+ if (page.Skills.Count > maxSkills - skills.Count)
+ {
+ throw new McpException($"The skill listing exceeded the limit of {maxSkills} entries. Use the paged overload or a larger explicit limit.");
+ }
+ skills.AddRange(page.Skills);
+ if (page.NextCursor is null)
+ {
+ return skills;
+ }
+ if (!cursors.Add(page.NextCursor))
+ {
+ throw new McpException("The server repeated a skills/list pagination cursor.");
+ }
+ requestParams.Cursor = page.NextCursor;
+ }
+
+ throw new McpException($"The skill listing exceeded the limit of {maxPages} pages. Use the paged overload or a larger explicit limit.");
+ }
+
+ ///
+ /// Retrieves one page of the skills the server lists.
+ ///
+ /// The client.
+ /// The request parameters, including the cursor of the page to retrieve.
+ /// The to monitor for cancellation requests. The default is .
+ /// The page with every entry structurally validated, but not compared with the skill's files.
+ /// or is .
+ /// The server did not declare the MCP Skills extension.
+ /// The server returned an entry that violates the specification.
+ /// The request failed or the server returned an error response.
+ public static async ValueTask ListSkillsAsync(
+ this McpClient client,
+ ListSkillsRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ ThrowIfSkillsNotSupported(client, nameof(ListSkillsAsync));
+
+ JsonRpcRequest request = new()
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams),
+ };
+
+ JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
+ var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ??
+ throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'.");
+ if (result.Skills is null)
+ {
+ throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array.");
+ }
+
+ foreach (var skill in result.Skills)
+ {
+ SkillValidation.ValidateReceived(skill, SkillsProtocol.MethodSkillsList);
+ }
+
+ return result;
+ }
+
+ ///
+ /// Retrieves a page's valid entries and reports each rejected entry without discarding the rest of the page.
+ ///
+ /// The client.
+ /// The request parameters, including the page cursor.
+ /// The token used to cancel the operation.
+ /// The valid entries, original pagination/cache hints, and explicit per-entry failures.
+ /// An argument is .
+ /// The server did not declare the Skills extension.
+ /// The page envelope is malformed or lacks a skills array.
+ /// The request failed or the server returned an error.
+ ///
+ /// This is an explicit partial-results API. The ordinary list overloads remain strict.
+ /// Callers must inspect ; an empty valid-entry list can
+ /// mean that every entry was rejected. Valid entries still require normal file/frontmatter verification.
+ ///
+ public static async ValueTask ListSkillsWithDiagnosticsAsync(
+ this McpClient client,
+ ListSkillsRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+ ThrowIfSkillsNotSupported(client, nameof(ListSkillsWithDiagnosticsAsync));
+
+ var request = new JsonRpcRequest
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams),
+ };
+ var response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
+ if (response.Result is not JsonObject rawResult ||
+ rawResult["skills"] is not JsonArray entries)
+ {
+ throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array.");
+ }
+
+ var envelope = new JsonObject();
+ foreach (var property in rawResult)
+ {
+ envelope[property.Key] = property.Key == "skills" ? new JsonArray() : property.Value?.DeepClone();
+ }
+ var page = DeserializeReceived(envelope, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ??
+ throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'.");
+ var rejected = new List();
+ for (int index = 0; index < entries.Count; index++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var skill = DeserializeReceived(entries[index], McpSkillsJsonContext.Default.Skill, SkillsProtocol.MethodSkillsList);
+ SkillValidation.ValidateReceived(skill!, SkillsProtocol.MethodSkillsList);
+ page.Skills.Add(skill!);
+ }
+ catch (Exception error) when (error is JsonException or SkillVerificationException)
+ {
+ rejected.Add(new SkillEntryRejection(index, error));
+ }
+ }
+
+ return new SkillListingResult(page, rejected.AsReadOnly());
+ }
+
+ ///
+ /// Retrieves the entry for a single skill by the URI of its SKILL.md.
+ ///
+ /// The client.
+ /// The URI of the skill's SKILL.md.
+ /// The to monitor for cancellation requests. The default is .
+ /// The structurally validated skill entry, not a verification of the file's frontmatter.
+ /// or is .
+ /// The server did not declare the MCP Skills extension.
+ ///
+ /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested.
+ ///
+ ///
+ /// The request failed or the server returned an error response, including
+ /// when the server serves no skill at .
+ ///
+ ///
+ /// A server answers for every skill it serves, whether or not the skill appears in its listing. Use this to
+ /// confirm a URI referenced from server instructions or another skill, and to refresh one skill's manifest
+ /// without re-enumerating the catalog.
+ ///
+ public static async ValueTask GetSkillAsync(this McpClient client, string uri, CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(uri);
+#else
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+#endif
+
+ var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri }, cancellationToken).ConfigureAwait(false);
+ return result.Skill;
+ }
+
+ ///
+ /// Retrieves the entry for a single skill using explicit request parameters.
+ ///
+ /// The client.
+ /// The request parameters.
+ /// The to monitor for cancellation requests. The default is .
+ /// The result with its entry structurally validated, but not compared with the skill's files.
+ /// or is .
+ /// The server did not declare the MCP Skills extension.
+ ///
+ /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested.
+ ///
+ /// The request failed or the server returned an error response.
+ public static async ValueTask GetSkillAsync(
+ this McpClient client,
+ GetSkillRequestParams requestParams,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(requestParams);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (requestParams is null) throw new ArgumentNullException(nameof(requestParams));
+#endif
+
+ ThrowIfSkillsNotSupported(client, nameof(GetSkillAsync));
+
+ JsonRpcRequest request = new()
+ {
+ Method = SkillsProtocol.MethodSkillsGet,
+ Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.GetSkillRequestParams),
+ };
+
+ JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false);
+ var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.GetSkillResult, SkillsProtocol.MethodSkillsGet) ??
+ throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'.");
+
+ SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet);
+ if (!string.Equals(result.Skill.Uri, requestParams.Uri, StringComparison.Ordinal))
+ {
+ throw new SkillVerificationException(
+ $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestParams.Uri}' with the entry for '{result.Skill.Uri}'.");
+ }
+
+ return result;
+ }
+
+ ///
+ /// Reads one of a skill's files through resources/read and verifies its URI, digest and size against the manifest.
+ ///
+ /// The client.
+ /// The skill entry being acted on.
+ /// The URI of the file to read. It must be listed in 's manifest.
+ /// The to monitor for cancellation requests. The default is .
+ /// The manifest-verified contents. Frontmatter verification remains the host's responsibility.
+ /// An argument is .
+ ///
+ /// 's manifest is . Such a skill offers no content
+ /// integrity; read its files with
+ /// only if the host permits content without digest verification. Frontmatter verification is still required.
+ ///
+ ///
+ /// is not listed in the manifest, the server's response does not contain contents for
+ /// , or any returned content's size or digest does not match its manifest entry.
+ ///
+ /// The request failed or the server returned an error response.
+ ///
+ ///
+ /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is
+ /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with
+ /// and proceed from the new manifest.
+ ///
+ ///
+ /// For SKILL.md, the host must also parse the fetched frontmatter and compare it with
+ /// before loading. A digest match does not perform or replace that check.
+ ///
+ ///
+ public static async ValueTask ReadSkillResourceAsync(
+ this McpClient client,
+ Skill skill,
+ string uri,
+ CancellationToken cancellationToken = default)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(client);
+ ArgumentNullException.ThrowIfNull(skill);
+ ArgumentNullException.ThrowIfNull(uri);
+#else
+ if (client is null) throw new ArgumentNullException(nameof(client));
+ if (skill is null) throw new ArgumentNullException(nameof(skill));
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+#endif
+
+ SkillVerifier.ThrowIfDynamic(skill);
+ if (SkillVerifier.FindResource(skill, uri) is null)
+ {
+ throw new SkillVerificationException(
+ $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " +
+ "refresh the entry with skills/get before reading it.");
+ }
+
+ var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false);
+ SkillVerifier.Verify(skill, uri, result);
+ return result;
+ }
+
+ private static void ThrowIfSkillsNotSupported(McpClient client, string operationName)
+ {
+ if (!SupportsSkills(client))
+ {
+ throw new InvalidOperationException(
+ $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not.");
+ }
+ }
+
+ ///
+ /// Deserializes part of a response. An escaped unpaired surrogate such as "\uD800" is valid JSON syntax but
+ /// not text. System.Text.Json throws a for it when it reads a string from JSON text, but
+ /// an when it deserializes a , so that case is turned
+ /// into a here and fails like any other malformed entry. Other exceptions propagate
+ /// unchanged.
+ ///
+ private static T? DeserializeReceived(JsonNode? node, JsonTypeInfo typeInfo, string method)
+ {
+ try
+ {
+ return node is null ? default : node.Deserialize(typeInfo);
+ }
+ catch (InvalidOperationException error) when (ContainsUnreadableText(node))
+ {
+ throw new JsonException(
+ $"The response to '{method}' contains a string that is not valid Unicode text, such as an unpaired surrogate.",
+ error);
+ }
+ }
+
+ /// Gets whether a property name or string value in cannot be read as text.
+ private static bool ContainsUnreadableText(JsonNode? node)
+ {
+ try
+ {
+ switch (node)
+ {
+ case JsonObject jsonObject:
+ return jsonObject.Any(property => ContainsUnreadableText(property.Value));
+
+ case JsonArray jsonArray:
+ return jsonArray.Any(ContainsUnreadableText);
+
+ case JsonValue jsonValue:
+ if (jsonValue.TryGetValue(out JsonElement element) &&
+ element.ValueKind == JsonValueKind.String)
+ {
+ _ = element.GetString();
+ }
+
+ return false;
+
+ default:
+ return false;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ return true;
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs
new file mode 100644
index 000000000..2598282ad
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs
@@ -0,0 +1,23 @@
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Describes one entry rejected while reading a diagnostic listing page.
+///
+public sealed class SkillEntryRejection
+{
+ internal SkillEntryRejection(int index, Exception error)
+ {
+ Index = index;
+ Error = error;
+ }
+
+ ///
+ /// Gets the zero-based position in the server's original page.
+ ///
+ public int Index { get; }
+
+ ///
+ /// Gets the parsing or structural-validation failure. No rejected entry is returned as a valid skill.
+ ///
+ public Exception Error { get; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs
new file mode 100644
index 000000000..7aeb969dc
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs
@@ -0,0 +1,23 @@
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// A client-side listing page with valid entries and explicit per-entry failures.
+///
+public sealed class SkillListingResult
+{
+ internal SkillListingResult(ListSkillsResult page, IReadOnlyList rejectedEntries)
+ {
+ Page = page;
+ RejectedEntries = rejectedEntries;
+ }
+
+ ///
+ /// Gets the valid entries and the server's pagination and cache hints.
+ ///
+ public ListSkillsResult Page { get; }
+
+ ///
+ /// Gets the rejected entries. Callers must inspect these even when contains no skills.
+ ///
+ public IReadOnlyList RejectedEntries { get; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs
new file mode 100644
index 000000000..16653ce52
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs
@@ -0,0 +1,39 @@
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// The exception thrown when a skill entry or its file content fails validation, including a manifest mismatch
+/// or an unlisted file. Hosts can also use it for a failed frontmatter comparison.
+///
+///
+/// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale
+/// because the skill was updated after the entry was fetched. In all cases the content must not be used. To
+/// recover from staleness, fetch a fresh entry with skills/get and proceed from its manifest; because the
+/// manifest changed, any approval bound to the previous one is revoked.
+///
+public sealed class SkillVerificationException : McpException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SkillVerificationException()
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message.
+ ///
+ /// The message that describes the error.
+ public SkillVerificationException(string message) : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with a specified error message
+ /// and a reference to the inner exception that is the cause of this exception.
+ ///
+ /// The message that describes the error.
+ /// The exception that is the cause of the current exception.
+ public SkillVerificationException(string message, Exception? innerException) : base(message, innerException)
+ {
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs
new file mode 100644
index 000000000..3286d1937
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs
@@ -0,0 +1,281 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Verifies skill file content against a skill's manifest, and computes manifest digests.
+///
+///
+///
+/// When a host retrieves a file listed in a skill's manifest, it must verify the content against that entry's
+/// digest and size, and must treat a read of a file the manifest does not list as a verification failure. These
+/// helpers implement those checks.
+///
+///
+/// They do not verify frontmatter. Before loading a skill, the host must also parse the fetched SKILL.md
+/// itself with a full YAML parser and compare its frontmatter field by field with the held entry, for example with
+/// , treating any difference or any parse failure as a
+/// verification failure. The entry's own frontmatter is never a substitute for parsing the file.
+///
+///
+/// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest
+/// and the content are consistent, not that either is trustworthy.
+///
+///
+public static class SkillVerifier
+{
+ ///
+ /// Computes the manifest digest of content: sha256: followed by the lowercase hexadecimal SHA-256 of the bytes.
+ ///
+ /// The raw bytes.
+ /// The digest.
+ public static string ComputeDigest(ReadOnlySpan content)
+ {
+#if NET
+ Span hash = stackalloc byte[SHA256.HashSizeInBytes];
+ SHA256.HashData(content, hash);
+#else
+ byte[] hash;
+ using (var sha256 = SHA256.Create())
+ {
+ hash = sha256.ComputeHash(content.ToArray());
+ }
+#endif
+
+ var builder = new StringBuilder(SkillsProtocol.DigestPrefix, SkillsProtocol.DigestPrefix.Length + (hash.Length * 2));
+ foreach (byte b in hash)
+ {
+ builder.Append(ToHexChar(b >> 4)).Append(ToHexChar(b & 0xF));
+ }
+
+ return builder.ToString();
+
+ static char ToHexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10));
+ }
+
+ ///
+ /// Verifies raw content against a manifest entry.
+ ///
+ /// The manifest entry.
+ /// The bytes that were read.
+ /// is .
+ /// The size or digest of does not match .
+ public static void Verify(SkillResource expected, ReadOnlySpan content)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(expected);
+#else
+ if (expected is null) throw new ArgumentNullException(nameof(expected));
+#endif
+
+ if (content.Length != expected.Size)
+ {
+ throw new SkillVerificationException(
+ $"The content of '{expected.Uri}' is {content.Length} bytes, but its manifest entry declares {expected.Size} bytes.");
+ }
+
+ string actualDigest = ComputeDigest(content);
+ if (!string.Equals(actualDigest, expected.Digest, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new SkillVerificationException(
+ $"The content of '{expected.Uri}' has digest '{actualDigest}', but its manifest entry declares '{expected.Digest}'.");
+ }
+ }
+
+ ///
+ /// Verifies the contents returned by resources/read against a manifest entry.
+ ///
+ /// The manifest entry.
+ /// The contents that were read.
+ /// An argument is .
+ ///
+ /// The contents are not for 's URI, are neither text nor a blob, or their size or
+ /// digest does not match .
+ ///
+ ///
+ /// Text contents are hashed as their UTF-8 encoding; blob contents are hashed as their decoded bytes.
+ ///
+ public static void Verify(SkillResource expected, ResourceContents contents)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(expected);
+ ArgumentNullException.ThrowIfNull(contents);
+#else
+ if (expected is null) throw new ArgumentNullException(nameof(expected));
+ if (contents is null) throw new ArgumentNullException(nameof(contents));
+#endif
+
+ if (!string.Equals(contents.Uri, expected.Uri, StringComparison.Ordinal))
+ {
+ throw new SkillVerificationException(
+ $"The contents are for '{contents.Uri}', but verification was requested against the manifest entry for '{expected.Uri}'.");
+ }
+
+ switch (contents)
+ {
+ case TextResourceContents text:
+ if (text.Text is null)
+ {
+ throw new SkillVerificationException($"The text contents of '{contents.Uri}' carry no text and cannot be verified.");
+ }
+
+ Verify(expected, Encoding.UTF8.GetBytes(text.Text));
+ break;
+
+ case BlobResourceContents blob:
+ ReadOnlyMemory decoded;
+ try
+ {
+ decoded = blob.DecodedData;
+ }
+ catch (FormatException e)
+ {
+ throw new SkillVerificationException($"The blob contents of '{contents.Uri}' are not valid Base64.", e);
+ }
+
+ Verify(expected, decoded.Span);
+ break;
+
+ default:
+ throw new SkillVerificationException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified.");
+ }
+ }
+
+ ///
+ /// Verifies the result of a resources/read for one of a skill's files against the skill's manifest.
+ ///
+ /// The skill entry being acted on.
+ /// The result of reading a file of the skill.
+ /// An argument is .
+ /// 's manifest is , which cannot be verified.
+ ///
+ /// The result is empty, contains a null entry or 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)
+ {
+ if (contents is null)
+ {
+ throw new SkillVerificationException($"The read returned a null content entry for skill '{skill.Uri}'.");
+ }
+
+ var expected = FindResource(skill, contents.Uri) ??
+ throw new SkillVerificationException(
+ $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " +
+ "refresh the entry with skills/get before reading it.");
+
+ Verify(expected, contents);
+ }
+ }
+
+ ///
+ /// Verifies the result of reading a specific file of a skill through resources/read against the skill's
+ /// manifest, additionally requiring that the result actually contains the requested file.
+ ///
+ /// The skill entry being acted on.
+ /// The URI that was requested. It must be listed in 's manifest.
+ /// The result of the read.
+ /// An argument is .
+ /// 's manifest is , which cannot be verified.
+ ///
+ /// is not listed in the manifest, the result does not contain contents for
+ /// , or any contents in the result fail verification per .
+ ///
+ ///
+ /// Checking every returned content against the manifest is not enough on its own: a server could answer a read
+ /// of one file with another, correctly digested, file of the same skill. Binding the result to the requested
+ /// URI closes that gap.
+ ///
+ public static void Verify(Skill skill, string uri, ReadResourceResult result)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(skill);
+ ArgumentNullException.ThrowIfNull(uri);
+ ArgumentNullException.ThrowIfNull(result);
+#else
+ if (skill is null) throw new ArgumentNullException(nameof(skill));
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+ if (result is null) throw new ArgumentNullException(nameof(result));
+#endif
+
+ ThrowIfDynamic(skill);
+ if (FindResource(skill, uri) is null)
+ {
+ throw new SkillVerificationException(
+ $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " +
+ "refresh the entry with skills/get before reading it.");
+ }
+
+ Verify(skill, result);
+
+ bool found = false;
+ foreach (var contents in result.Contents)
+ {
+ found |= string.Equals(contents.Uri, uri, StringComparison.Ordinal);
+ }
+
+ if (!found)
+ {
+ throw new SkillVerificationException(
+ $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'.");
+ }
+ }
+
+ ///
+ /// Rejects a skill whose manifest is missing (the property is required but can still be assigned
+ /// ) or dynamic, neither of which has a manifest that can be digest-verified.
+ ///
+ internal static void ThrowIfDynamic(Skill skill)
+ {
+ if (skill.Resources is null)
+ {
+ throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest.", nameof(skill));
+ }
+
+ if (skill.Resources.IsDynamic)
+ {
+ throw new InvalidOperationException(
+ $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified. " +
+ $"Use {nameof(McpClient.ReadResourceAsync)} directly if unverifiable content is acceptable.");
+ }
+ }
+
+ internal static SkillResource? FindResource(Skill skill, string uri)
+ {
+ var resources = skill.Resources?.Resources;
+ if (resources is null)
+ {
+ return null;
+ }
+
+ foreach (var resource in resources)
+ {
+ if (string.Equals(resource.Uri, uri, StringComparison.Ordinal))
+ {
+ return resource;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs
new file mode 100644
index 000000000..10e451bc2
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Provides source-generated JSON serialization metadata for the MCP Skills extension types.
+///
+[JsonSourceGenerationOptions(
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+[JsonSerializable(typeof(Skill))]
+[JsonSerializable(typeof(SkillResource))]
+[JsonSerializable(typeof(SkillResources))]
+[JsonSerializable(typeof(ListSkillsRequestParams))]
+[JsonSerializable(typeof(ListSkillsResult))]
+[JsonSerializable(typeof(GetSkillRequestParams))]
+[JsonSerializable(typeof(GetSkillResult))]
+public sealed partial class McpSkillsJsonContext : JsonSerializerContext
+{
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj
new file mode 100644
index 000000000..00a0bf57f
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj
@@ -0,0 +1,55 @@
+
+
+
+ net10.0;net9.0;net8.0;netstandard2.0
+ true
+ true
+ ModelContextProtocol.Extensions.Skills
+ MCP Skills extension (SEP-2640) for the .NET Model Context Protocol (MCP) SDK
+ README.md
+
+ $(NoWarn);MCPEXP001;MCPEXP002
+
+
+
+
+
+ true
+
+
+
+
+ $(NoWarn);CS0436
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs
new file mode 100644
index 000000000..4ba7ff040
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs
@@ -0,0 +1,24 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents the parameters for a skills/get request retrieving a single skill's entry by URI.
+///
+///
+/// See the Skills extension specification
+/// for details.
+///
+public sealed class GetSkillRequestParams : RequestParams
+{
+ ///
+ /// Gets or sets the URI of the skill's SKILL.md.
+ ///
+ ///
+ /// If the URI does not identify a skill the server serves, the server returns error -32602
+ /// (Invalid params), the same code resources/read uses for unknown resources.
+ ///
+ [JsonPropertyName("uri")]
+ public required string Uri { get; set; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs
new file mode 100644
index 000000000..805ac0a9f
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs
@@ -0,0 +1,36 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents a server's response to a skills/get request, containing one skill's entry.
+///
+///
+///
+/// A server answers for every skill it serves, whether or not that skill appears in its skills/list
+/// result. The result carries no pagination cursor.
+///
+///
+/// See the Skills extension specification
+/// for details.
+///
+///
+public sealed class GetSkillResult : Result, ICacheableResult
+{
+ ///
+ /// Gets or sets the skill's entry, identical in shape and meaning to an entry of skills/list.
+ ///
+ [JsonPropertyName("skill")]
+ public required Skill Skill { get; set; }
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs
new file mode 100644
index 000000000..41bd87a95
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs
@@ -0,0 +1,12 @@
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents the parameters for a skills/list request enumerating the skills a server serves.
+///
+///
+/// See the Skills extension specification
+/// for details.
+///
+public sealed class ListSkillsRequestParams : PaginatedRequestParams;
diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs
new file mode 100644
index 000000000..5ed0973da
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs
@@ -0,0 +1,41 @@
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents a server's response to a skills/list request, containing the skills it serves.
+///
+///
+///
+/// The result may be empty or partial. A server whose skill catalog is large, generated on demand, or
+/// otherwise unenumerable may return fewer skills than it serves, and hosts must not treat an empty listing as
+/// proof that a server has no skills. Skills absent from a listing remain retrievable through skills/get.
+///
+///
+/// An entry is atomic: a skill's manifest is never split across pages.
+///
+///
+/// See the Skills extension specification
+/// for details.
+///
+///
+public sealed class ListSkillsResult : PaginatedResult, ICacheableResult
+{
+ ///
+ /// Gets or sets the skill entries.
+ ///
+ [JsonPropertyName("skills")]
+ [JsonRequired]
+ public IList Skills { get; set; } = [];
+
+ ///
+ [JsonPropertyName("ttlMs")]
+ [JsonConverter(typeof(TimeSpanMillisecondsConverter))]
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ [JsonPropertyName("cacheScope")]
+ [JsonConverter(typeof(CacheScopeConverter))]
+ public CacheScope? CacheScope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs
new file mode 100644
index 000000000..c21e886a0
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs
@@ -0,0 +1,115 @@
+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 decoded path segment preceding /SKILL.md must equal the name field of ,
+ /// so that a skill's name is recoverable from its URI alone.
+ ///
+ [JsonPropertyName("uri")]
+ public required string Uri { get; set; }
+
+ ///
+ /// Gets or sets the skill's SKILL.md YAML frontmatter, rendered verbatim as a JSON object.
+ ///
+ ///
+ /// Every field the author wrote is passed through, not a curated subset. name and description
+ /// are always present. Hosts re-parse the fetched SKILL.md and compare its frontmatter against this
+ /// object field by field, treating any discrepancy as a verification failure, so this must reproduce the
+ /// authored frontmatter exactly. Structural validation of the entry does not read the file to check that.
+ ///
+ [JsonPropertyName("frontmatter")]
+ public required JsonObject Frontmatter { get; set; }
+
+ ///
+ /// Gets or sets the skill's file manifest: an enumeration of every file with its digest and size, or
+ /// when the skill's content is generated and cannot be digested.
+ ///
+ [JsonPropertyName("resources")]
+ public required SkillResources Resources { get; set; }
+
+ ///
+ /// Gets or sets metadata reserved by the protocol for clients and servers to associate with this skill.
+ ///
+ ///
+ /// This metadata is separate from the metadata field in .
+ /// Implementations must not make assumptions about its contents.
+ ///
+ [JsonPropertyName("_meta")]
+ public JsonObject? Meta { 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");
+
+ ///
+ /// Gets the skill's license from , or if it is absent or not a string.
+ ///
+ ///
+ /// The Agent Skills specification defines it as a license name or a reference to a license file bundled with the skill.
+ ///
+ [JsonIgnore]
+ public string? License => GetFrontmatterString("license");
+
+ ///
+ /// Gets the skill's compatibility from , or if it is absent or not a string.
+ ///
+ ///
+ /// The Agent Skills specification uses it to state environment requirements, such as the intended product,
+ /// system packages, or network access.
+ ///
+ [JsonIgnore]
+ public string? Compatibility => GetFrontmatterString("compatibility");
+
+ ///
+ /// Gets the skill's allowed-tools value from , a space-separated string of tools,
+ /// or if it is absent or not a string.
+ ///
+ ///
+ /// The Agent Skills specification marks this field as experimental. A server that sets it is asking for elevated
+ /// access on the host, so the Skills extension specification requires hosts to ignore it for skills that arrive
+ /// over MCP unless the user has explicitly approved that grant for that skill.
+ ///
+ [JsonIgnore]
+ public string? AllowedTools => GetFrontmatterString("allowed-tools");
+
+ 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..8abfb96e1
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs
@@ -0,0 +1,71 @@
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Supplies the skills a server serves.
+///
+///
+///
+/// A catalog is the source of truth behind a server's skills/list and skills/get methods.
+/// serves a fixed set of entries; implement this interface directly when
+/// skills come from a database, a file share, or another source that should not be loaded up front.
+///
+///
+/// The two methods are deliberately independent. A server may enumerate only part of its catalog, or none of
+/// it, while still answering for every skill it serves by URI.
+///
+///
+/// A catalog is responsible only for the entries. The skills' files must additionally be served as ordinary
+/// resources through resources/read, since that is how hosts fetch skill content.
+///
+///
+/// Each entry's must be identical to the frontmatter of the SKILL.md it
+/// describes. The server validates an entry's structure but does not read the files to check this; building
+/// entries with is one way to keep the two in agreement.
+///
+///
+/// The skills methods do not pass through the request filters that guard the built-in resource methods, so a
+/// catalog whose skills are not visible to every caller must decide what to return from the
+/// it receives, and the corresponding resources must be guarded the same way.
+///
+///
+/// One catalog instance serves every request, so it must not capture scoped services directly. A catalog that
+/// needs them can create its own scope for each call; such a scope is independent of the request's scope.
+///
+///
+public interface IMcpSkillCatalog
+{
+ ///
+ /// Lists a page of the skills this catalog publishes.
+ ///
+ ///
+ /// An opaque cursor returned by a previous call, or to start at the first page.
+ ///
+ /// The request being answered, including the caller's identity when the transport supplies one.
+ /// The to monitor for cancellation requests.
+ ///
+ /// A page of entries, and the cursor for the following page when more entries remain. A skill's manifest is
+ /// never split across pages.
+ ///
+ ///
+ /// Returning an empty page is valid. Hosts must not treat an empty listing as proof that a server has no skills.
+ /// Throw with for a cursor this
+ /// catalog cannot interpret. A cursor is an opaque position, not a capability: a catalog may resume from a
+ /// well-formed cursor it does not recognize, as does.
+ ///
+ ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken);
+
+ ///
+ /// Gets the entry for a single skill by the URI of its SKILL.md.
+ ///
+ /// The URI of the skill's SKILL.md.
+ /// The request being answered, including the caller's identity when the transport supplies one.
+ /// The to monitor for cancellation requests.
+ ///
+ /// The skill's entry, or if this catalog does not serve a skill at
+ /// to this caller.
+ ///
+ ///
+ /// This must answer for every skill the server serves to the caller, including skills omitted from .
+ ///
+ ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken);
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs
new file mode 100644
index 000000000..ceb5387a1
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs
@@ -0,0 +1,142 @@
+using System.Text;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// An over a fixed set of skill entries held in memory.
+///
+///
+///
+/// Every entry is validated against the specification's structural requirements when the catalog is constructed.
+/// It does not read the skills' files, so each entry's frontmatter must already match its SKILL.md.
+///
+///
+/// The catalog keeps its own copy of every entry and hands out copies, so neither later changes to the objects
+/// passed to the constructor nor changes to a returned entry affect what is served. Entries are ordered by URI so that pagination is stable across calls. Cursors are
+/// keyset cursors over that order rather than offsets.
+///
+///
+/// Every caller sees the same entries. For a catalog whose contents depend on the caller, implement
+/// directly and consult .
+///
+///
+public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog
+{
+ private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+ private readonly Skill[] _ordered;
+ private readonly string[] _orderedUris;
+ private readonly Dictionary _byUri;
+ private readonly int _pageSize;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The skills this catalog serves.
+ /// The maximum number of entries returned per call. Defaults to 50.
+ /// is .
+ /// is less than 1.
+ ///
+ /// Two skills share the same URI, or an entry violates the specification's structural requirements (for example,
+ /// its name does not match its URI, its manifest omits its own SKILL.md, or a digest is malformed).
+ ///
+ public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(skills);
+ ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1);
+#else
+ if (skills is null) throw new ArgumentNullException(nameof(skills));
+ if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize));
+#endif
+
+ _pageSize = pageSize;
+ _byUri = new Dictionary(StringComparer.Ordinal);
+ foreach (var skill in skills)
+ {
+ SkillValidation.Validate(skill, nameof(skills));
+ var snapshot = SkillValidation.Snapshot(skill);
+ SkillValidation.Validate(snapshot, nameof(skills));
+
+ if (_byUri.ContainsKey(snapshot.Uri))
+ {
+ throw new ArgumentException($"Duplicate skill URI '{snapshot.Uri}'.", nameof(skills));
+ }
+
+ _byUri.Add(snapshot.Uri, snapshot);
+ }
+
+ _ordered = [.. _byUri.Values];
+ Array.Sort(_ordered, static (left, right) => string.CompareOrdinal(left.Uri, right.Uri));
+ _orderedUris = Array.ConvertAll(_ordered, static skill => skill.Uri);
+ }
+
+ ///
+ /// Gets the number of skills in this catalog.
+ ///
+ public int Count => _ordered.Length;
+
+ ///
+ public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ int start = 0;
+ if (DecodeCursor(cursor) is { } afterUri)
+ {
+ int index = Array.BinarySearch(_orderedUris, afterUri, StringComparer.Ordinal);
+ start = index >= 0 ? index + 1 : ~index;
+ }
+
+ int count = Math.Min(_pageSize, _ordered.Length - start);
+ if (count <= 0)
+ {
+ return new ValueTask(McpSkillPage.Empty);
+ }
+
+ var page = new Skill[count];
+ for (int i = 0; i < count; i++)
+ {
+ page[i] = SkillValidation.Snapshot(_ordered[start + i]);
+ }
+
+ bool hasMore = start + count < _ordered.Length;
+ return new ValueTask(new McpSkillPage
+ {
+ Skills = page,
+ NextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null,
+ });
+ }
+
+ ///
+ public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(uri);
+#else
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+#endif
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ return new ValueTask(_byUri.TryGetValue(uri, out var skill) ? SkillValidation.Snapshot(skill) : null);
+ }
+
+ private static string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri));
+
+ private static string? DecodeCursor(string? cursor)
+ {
+ if (string.IsNullOrEmpty(cursor))
+ {
+ return null;
+ }
+
+ try
+ {
+ return s_strictUtf8.GetString(Convert.FromBase64String(cursor!));
+ }
+ catch (Exception exception) when (exception is FormatException or DecoderFallbackException)
+ {
+ throw new McpProtocolException($"Invalid cursor '{cursor}'.", McpErrorCode.InvalidParams);
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs
new file mode 100644
index 000000000..653886eb2
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs
@@ -0,0 +1,722 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents a skill served by an MCP server: its entry together with the
+/// instances that serve the skill's files.
+///
+///
+///
+/// The specification requires a skill's manifest to carry the SHA-256 digest and size of every file, and a host
+/// refuses content whose bytes do not match. Building a skill through
+/// or (or their overloads) computes the manifest from the same bytes the
+/// resources serve, so the two cannot disagree.
+///
+///
+/// Overloads without explicit metadata read the frontmatter with the SDK's built-in reader and reject unsupported
+/// or malformed input rather than publish guessed metadata. The reader handles a bounded subset of YAML: block
+/// mappings and sequences, plain, quoted and block scalars, single-line flow collections, and comments. Overloads
+/// accepting a exist for YAML the reader does not support; see
+/// for what they check and what remains
+/// the caller's responsibility.
+///
+///
+/// Register skills with ,
+/// which registers both the catalog entries and the file resources, or point
+///
+/// at a directory of skills.
+///
+///
+public sealed class McpServerSkill
+{
+ private readonly Skill _entry;
+
+ private McpServerSkill(Skill entry, IReadOnlyList resources)
+ {
+ // The entry may hold the caller's frontmatter object; keep a private copy so later changes to it do not
+ // reach the skill.
+ _entry = SkillValidation.Snapshot(entry);
+ Resources = resources;
+ }
+
+ ///
+ /// Gets a copy of the skill's entry, as returned by skills/list and skills/get.
+ ///
+ ///
+ /// Each access returns a new copy. The manifest was computed from the same bytes serve;
+ /// frontmatter was read or supplied at creation. Changes to a returned copy affect neither the skill nor
+ /// what WithSkills registers.
+ ///
+ public Skill ProtocolSkill => SkillValidation.Snapshot(_entry);
+
+ ///
+ /// Gets the resources serving the skill's files, one per file, each addressable at the URI its manifest entry names.
+ ///
+ public IReadOnlyList Resources { get; }
+
+ ///
+ /// Creates a skill from its files, reading the frontmatter from SKILL.md and deriving the skill's URI
+ /// from the frontmatter's name as skill://{name}/SKILL.md.
+ ///
+ /// The skill's files. Exactly one must have the path SKILL.md.
+ /// The skill.
+ /// is .
+ ///
+ /// omits SKILL.md, contains a duplicate or unsafe path, or exceeds the
+ /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (it is malformed or uses
+ /// YAML the reader does not support) 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, decoded once, 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 (it is malformed or uses YAML the reader does not support), 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, decoded once, must equal the skill's name.
+ ///
+ ///
+ /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and
+ /// description fields and reproduce the authored frontmatter exactly.
+ ///
+ /// The skill's files. Exactly one must have the path SKILL.md.
+ /// The skill.
+ /// An argument is .
+ ///
+ /// does not end in /SKILL.md; is missing a required
+ /// field, has a name that does not match , or differs from frontmatter the reader can
+ /// read completely; or omits SKILL.md, contains a duplicate or unsafe path,
+ /// exceeds the specification's per-skill limits, or has invalid UTF-8, missing frontmatter boundaries,
+ /// or a format error detected by the reader.
+ ///
+ ///
+ ///
+ /// Prefer , which reads the frontmatter from the
+ /// file. This overload still checks UTF-8, the frontmatter boundaries, and the entry's structure, computes the
+ /// manifest from the served bytes, and rejects malformed YAML the reader detects. When the reader reads the
+ /// complete block, the supplied object must match it exactly.
+ ///
+ ///
+ /// When the reader stops at an unsupported construct, it does not examine the rest of the block, so acceptance
+ /// here proves neither that the YAML is valid nor that it matches . The caller
+ /// must have parsed the entire frontmatter with a full YAML parser and supplied the identical JSON.
+ ///
+ ///
+ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(uri);
+ ArgumentNullException.ThrowIfNull(frontmatter);
+ ArgumentNullException.ThrowIfNull(files);
+#else
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+ if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter));
+ if (files is null) throw new ArgumentNullException(nameof(files));
+#endif
+
+ return CreateCore(uri, uriPrefix: null, frontmatter, files, nameof(files));
+ }
+
+ ///
+ /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md
+ /// and deriving the skill's URI from the frontmatter's name as skill://{name}/SKILL.md.
+ ///
+ /// The skill's root directory. It must contain a SKILL.md.
+ /// The skill.
+ /// is .
+ /// does not exist.
+ ///
+ /// The directory's contents do not form a valid skill (see ),
+ /// or the directory contains a symbolic link or other reparse point.
+ ///
+ ///
+ ///
+ /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the
+ /// manifest or the served content.
+ ///
+ ///
+ /// Dot-prefixed, hidden, and system files and directories are excluded before reading or descending into them.
+ /// Use to include them explicitly, or
+ /// with an explicit file selection.
+ ///
+ ///
+ /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file
+ /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a
+ /// regular file or directory, or build the skill with and
+ /// explicit files.
+ ///
+ ///
+ public static McpServerSkill CreateFromDirectory(string directoryPath) =>
+ CreateFromDirectory(directoryPath, includeHiddenAndSystemFiles: false);
+
+ ///
+ /// Creates a skill from a directory with an explicit hidden/system-file policy.
+ ///
+ /// The skill's root directory, containing SKILL.md.
+ /// Whether to include dot-prefixed, hidden, and system entries.
+ /// The skill with its manifest computed from the selected files.
+ /// Included symbolic links remain errors. See .
+ public static McpServerSkill CreateFromDirectory(string directoryPath, bool includeHiddenAndSystemFiles)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(directoryPath);
+#else
+ if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath));
+#endif
+
+ return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null,
+ ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath));
+ }
+
+ ///
+ /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md.
+ ///
+ ///
+ /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path
+ /// segment preceding /SKILL.md, decoded once, must equal the skill's name.
+ ///
+ /// The skill's root directory. It must contain a SKILL.md.
+ /// The skill.
+ /// An argument is .
+ /// does not exist.
+ ///
+ /// The directory's contents do not form a valid skill (see ),
+ /// or the directory contains a symbolic link or other reparse point.
+ ///
+ ///
+ /// See for how files and links are handled.
+ ///
+ public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) =>
+ CreateFromDirectory(uri, directoryPath, includeHiddenAndSystemFiles: false);
+
+ ///
+ /// Creates a skill with an explicit URI and hidden/system-file policy.
+ ///
+ /// The resource URI of the skill's SKILL.md.
+ /// The skill's root directory.
+ /// Whether to include dot-prefixed, hidden, and system entries.
+ /// The skill.
+ /// Included symbolic links remain errors. See .
+ public static McpServerSkill CreateFromDirectory(string uri, string directoryPath, bool includeHiddenAndSystemFiles)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(uri);
+ ArgumentNullException.ThrowIfNull(directoryPath);
+#else
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+ if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath));
+#endif
+
+ return CreateCore(uri, uriPrefix: null, frontmatter: null,
+ ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath));
+ }
+
+ ///
+ /// Creates a skill from the visible, non-system files in a directory, recursively, with explicitly supplied frontmatter.
+ ///
+ ///
+ /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path
+ /// segment preceding /SKILL.md, decoded once, must equal the skill's name.
+ ///
+ ///
+ /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and
+ /// description fields and reproduce the authored frontmatter exactly.
+ ///
+ /// The skill's root directory. It must contain a SKILL.md.
+ /// The skill.
+ /// An argument is .
+ /// does not exist.
+ ///
+ /// The directory's contents do not form a valid skill (see ),
+ /// or the directory contains a symbolic link or other reparse point.
+ ///
+ ///
+ /// Prefer . The supplied frontmatter is checked, and remains
+ /// the caller's responsibility, as described for .
+ /// See for how files and links are handled.
+ ///
+ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) =>
+ CreateFromDirectory(uri, frontmatter, directoryPath, includeHiddenAndSystemFiles: false);
+
+ ///
+ /// Creates a skill with explicit frontmatter and a hidden/system-file policy.
+ ///
+ /// The resource URI of the skill's SKILL.md.
+ /// The fully validated JSON frontmatter, matching the file.
+ /// The skill's root directory.
+ /// Whether to include dot-prefixed, hidden, and system entries.
+ /// The skill.
+ ///
+ /// See for caller responsibilities.
+ /// Included symbolic links remain errors.
+ ///
+ public static McpServerSkill CreateFromDirectory(
+ string uri, JsonObject frontmatter, string directoryPath, bool includeHiddenAndSystemFiles)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(uri);
+ ArgumentNullException.ThrowIfNull(frontmatter);
+ ArgumentNullException.ThrowIfNull(directoryPath);
+#else
+ if (uri is null) throw new ArgumentNullException(nameof(uri));
+ if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter));
+ if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath));
+#endif
+
+ return CreateCore(uri, uriPrefix: null, frontmatter,
+ ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath));
+ }
+
+ private const string DefaultUriPrefix = "skill://";
+
+ ///
+ /// Creates a skill from a directory, deriving its URI as {uriPrefix}{name}/SKILL.md. Used by
+ /// WithSkillsFromDirectory.
+ ///
+ internal static McpServerSkill CreateFromDirectory(
+ string directoryPath, string uriPrefix, string paramName, bool includeHiddenAndSystemFiles = false) =>
+ CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath, includeHiddenAndSystemFiles), paramName);
+
+ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName)
+ {
+ // A violation found in the entry is reported against the frontmatter argument when the caller supplied
+ // one, and against the files (or directory) it was read from otherwise.
+ string entryParamName = frontmatter is null ? filesParamName : nameof(frontmatter);
+
+ // Normalize and order the files: SKILL.md first, then the rest by path, so the manifest is deterministic.
+ var normalized = new List<(string Path, McpServerSkillFile File)>();
+ var seenPaths = new HashSet(StringComparer.Ordinal);
+ foreach (var file in files)
+ {
+ if (file is null)
+ {
+ throw new ArgumentException("The skill's files must not contain null entries.", filesParamName);
+ }
+
+ string path = NormalizePath(file.Path);
+ if (!seenPaths.Add(path))
+ {
+ throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", filesParamName);
+ }
+
+ normalized.Add((path, file));
+ }
+
+ if (!seenPaths.Contains(SkillsProtocol.SkillFileName))
+ {
+ throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", filesParamName);
+ }
+
+ normalized.Sort(static (left, right) =>
+ {
+ bool leftIsSkillFile = left.Path == SkillsProtocol.SkillFileName;
+ bool rightIsSkillFile = right.Path == SkillsProtocol.SkillFileName;
+ if (leftIsSkillFile != rightIsSkillFile)
+ {
+ return leftIsSkillFile ? -1 : 1;
+ }
+
+ return string.CompareOrdinal(left.Path, right.Path);
+ });
+
+ // Snapshot every file's bytes once. The caller's ReadOnlyMemory may alias an array the caller goes
+ // on to mutate, and the digest published in the manifest must describe exactly the bytes served.
+ var contents = new byte[normalized.Count][];
+ for (int i = 0; i < normalized.Count; i++)
+ {
+ contents[i] = normalized[i].File.Content.ToArray();
+ }
+
+ // Read frontmatter from SKILL.md (always first after sorting). UTF-8, boundaries, detected format errors,
+ // and equality for completely parsed blocks are checked even when metadata was supplied. An unsupported
+ // construct can stop the reader before later content is examined. In that case the caller owns parsing
+ // and validating the entire frontmatter and supplying matching JSON; this is not full YAML validation.
+ if (!TryDecodeUtf8(contents[0], out string? skillMarkdown))
+ {
+ throw new ArgumentException($"{SkillsProtocol.SkillFileName} is not valid UTF-8.", filesParamName);
+ }
+
+ JsonObject? fileFrontmatter = null;
+ try
+ {
+ fileFrontmatter = SkillFrontmatter.Parse(skillMarkdown!);
+ }
+ catch (SkillFrontmatter.UnsupportedYamlException e) when (frontmatter is null)
+ {
+ throw new ArgumentException(
+ $"The frontmatter of {SkillsProtocol.SkillFileName} uses YAML that the frontmatter reader does not support: {e.Message} " +
+ "Supply the frontmatter explicitly with the overload that takes a JsonObject.",
+ filesParamName);
+ }
+ catch (SkillFrontmatter.UnsupportedYamlException)
+ {
+ // The application must already have validated the entire frontmatter and supplied matching JSON.
+ // Reaching an unsupported construct establishes neither whole-document validity nor equality.
+ }
+ catch (FormatException e)
+ {
+ throw new ArgumentException($"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {e.Message}", filesParamName);
+ }
+
+ if (frontmatter is null)
+ {
+ frontmatter = fileFrontmatter!;
+ }
+ else if (fileFrontmatter is not null && !JsonNode.DeepEquals(fileFrontmatter, frontmatter))
+ {
+ throw new ArgumentException(
+ $"The supplied frontmatter does not match the frontmatter in {SkillsProtocol.SkillFileName}. Hosts compare the two field by field " +
+ "and refuse the skill on any difference. Fix the mismatch, or omit the frontmatter argument to have it read from the file. " +
+ $"From the file: {fileFrontmatter.ToJsonString()} Supplied: {frontmatter.ToJsonString()}",
+ nameof(frontmatter));
+ }
+
+ if (uri is null)
+ {
+ string? name = new Skill { Uri = string.Empty, Frontmatter = frontmatter, Resources = SkillResources.Dynamic }.Name;
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new ArgumentException(
+ $"The frontmatter of {SkillsProtocol.SkillFileName} must declare a string 'name' for the skill's URI to be derived from it.",
+ filesParamName);
+ }
+
+ var uriName = uriPrefix!.EndsWith("://", StringComparison.Ordinal) ? name : Uri.EscapeDataString(name);
+ uri = uriPrefix + uriName + "/" + SkillsProtocol.SkillFileName;
+ }
+
+ string root = SkillValidation.GetSkillRoot(uri, nameof(uri));
+
+ // Build and validate the entry before creating any resources, so an invalid skill fails fast with a
+ // message about the entry rather than about a resource.
+ var manifest = new List(normalized.Count);
+ for (int i = 0; i < normalized.Count; i++)
+ {
+ manifest.Add(new SkillResource
+ {
+ Uri = root + "/" + EscapePath(normalized[i].Path),
+ Digest = SkillVerifier.ComputeDigest(contents[i]),
+ Size = contents[i].Length,
+ });
+ }
+
+ var skill = new Skill
+ {
+ Uri = uri,
+ Frontmatter = frontmatter,
+ Resources = SkillResources.FromResources(manifest),
+ };
+
+ SkillValidation.Validate(skill, entryParamName);
+
+ var resources = new McpServerResource[normalized.Count];
+ for (int i = 0; i < normalized.Count; i++)
+ {
+ var (path, file) = normalized[i];
+ bool isSkillFile = path == SkillsProtocol.SkillFileName;
+ resources[i] = CreateResource(
+ manifest[i].Uri,
+ name: isSkillFile ? skill.Name! : path,
+ description: isSkillFile ? skill.Description : null,
+ mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, contents[i])),
+ contents[i]);
+ }
+
+ return new McpServerSkill(skill, resources);
+ }
+
+ private static List ReadDirectory(string directoryPath, bool includeHiddenAndSystemFiles)
+ {
+ string fullDirectory = Path.GetFullPath(directoryPath);
+ if (!Directory.Exists(fullDirectory))
+ {
+ throw new DirectoryNotFoundException($"The skill directory '{fullDirectory}' does not exist.");
+ }
+
+ if ((File.GetAttributes(fullDirectory) & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new ArgumentException(
+ $"'{fullDirectory}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " +
+ "because a link can point outside the intended location. Pass the target directory instead.",
+ nameof(directoryPath));
+ }
+
+ if (!fullDirectory.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal))
+ {
+ fullDirectory += Path.DirectorySeparatorChar;
+ }
+
+ var files = new List();
+ long totalSize = 0;
+ CollectFiles(fullDirectory, fullDirectory, files, ref totalSize, includeHiddenAndSystemFiles);
+ return files;
+ }
+
+ ///
+ /// Walks a skill directory without following links. A symbolic link (or any other reparse point) can point
+ /// outside the skill directory, and a file reached through one would be published under a URI that looks like
+ /// it lives inside the skill. Rather than try to decide which link targets are acceptable, links are rejected.
+ ///
+ private static void CollectFiles(
+ string root, string directory, List files, ref long totalSize, bool includeHiddenAndSystemFiles)
+ {
+ foreach (string entry in Directory.EnumerateFileSystemEntries(directory))
+ {
+ if (!includeHiddenAndSystemFiles &&
+ Path.GetFileName(entry).StartsWith(".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+ FileAttributes attributes = File.GetAttributes(entry);
+ if (!includeHiddenAndSystemFiles &&
+ (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0)
+ {
+ continue;
+ }
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new ArgumentException(
+ $"'{entry}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " +
+ $"because a link can point outside the skill. Replace it with a regular file or directory, or build the skill " +
+ $"with {nameof(McpServerSkill)}.{nameof(Create)} and explicit files.",
+ "directoryPath");
+ }
+
+ if ((attributes & FileAttributes.Directory) != 0)
+ {
+ CollectFiles(root, entry, files, ref totalSize, includeHiddenAndSystemFiles);
+ continue;
+ }
+
+ // Apply the specification's per-skill limits before reading, so an oversized or overly broad directory
+ // fails with the limit named rather than after allocating everything under it. The manifest built from
+ // the bytes actually read is validated against the same limits afterwards.
+ if (files.Count >= SkillsProtocol.MaxResourcesPerSkill)
+ {
+ throw new ArgumentException(
+ $"The skill directory '{root}' contains more than {SkillsProtocol.MaxResourcesPerSkill} files, the limit per skill.",
+ "directoryPath");
+ }
+
+ long length = new FileInfo(entry).Length;
+ if (length > SkillsProtocol.MaxTotalSizeBytes - totalSize)
+ {
+ throw new ArgumentException(
+ $"The files under '{root}' total more than {SkillsProtocol.MaxTotalSizeBytes} bytes, the limit per skill.",
+ "directoryPath");
+ }
+
+ totalSize += length;
+
+ string relativePath = entry.Substring(root.Length).Replace(Path.DirectorySeparatorChar, '/');
+ if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar)
+ {
+ relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, '/');
+ }
+
+ files.Add(new McpServerSkillFile
+ {
+ Path = relativePath,
+ Content = ReadExactly(entry, length),
+ });
+ }
+ }
+
+ ///
+ /// Reads a file whose length was checked against the per-skill limits a moment ago, allocating only that
+ /// length, and fails if the file turns out to be a different size, so a file that grows or shrinks between the
+ /// check and the read can neither exceed the limit nor be served with a manifest that does not describe it.
+ ///
+ private static byte[] ReadExactly(string path, long expectedLength)
+ {
+ var buffer = new byte[expectedLength];
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read);
+ int total = 0;
+ while (total < buffer.Length)
+ {
+ int read = stream.Read(buffer, total, buffer.Length - total);
+ if (read == 0)
+ {
+ break;
+ }
+
+ total += read;
+ }
+
+ if (total != buffer.Length || stream.ReadByte() != -1)
+ {
+ throw new ArgumentException($"'{path}' changed size while it was being read.", "directoryPath");
+ }
+
+ return buffer;
+ }
+
+ ///
+ /// Percent-encodes each segment of a normalized relative path so that characters with URI syntax (such as
+ /// {, ?, #, or a space) in a file name stay literal. Without this, a file named
+ /// {name}.md would register as a resource template rather than a concrete resource.
+ ///
+ private static string EscapePath(string normalizedPath)
+ {
+ string[] segments = normalizedPath.Split('/');
+ for (int i = 0; i < segments.Length; i++)
+ {
+ segments[i] = Uri.EscapeDataString(segments[i]);
+ }
+
+ return string.Join("/", segments);
+ }
+
+ private static string NormalizePath(string? path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ throw new ArgumentException("A skill file must have a non-empty path.", nameof(McpServerSkillFile.Path));
+ }
+
+ string normalized = path!.Replace('\\', '/');
+ if (normalized.StartsWith("./", StringComparison.Ordinal))
+ {
+ normalized = normalized.Substring(2);
+ }
+
+ if (normalized.Length == 0 || normalized[0] == '/' || normalized[normalized.Length - 1] == '/')
+ {
+ throw new ArgumentException($"The skill file path '{path}' must be relative to the skill's root and must not end in a separator.", nameof(McpServerSkillFile.Path));
+ }
+
+ foreach (string segment in normalized.Split('/'))
+ {
+ if (segment.Length == 0 || segment == "." || segment == "..")
+ {
+ throw new ArgumentException($"The skill file path '{path}' must not contain empty, '.', or '..' segments.", nameof(McpServerSkillFile.Path));
+ }
+ }
+
+ return normalized;
+ }
+
+ private static McpServerResource CreateResource(string uri, string name, string? description, string mimeType, ReadOnlyMemory content)
+ {
+ // Text is served as TextResourceContents only when the bytes round-trip through UTF-8 exactly, because the
+ // host hashes the UTF-8 encoding of the text it receives and compares it against the manifest digest, which
+ // was computed over the raw bytes. Anything else is served as a blob, which round-trips by construction.
+ ResourceContents Read() =>
+ IsTextualMimeType(mimeType) && TryDecodeUtf8(content.Span, out string? text)
+ ? new TextResourceContents { Uri = uri, MimeType = mimeType, Text = text! }
+ : BlobResourceContents.FromBytes(content, uri, mimeType);
+
+ return McpServerResource.Create(Read, new McpServerResourceCreateOptions
+ {
+ UriTemplate = uri,
+ Name = name,
+ Description = description,
+ MimeType = mimeType,
+ });
+ }
+
+ private static bool TryDecodeUtf8(ReadOnlySpan bytes, out string? text)
+ {
+ try
+ {
+#if NET
+ text = s_strictUtf8.GetString(bytes);
+#else
+ text = s_strictUtf8.GetString(bytes.ToArray());
+#endif
+ return true;
+ }
+ catch (DecoderFallbackException)
+ {
+ text = null;
+ return false;
+ }
+ }
+
+ private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+
+ private static bool IsTextualMimeType(string mimeType) =>
+ mimeType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/json", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/yaml", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/toml", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/javascript", StringComparison.OrdinalIgnoreCase) ||
+ mimeType.Equals("application/x-sh", StringComparison.OrdinalIgnoreCase);
+
+ private static string GuessMimeType(string path, ReadOnlySpan content)
+ {
+ int dot = path.LastIndexOf('.');
+ string extension = dot < 0 || dot < path.LastIndexOf('/') ? string.Empty : path.Substring(dot + 1).ToLowerInvariant();
+
+ return extension switch
+ {
+ "md" or "markdown" => "text/markdown",
+ "txt" => "text/plain",
+ "csv" => "text/csv",
+ "html" or "htm" => "text/html",
+ "css" => "text/css",
+ "js" or "mjs" => "text/javascript",
+ "ts" => "text/typescript",
+ "py" => "text/x-python",
+ "cs" => "text/x-csharp",
+ "sh" or "bash" => "application/x-sh",
+ "json" => "application/json",
+ "yaml" or "yml" => "application/yaml",
+ "toml" => "application/toml",
+ "xml" => "application/xml",
+ "svg" => "image/svg+xml",
+ "png" => "image/png",
+ "jpg" or "jpeg" => "image/jpeg",
+ "gif" => "image/gif",
+ "webp" => "image/webp",
+ "pdf" => "application/pdf",
+ _ => TryDecodeUtf8(content, out _) ? "text/plain" : "application/octet-stream",
+ };
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs
new file mode 100644
index 000000000..a4f9f0040
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs
@@ -0,0 +1,52 @@
+using System.Text;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents one file of a skill authored through .
+///
+public sealed class McpServerSkillFile
+{
+ ///
+ /// Gets the file's path relative to the skill's root directory, using / as the separator
+ /// (for example, SKILL.md or references/GUIDE.md).
+ ///
+ public required string Path { get; init; }
+
+ ///
+ /// Gets the file's raw content. Its digest and size in the skill's manifest are computed from exactly these bytes.
+ ///
+ public required ReadOnlyMemory Content { get; init; }
+
+ ///
+ /// Gets the MIME type to advertise for the file's resource, or to infer one from the
+ /// file's extension.
+ ///
+ public string? MimeType { get; init; }
+
+ ///
+ /// Creates a file from UTF-8 encoded text.
+ ///
+ /// The file's path relative to the skill's root directory.
+ /// The file's content.
+ /// The MIME type to advertise, or to infer one from .
+ /// The file.
+ /// or is .
+ public static McpServerSkillFile FromText(string path, string text, string? mimeType = null)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(path);
+ ArgumentNullException.ThrowIfNull(text);
+#else
+ if (path is null) throw new ArgumentNullException(nameof(path));
+ if (text is null) throw new ArgumentNullException(nameof(text));
+#endif
+
+ return new McpServerSkillFile
+ {
+ Path = path,
+ Content = Encoding.UTF8.GetBytes(text),
+ MimeType = mimeType,
+ };
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs
new file mode 100644
index 000000000..ed1f9a55a
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs
@@ -0,0 +1,22 @@
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Represents one page of skill entries returned by .
+///
+public sealed class McpSkillPage
+{
+ ///
+ /// Gets an empty page with no following page.
+ ///
+ public static McpSkillPage Empty { get; } = new() { Skills = [] };
+
+ ///
+ /// Gets the entries in this page.
+ ///
+ public required IReadOnlyList Skills { get; init; }
+
+ ///
+ /// Gets the cursor to pass back for the following page, or when no entries remain.
+ ///
+ public string? NextCursor { get; init; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs
new file mode 100644
index 000000000..8233642d8
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs
@@ -0,0 +1,58 @@
+using ModelContextProtocol.Protocol;
+using System.Security.Claims;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Describes the request an is answering.
+///
+///
+///
+/// The skills methods are registered as raw request handlers, so they do not pass through the typed request
+/// filter pipeline that guards the built-in resource methods (for example, the ASP.NET Core authorization
+/// filters). A catalog that must not disclose every skill to every caller makes that decision itself, from the
+/// and any that incoming-message filters attached to the request.
+///
+///
+/// is populated by the ASP.NET Core transport from the HTTP request's principal. For other
+/// transports, or when no authentication is configured, it is .
+///
+///
+public sealed class McpSkillRequestContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The JSON-RPC request being answered.
+ /// is .
+ public McpSkillRequestContext(JsonRpcRequest jsonRpcRequest)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(jsonRpcRequest);
+#else
+ if (jsonRpcRequest is null) throw new ArgumentNullException(nameof(jsonRpcRequest));
+#endif
+
+ JsonRpcRequest = jsonRpcRequest;
+ }
+
+ ///
+ /// Gets the JSON-RPC request being answered.
+ ///
+ public JsonRpcRequest JsonRpcRequest { get; }
+
+ ///
+ /// Gets the authenticated user making the request, or if none is associated with it.
+ ///
+ public ClaimsPrincipal? User => JsonRpcRequest.Context?.User;
+
+ ///
+ /// Gets the per-request items that incoming-message filters attached to the request, if any.
+ ///
+ public IDictionary? Items => JsonRpcRequest.Context?.Items;
+
+ ///
+ /// Gets the protocol version the request was made under, when the transport or the request carried one.
+ ///
+ public string? ProtocolVersion => JsonRpcRequest.Context?.ProtocolVersion;
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs
new file mode 100644
index 000000000..027d7d27c
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs
@@ -0,0 +1,549 @@
+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.
+ ///
+ /// Skills are already registered for this server.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// A server has one Skills registration. Pass every skill to a single call; a second call to this method,
+ /// , or
+ /// for the same service
+ /// collection throws before it registers anything. A call that throws for any other reason registers nothing
+ /// either, so a corrected call can follow it.
+ ///
+ ///
+ /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once,
+ /// provided every skill lists it with the same digest and the identical URI text. URIs that differ only in the
+ /// case of their scheme or authority are rejected, because the server's resource collection treats them as one.
+ ///
+ ///
+ /// Each file must be the only resource at its URI. When another resource is registered at a URI the server's
+ /// resource collection treats as the same, whether before or after this call, building the server's options
+ /// throws an that names the URI, instead of serving one resource in
+ /// place of the other.
+ ///
+ ///
+ /// 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
+
+ // Before registering any file resources, so a second registration leaves the service collection unchanged.
+ ThrowIfSkillsRegistered(builder);
+
+ 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();
+
+ // The registered file resources, keyed exactly as the server's resource collection keys them.
+ var skillFiles = new McpServerResourceCollection();
+
+ // Added to the services only after every skill, the catalog, and the configuration are accepted, so a call
+ // that throws leaves no resources behind for a corrected retry to conflict with.
+ var stagedFiles = new List();
+ foreach (var skill in skills)
+ {
+ if (skill is null)
+ {
+ throw new ArgumentException("The skills must not contain null entries.", nameof(skills));
+ }
+
+ var protocolSkill = skill.ProtocolSkill;
+ entries.Add(protocolSkill);
+
+ var manifest = protocolSkill.Resources.Resources!;
+ for (int i = 0; i < manifest.Count; i++)
+ {
+ var entry = manifest[i];
+ var key = new Uri(entry.Uri, UriKind.Absolute);
+ if (registeredFiles.TryGetValue(key, out var existing))
+ {
+ if (!string.Equals(existing.Uri, entry.Uri, StringComparison.Ordinal))
+ {
+ // Even with identical content the alias cannot be served: the collection registers one
+ // resource, whose contents carry the first URI, so a verified read of the second fails.
+ throw new ArgumentException(
+ $"The file '{entry.Uri}' is equivalent to '{existing.Uri}', listed by another skill. Resource URIs are compared " +
+ "case-insensitively in their scheme and authority, so the two would be served as one. Use identical URIs for a shared file.",
+ nameof(skills));
+ }
+
+ if (!string.Equals(existing.Digest, entry.Digest, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"The file '{entry.Uri}' is listed by more than one skill with different content.",
+ nameof(skills));
+ }
+
+ continue;
+ }
+
+ registeredFiles.Add(key, (entry.Uri, entry.Digest));
+ stagedFiles.Add(skill.Resources[i]);
+ skillFiles.TryAdd(skill.Resources[i]);
+ }
+ }
+
+ WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure);
+ foreach (var file in stagedFiles)
+ {
+ builder.Services.AddSingleton(file);
+ }
+
+ // Other resources are known only once the server's options are built, so the check runs then.
+ builder.Services.AddSingleton>(
+ services => new SkillFileConflictCheck(skillFiles, services.GetServices()));
+
+ return builder;
+ }
+
+ ///
+ /// Enables MCP Skills support for every skill directory found directly under ,
+ /// serving both their entries and their files, excluding dot-prefixed, hidden, and system entries.
+ ///
+ /// The server builder.
+ ///
+ /// A directory whose immediate subdirectories are skills. Each included subdirectory containing a SKILL.md becomes
+ /// one skill; subdirectories without one are ignored.
+ ///
+ ///
+ /// The prefix of each skill's URI. A skill's SKILL.md is published at {uriPrefix}{name}/SKILL.md,
+ /// where name is the skill's frontmatter name. Defaults to skill://; use a longer prefix such as
+ /// skill://acme/billing/ to place the skills under an organizational path. Names used as path segments
+ /// are percent-encoded.
+ ///
+ /// 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.
+ ///
+ /// Skills are already registered for this server.
+ ///
+ ///
+ /// Each skill is built with : files are read once,
+ /// digests are computed from the bytes served, symbolic links are rejected, and frontmatter the SDK's reader
+ /// cannot read is an error. Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one
+ /// of that skill's files.
+ ///
+ ///
+ /// A subdirectory without a SKILL.md is not a skill and is ignored, so shared assets or documentation can
+ /// live alongside skills. A subdirectory with a SKILL.md is a skill, and a skill that fails validation is
+ /// an error rather than a skipped entry, so that a broken skill is noticed at startup instead of being silently
+ /// absent from the catalog.
+ ///
+ ///
+ /// A server has one Skills registration, so this method can be called once. To serve skills from several
+ /// directories, build them with and pass all of them to
+ /// one call.
+ /// The skills' files are registered as described there, including the check that each file is the only
+ /// resource at its URI.
+ ///
+ ///
+ /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not
+ /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core
+ /// authorization filters. When some callers must not see some skills, implement
+ /// and use , and guard
+ /// the corresponding file resources separately.
+ ///
+ ///
+ public static IMcpServerBuilder WithSkillsFromDirectory(
+ this IMcpServerBuilder builder,
+ string directoryPath,
+ string uriPrefix = "skill://",
+ Action? configure = null) =>
+ WithSkillsFromDirectory(builder, directoryPath, includeHiddenAndSystemFiles: false, uriPrefix, configure);
+
+ ///
+ /// Enables skills from immediate subdirectories with an explicit hidden/system-file policy.
+ ///
+ /// The server builder.
+ /// The directory containing skill directories.
+ /// Whether to include dot-prefixed, hidden, and system entries.
+ /// The prefix used to derive skill URIs.
+ /// An optional callback configuring the extension.
+ /// The supplied builder.
+ /// Skills are already registered for this server.
+ ///
+ /// The same selection applies to skill directories and their contents. Included symbolic links remain errors.
+ /// See .
+ ///
+ public static IMcpServerBuilder WithSkillsFromDirectory(
+ this IMcpServerBuilder builder,
+ string directoryPath,
+ bool includeHiddenAndSystemFiles,
+ string uriPrefix = "skill://",
+ Action? configure = null)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(directoryPath);
+ ArgumentNullException.ThrowIfNull(uriPrefix);
+#else
+ if (builder is null) throw new ArgumentNullException(nameof(builder));
+ if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath));
+ if (uriPrefix is null) throw new ArgumentNullException(nameof(uriPrefix));
+#endif
+
+ // Before reading the directory, so a second registration reports this rather than an error about the directory.
+ ThrowIfSkillsRegistered(builder);
+
+ if (uriPrefix.Length == 0)
+ {
+ throw new ArgumentException("The URI prefix must not be empty.", nameof(uriPrefix));
+ }
+
+ if (!uriPrefix.EndsWith("/", StringComparison.Ordinal))
+ {
+ uriPrefix += "/";
+ }
+
+ string fullDirectory = Path.GetFullPath(directoryPath);
+ if (!Directory.Exists(fullDirectory))
+ {
+ throw new DirectoryNotFoundException($"The skills directory '{fullDirectory}' does not exist.");
+ }
+
+ var skillDirectories = new List(Directory.EnumerateDirectories(fullDirectory));
+ skillDirectories.Sort(StringComparer.Ordinal);
+
+ var skills = new List();
+ foreach (string skillDirectory in skillDirectories)
+ {
+ if (!includeHiddenAndSystemFiles &&
+ Path.GetFileName(skillDirectory).StartsWith(".", StringComparison.Ordinal))
+ {
+ continue;
+ }
+ var attributes = File.GetAttributes(skillDirectory);
+ if (!includeHiddenAndSystemFiles &&
+ (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0)
+ {
+ continue;
+ }
+ // The per-skill loader rejects links inside a skill; the same rule applies to the skill directory
+ // itself, which could otherwise be a link to a directory outside the skills root.
+ if ((attributes & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new ArgumentException(
+ $"'{skillDirectory}' is a symbolic link or other reparse point. Links are not followed when loading skills, " +
+ "because a link can point outside the skills directory. Replace it with a regular directory.",
+ nameof(directoryPath));
+ }
+
+ if (!File.Exists(Path.Combine(skillDirectory, SkillsProtocol.SkillFileName)))
+ {
+ continue;
+ }
+
+ skills.Add(McpServerSkill.CreateFromDirectory(
+ skillDirectory, uriPrefix, nameof(directoryPath), includeHiddenAndSystemFiles));
+ }
+
+ if (skills.Count == 0)
+ {
+ throw new ArgumentException(
+ $"No skill directories were found under '{fullDirectory}'. Each skill must be an immediate subdirectory containing a {SkillsProtocol.SkillFileName}.",
+ nameof(directoryPath));
+ }
+
+ return WithSkills(builder, skills, configure);
+ }
+
+ ///
+ /// Enables MCP Skills support backed by the specified catalog.
+ ///
+ /// The server builder.
+ /// The catalog supplying the skills this server serves.
+ /// An optional callback that configures the extension's behavior.
+ /// The builder provided in .
+ /// or is .
+ /// Skills are already registered for this server.
+ ///
+ ///
+ /// 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 server has one Skills registration, and so one catalog. A second call to this method or to the other
+ /// WithSkills and WithSkillsFromDirectory overloads for the same service collection throws. To serve
+ /// skills from several sources, implement one that combines them.
+ ///
+ ///
+ /// 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
+
+ ThrowIfSkillsRegistered(builder);
+
+ var options = new McpSkillsOptions();
+ configure?.Invoke(options);
+
+ builder.Services.AddSingleton(new SkillsRegistrationMarker());
+ builder.Services.AddSingleton>(
+ _ => new McpSkillsConfigureOptions(catalog, options));
+
+ return builder;
+ }
+
+ ///
+ /// Fails when Skills are already registered for the builder's service collection. A second registration would
+ /// install skills/list and skills/get again, which the server rejects only when it is created, with
+ /// an error that does not mention Skills.
+ ///
+ private static void ThrowIfSkillsRegistered(IMcpServerBuilder builder)
+ {
+ bool isRegistered = builder.Services.Any(descriptor => descriptor.ServiceType == typeof(SkillsRegistrationMarker));
+ if (isRegistered)
+ {
+ throw new InvalidOperationException(
+ "Skills are already registered for this server. Call WithSkills or WithSkillsFromDirectory once: pass every " +
+ "McpServerSkill to a single WithSkills call, or implement one IMcpSkillCatalog that combines your sources.");
+ }
+ }
+
+ /// Marks a service collection whose server has Skills registered.
+ private sealed class SkillsRegistrationMarker;
+
+ ///
+ /// Fails building the server's options when another resource is registered at the URI of a Skills file. The
+ /// server's resource collection keeps one resource per URI, so one of the two would be served in place of the
+ /// other, whichever was registered first.
+ ///
+ private sealed class SkillFileConflictCheck(McpServerResourceCollection skillFiles, IEnumerable registeredResources)
+ : IPostConfigureOptions
+ {
+ public void PostConfigure(string? name, McpServerOptions options)
+ {
+ // The registered services include a resource the collection dropped because an equivalent URI came first;
+ // the collection can also hold resources that were added to it directly.
+ var resources = options.ResourceCollection is { } collection ?
+ registeredResources.Concat(collection) :
+ registeredResources;
+ foreach (var resource in resources)
+ {
+ string uri = resource.ProtocolResourceTemplate.UriTemplate;
+ if (skillFiles.TryGetPrimitive(uri, out var file) &&
+ !ReferenceEquals(file, resource))
+ {
+ throw new InvalidOperationException(
+ $"The Skills file '{file.ProtocolResourceTemplate.UriTemplate}' conflicts with another resource registered at '{uri}'. " +
+ "The server keeps one resource per URI, so one of them would be served in place of the other. " +
+ "Remove the other resource, or give it a different URI.");
+ }
+ }
+ }
+ }
+
+ private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkillsOptions skillsOptions)
+ : IConfigureOptions
+ {
+ public void Configure(McpServerOptions options)
+ {
+#if NET
+ ArgumentNullException.ThrowIfNull(options);
+#else
+ if (options is null) throw new ArgumentNullException(nameof(options));
+#endif
+
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ if (!options.Capabilities.Extensions.ContainsKey(SkillsProtocol.ExtensionId))
+ {
+ options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject();
+ }
+
+ // A server declaring the skills extension must also declare the resources capability, since skill
+ // files are read through resources/read.
+ options.Capabilities.Resources ??= new ResourcesCapability();
+
+ options.RequestHandlers ??= new List();
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Handler = HandleListSkillsAsync,
+ });
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ // RoutingNameParameter is deliberately left unset. The specification defines no Mcp-Name header
+ // mapping for skills/get, so requiring one on Streamable HTTP would reject conforming clients.
+ Method = SkillsProtocol.MethodSkillsGet,
+ Handler = HandleGetSkillAsync,
+ });
+ }
+
+ private async ValueTask HandleListSkillsAsync(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams);
+ var page = await catalog.ListAsync(requestParams?.Cursor, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false);
+ // The sealed built-in catalog validates its owned snapshots at construction.
+ if (catalog is not InMemoryMcpSkillCatalog)
+ {
+ foreach (var entry in page.Skills)
+ {
+ ValidateCatalogEntry(entry);
+ }
+ }
+
+ var result = new ListSkillsResult
+ {
+ Skills = [.. page.Skills],
+ NextCursor = page.NextCursor,
+ };
+
+ // resultType, ttlMs, and cacheScope are 2026-07-28 result fields. Earlier revisions reject them as
+ // unrecognized keys (issue #1721), so they are only emitted when the request was negotiated under
+ // 2026-07-28 or later, where ttlMs and cacheScope are required and default to the conservative
+ // "immediately stale, not shareable" values the SDK uses for the built-in list methods.
+ if (IsJuly2026OrLaterProtocolRequest(request))
+ {
+ result.ResultType = "complete";
+ result.TimeToLive = skillsOptions.TimeToLive ?? TimeSpan.Zero;
+ result.CacheScope = skillsOptions.CacheScope ?? CacheScope.Private;
+ }
+
+ return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.ListSkillsResult);
+ }
+
+ private async ValueTask HandleGetSkillAsync(JsonRpcRequest request, CancellationToken cancellationToken)
+ {
+ var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.GetSkillRequestParams);
+ if (string.IsNullOrEmpty(requestParams?.Uri))
+ {
+ throw new McpProtocolException("The 'uri' parameter is required.", McpErrorCode.InvalidParams);
+ }
+
+ var skill = await catalog.GetAsync(requestParams!.Uri, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false) ??
+ throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams);
+ if (catalog is not InMemoryMcpSkillCatalog)
+ {
+ ValidateCatalogEntry(skill);
+ }
+ if (!string.Equals(skill.Uri, requestParams.Uri, StringComparison.Ordinal))
+ {
+ throw new McpProtocolException(
+ $"The skill catalog answered a request for '{requestParams.Uri}' with the entry for '{skill.Uri}'.",
+ McpErrorCode.InternalError);
+ }
+
+ var result = new GetSkillResult { Skill = skill };
+ if (IsJuly2026OrLaterProtocolRequest(request))
+ {
+ result.ResultType = "complete";
+ result.TimeToLive = skillsOptions.TimeToLive.GetValueOrDefault();
+ result.CacheScope = skillsOptions.CacheScope.GetValueOrDefault(CacheScope.Private);
+ }
+
+ return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult);
+ }
+
+ ///
+ /// A custom catalog is trusted to answer, but not to be correct: an invalid entry is a server bug, reported
+ /// as an internal error rather than published to hosts that would have to reject it.
+ ///
+ private static void ValidateCatalogEntry(Skill? entry)
+ {
+ try
+ {
+ SkillValidation.Validate(entry!, "entry");
+ }
+ catch (ArgumentException e)
+ {
+ throw new McpProtocolException($"The skill catalog returned an invalid entry: {e.Message}", McpErrorCode.InternalError);
+ }
+ }
+
+ private static T? DeserializeParams(JsonRpcRequest request, JsonTypeInfo typeInfo) where T : class
+ {
+ if (request.Params is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return JsonSerializer.Deserialize(request.Params, typeInfo);
+ }
+ catch (JsonException e)
+ {
+ throw new McpProtocolException($"Invalid parameters for '{request.Method}': {e.Message}", McpErrorCode.InvalidParams);
+ }
+ }
+
+ ///
+ /// Returns whether the request was negotiated under the 2026-07-28 protocol revision or later. Under that
+ /// revision every request carries its protocol version; requests from an initialize-handshake
+ /// session (2025-11-25 and earlier) carry none, so a missing version means an earlier revision.
+ ///
+ private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest request) =>
+ McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request.Context?.ProtocolVersion);
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs
new file mode 100644
index 000000000..c674e1a34
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs
@@ -0,0 +1,42 @@
+using ModelContextProtocol.Protocol;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Configures the behavior of the MCP Skills extension on a server.
+///
+public sealed class McpSkillsOptions
+{
+ ///
+ /// Gets or sets the freshness hint advertised on skills/list and skills/get results.
+ ///
+ ///
+ ///
+ /// This is the base protocol's ttlMs caching attribute, with the same semantics as on
+ /// tools/list. It is a freshness hint about the returned listing or entry, not an integrity property
+ /// of the skills' content.
+ ///
+ ///
+ /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that
+ /// revision or later, a value is sent as (immediately stale),
+ /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent.
+ ///
+ ///
+ public TimeSpan? TimeToLive { get; set; }
+
+ ///
+ /// Gets or sets the cache scope advertised on skills/list and skills/get results.
+ ///
+ ///
+ ///
+ /// Set this to only when the catalog is identical for every caller.
+ /// A listing or entry that varies by principal must not be advertised as publicly cacheable.
+ ///
+ ///
+ /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that
+ /// revision or later, a value is sent as ,
+ /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent.
+ ///
+ ///
+ public CacheScope? CacheScope { get; set; }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs
new file mode 100644
index 000000000..89536f389
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs
@@ -0,0 +1,1991 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Reads a bounded subset of SKILL.md YAML frontmatter into a , without a YAML library.
+/// Server authoring uses it to read the frontmatter of the skills it builds.
+///
+///
+///
+/// This reader handles a bounded subset of YAML: block mappings, nested mappings and sequences, plain, quoted and
+/// block scalars, single-line flow collections, and comments. It is not a general-purpose YAML parser or validator.
+/// The root must be a mapping; a root that is any other node is rejected as not a mapping.
+///
+///
+/// It throws for malformed input it detects, including a raw character outside YAML's
+/// printable set, such as a NUL, or a lone carriage return, NEL, LS or PS, and
+/// for valid YAML outside its subset, such as anchors, aliases, tags, complex (? ) and empty mapping keys,
+/// nested or multi-line flow collections (a comment inside a flow collection makes it multi-line), nesting deeper
+/// than 32 levels, and hexadecimal or octal integers wider than 64 bits. Reading can stop at the first unsupported
+/// construct, so that exception says nothing about the rest of the frontmatter.
+///
+///
+/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats,
+/// otherwise strings). Hexadecimal (0x) and octal (0o) integers keep their exact value up to 64 bits,
+/// leading zeros included. Values JSON cannot represent, such as .inf, are rejected.
+///
+///
+internal static class SkillFrontmatter
+{
+ private const string Delimiter = "---";
+ private const string NotAMappingMessage = "The frontmatter must be a YAML mapping of keys to values.";
+
+ /// YAML's white space characters. Other Unicode spaces, such as U+00A0, are scalar content.
+ private static readonly char[] s_yamlWhitespace = [' ', '\t'];
+ private static readonly char[] s_flowIndicators = [',', '[', ']', '{', '}'];
+
+ /// The deepest nesting of block collections the reader accepts. Frontmatter is shallow; this only exists to keep a hostile file from exhausting the stack.
+ private const int MaxNestingDepth = 32;
+
+ ///
+ /// Thrown when the reader encounters an unsupported construct. Parsing may stop before the remaining
+ /// frontmatter is examined; this exception does not establish whole-document validity or metadata equality.
+ ///
+ internal sealed class UnsupportedYamlException(string message) : FormatException(message);
+
+ /// Thrown when a valid YAML scalar has no JSON form, such as .inf or an unpaired surrogate escape.
+ private sealed class UnrepresentableValueException(string message) : FormatException(message);
+
+ /// Thrown when a quoted scalar has no closing quote on its line; a later delimiter may indicate an unsupported multi-line form.
+ private sealed class UnterminatedQuoteException(string message, char quote) : FormatException(message)
+ {
+ public char Quote { get; } = quote;
+ }
+
+ /// Thrown when a flow collection has no closing bracket on its line; a later delimiter may indicate an unsupported multi-line form.
+ private sealed class UnterminatedFlowException(string message, char closer) : FormatException(message)
+ {
+ public char Closer { get; } = closer;
+ }
+
+ ///
+ /// Parses the frontmatter at the start of .
+ ///
+ /// The full text of a SKILL.md.
+ /// The frontmatter as a JSON object.
+ ///
+ /// The text does not begin with a ----delimited frontmatter block, the block is not a mapping, or it
+ /// contains malformed YAML or YAML this reader does not support.
+ ///
+ public static JsonObject Parse(string skillMarkdown)
+ {
+ var lines = SplitLines(skillMarkdown);
+ if (lines.Count == 0 || lines[0].TrimEnd(s_yamlWhitespace) != Delimiter)
+ {
+ throw new FormatException($"{SkillsProtocol.SkillFileName} must begin with a line containing only '{Delimiter}' that opens the YAML frontmatter.");
+ }
+
+ int end = -1;
+ for (int i = 1; i < lines.Count; i++)
+ {
+ string trimmed = lines[i].TrimEnd(s_yamlWhitespace);
+ if (trimmed == Delimiter || trimmed == "...")
+ {
+ end = i;
+ break;
+ }
+ }
+
+ if (end < 0)
+ {
+ throw new FormatException($"The YAML frontmatter of {SkillsProtocol.SkillFileName} is not closed by a line containing only '{Delimiter}'.");
+ }
+
+ var body = new List(end - 1);
+ for (int i = 1; i < end; i++)
+ {
+ ThrowIfDisallowedCharacter(lines[i], i + 1);
+ body.Add(new Line(lines[i], i + 1));
+ }
+
+ var parser = new Parser(body);
+ JsonNode? root;
+ try
+ {
+ root = parser.ParseDocument();
+ }
+ catch (UnrepresentableValueException e)
+ {
+ // The subclass only lets the parser tell a scalar root from a mapping with a broken first entry.
+ throw new FormatException(e.Message, e);
+ }
+
+ return root as JsonObject ?? throw new FormatException(NotAMappingMessage);
+ }
+
+ ///
+ /// Rejects a raw character that YAML parsers do not all read the same way. It runs on the raw lines before parsing,
+ /// so comments are checked too. That covers any character outside YAML's printable set (YAML 1.2.2
+ /// c-printable): a C0 control other than tab, line feed and carriage return, DEL, a C1 control other than NEL,
+ /// U+FFFE, U+FFFF or an unpaired surrogate. YAML 1.2.2 lets a quoted scalar hold such characters other than C0
+ /// controls, but libyaml, Go's yaml.v3 and PyYAML reject them anywhere, so this reader does too. It also covers a
+ /// lone carriage return ( removes the one before a line feed), NEL, LS and PS. YamlDotNet,
+ /// libyaml and PyYAML read all four as line breaks, as YAML 1.2.2 reads a lone carriage return, while this reader
+ /// and the npm yaml package would read them as text, so a comment could hide a key from one side. An escape
+ /// such as \0 or \L in a double-quoted scalar is not a raw character and stays valid.
+ ///
+ private static void ThrowIfDisallowedCharacter(string line, int lineNumber)
+ {
+ for (int i = 0; i < line.Length; i++)
+ {
+ char c = line[i];
+ if (char.IsHighSurrogate(c) &&
+ i + 1 < line.Length &&
+ char.IsLowSurrogate(line[i + 1]))
+ {
+ i++;
+ }
+ else if (c is '\r' or '\u0085' or '\u2028' or '\u2029')
+ {
+ throw new FormatException(
+ $"Line {lineNumber}: the frontmatter contains U+{(int)c:X4}, which some YAML parsers read as a line break and others as text. Remove it, or write it as an escape in a double-quoted string.");
+ }
+ else if (!IsPrintable(c))
+ {
+ throw new FormatException(
+ $"Line {lineNumber}: the frontmatter contains U+{(int)c:X4}, which is outside YAML's printable character set. Remove it, or write it as an escape in a double-quoted string.");
+ }
+ }
+ }
+
+ /// Whether YAML's printable set contains , a character that is not a surrogate.
+ private static bool IsPrintable(char c) =>
+ c is '\t' or '\n' or '\r' or (>= ' ' and <= '~') or '\u0085' or (>= '\u00A0' and <= '\uD7FF') or (>= '\uE000' and <= '\uFFFD');
+
+ private static List SplitLines(string text)
+ {
+ if (text.Length > 0 && text[0] == '\uFEFF')
+ {
+ text = text.Substring(1);
+ }
+
+ var lines = new List();
+ int start = 0;
+ for (int i = 0; i < text.Length; i++)
+ {
+ if (text[i] == '\n')
+ {
+ int lineEnd = i > start && text[i - 1] == '\r' ? i - 1 : i;
+ lines.Add(text.Substring(start, lineEnd - start));
+ start = i + 1;
+ }
+ }
+
+ if (start < text.Length)
+ {
+ lines.Add(text.Substring(start));
+ }
+
+ return lines;
+ }
+
+ private readonly struct Line
+ {
+ public Line(string raw, int number)
+ {
+ Raw = raw;
+ Number = number;
+
+ int indent = 0;
+ while (indent < raw.Length && raw[indent] == ' ')
+ {
+ indent++;
+ }
+
+ // Tabs cannot indent block structure, but they may separate a value from its indicator and are content
+ // inside block scalars, so a leading tab only marks the line here; the parser rejects it where it matters.
+ int contentStart = indent;
+ while (contentStart < raw.Length && raw[contentStart] is ' ' or '\t')
+ {
+ contentStart++;
+ }
+
+ Indent = indent;
+ HasTabIndentation = contentStart != indent;
+ Content = raw.Substring(contentStart);
+ IsBlank = Content.Length == 0 || Content[0] == '#';
+ }
+
+ public string Raw { get; }
+ public int Number { get; }
+ public int Indent { get; }
+ public string Content { get; }
+
+ /// Whether a tab appears in the line's leading white space, which YAML does not allow for indentation.
+ public bool HasTabIndentation { get; }
+
+ /// Whether the line is empty or a comment, and therefore structurally insignificant.
+ public bool IsBlank { get; }
+ }
+
+ private sealed class Parser(List lines)
+ {
+ private readonly List _lines = lines;
+ private int _pos;
+ private int _depth;
+
+ /// The first multi-line flow collection, reported only after the rest of the frontmatter is read.
+ private UnsupportedYamlException? _multiLineFlow;
+
+ public JsonNode? ParseDocument()
+ {
+ SkipBlank();
+ if (_pos >= _lines.Count)
+ {
+ return new JsonObject();
+ }
+
+ var first = _lines[_pos];
+ ThrowIfTabIndented(first);
+ if (first.Content[0] == '*')
+ {
+ throw new FormatException($"Line {first.Number}: the frontmatter cannot start with an alias ('*'), because no anchor is defined before it.");
+ }
+
+ if (first.Content[0] is '&' or '!' &&
+ SkipNodeProperties(first.Content) is { } afterProperties)
+ {
+ throw CreateRootPropertiesException(first, afterProperties);
+ }
+
+ bool isFlowCollection = first.Content[0] is '[' or '{' &&
+ !IsFlowCollectionKey(first.Content, first.Number);
+ if (IsSequenceEntry(first.Content) ||
+ (isFlowCollection && first.Content[0] == '['))
+ {
+ CheckRootSequence(first);
+ throw new FormatException(NotAMappingMessage);
+ }
+
+ JsonNode? node;
+ if (isFlowCollection)
+ {
+ // A flow mapping is read like a flow value. One that spans lines is reported below, after the rest.
+ _pos++;
+ node = ParseValue(PrepareValue(first.Content, out _), commentEndedValue: false, parentIndent: -1, first.Number, allowSameIndentSequence: false);
+ }
+ else if (!StartsMappingEntry(first.Content, first.Number) &&
+ IsScalarDocument(first))
+ {
+ throw new FormatException(NotAMappingMessage);
+ }
+ else
+ {
+ // The root mapping may be indented, as long as all of its keys share that indentation. A first line
+ // that is neither a mapping entry nor a whole scalar document is reported by the mapping.
+ node = ParseBlock(first.Indent);
+ }
+
+ SkipBlank();
+ if (_pos < _lines.Count)
+ {
+ throw new FormatException($"Line {_lines[_pos].Number}: unexpected content after the frontmatter mapping.");
+ }
+
+ if (_multiLineFlow is not null)
+ {
+ throw _multiLineFlow;
+ }
+
+ 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;
+ }
+
+ ///
+ /// Classifies a root node that has an anchor or a tag, which this reader does not support: a mapping is valid
+ /// YAML outside the subset, and any other node is not a mapping.
+ ///
+ private FormatException CreateRootPropertiesException(Line first, string afterProperties)
+ {
+ string node = afterProperties;
+ int lineNumber = first.Number;
+ if (node.Length == 0)
+ {
+ // The properties belong to the node that starts on the next line.
+ _pos++;
+ if (PeekSignificant() is { } next)
+ {
+ node = SkipNodeProperties(next.Content) ?? next.Content;
+ lineNumber = next.Number;
+ }
+ }
+
+ bool isMapping = node.Length > 0 &&
+ (node[0] == '{' || StartsMappingEntry(node, lineNumber));
+ return isMapping
+ ? new UnsupportedYamlException($"Line {first.Number}: YAML anchors, aliases, and tags are not supported in frontmatter.")
+ : new FormatException(NotAMappingMessage);
+ }
+
+ ///
+ /// Reads a root sequence only to report malformed YAML in it. The frontmatter is rejected for not being a
+ /// mapping either way, so valid YAML in it that this reader does not support does not matter.
+ ///
+ private void CheckRootSequence(Line first)
+ {
+ try
+ {
+ if (first.Content[0] == '[')
+ {
+ _pos++;
+ ParseValue(PrepareValue(first.Content, out _), commentEndedValue: false, parentIndent: -1, first.Number, allowSameIndentSequence: false);
+ }
+ else
+ {
+ ParseBlock(first.Indent);
+ }
+ }
+ catch (UnsupportedYamlException)
+ {
+ // Still a sequence, so still not a mapping.
+ }
+ }
+
+ /// Whether a line's content starts an entry of a block mapping, which the mapping then reads or reports.
+ private bool StartsMappingEntry(string content, int lineNumber)
+ {
+ if (IsSequenceEntry(content))
+ {
+ return false;
+ }
+
+ if (content[0] == '?' &&
+ (content.Length == 1 || content[1] is ' ' or '\t'))
+ {
+ // An explicit key, which the mapping reports as unsupported.
+ return true;
+ }
+
+ if (content[0] is '[' or '{')
+ {
+ return IsFlowCollectionKey(content, lineNumber);
+ }
+
+ if (content[0] is '"' or '\'')
+ {
+ return IsCompactMappingEntry(content, lineNumber);
+ }
+
+ return FindKeySeparator(content) >= 0;
+ }
+
+ ///
+ /// Whether the whole document is one scalar that starts on , which is valid YAML but
+ /// not a mapping. The scalar is read only to tell it from a mapping whose first entry has a mistake.
+ ///
+ private bool IsScalarDocument(Line first)
+ {
+ int start = _pos;
+ try
+ {
+ _pos++;
+ string rest = PrepareValue(first.Content, out bool commentEndedValue);
+ ParseValue(rest, commentEndedValue, parentIndent: -1, first.Number, allowSameIndentSequence: false);
+ SkipBlank();
+ return _pos >= _lines.Count;
+ }
+ catch (UnsupportedYamlException)
+ {
+ // A scalar form this reader does not support, such as a multi-line quoted scalar.
+ return true;
+ }
+ catch (UnrepresentableValueException)
+ {
+ // A scalar that JSON cannot represent, such as .inf.
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ finally
+ {
+ _pos = start;
+ }
+ }
+
+ /// Parses the block node formed by the lines at .
+ private JsonNode? ParseBlock(int indent, int parentIndent = -1)
+ {
+ SkipBlank();
+ var line = _lines[_pos];
+ if (_depth >= MaxNestingDepth)
+ {
+ throw new UnsupportedYamlException($"Line {line.Number}: the frontmatter is nested more than {MaxNestingDepth} levels deep, which is not supported in frontmatter.");
+ }
+
+ _depth++;
+ try
+ {
+ if (IsSequenceEntry(line.Content))
+ {
+ return ParseSequence(indent);
+ }
+
+ bool isExplicitMappingKey = line.Content[0] == '?' &&
+ (line.Content.Length == 1 || line.Content[1] is ' ' or '\t');
+ bool isScalarValue = parentIndent >= 0 &&
+ line.Content[0] is not ('&' or '*' or '!') &&
+ !isExplicitMappingKey &&
+ !IsCompactMappingEntry(line.Content, line.Number) &&
+ !IsFlowCollectionKey(line.Content, line.Number);
+ if (isScalarValue)
+ {
+ ThrowIfTabIndented(line);
+ var rest = PrepareValue(line.Content, out bool commentEndedValue);
+ _pos++;
+ return ParseValue(rest, commentEndedValue, parentIndent, line.Number, allowSameIndentSequence: false);
+ }
+
+ return ParseMapping(indent);
+ }
+ finally
+ {
+ _depth--;
+ }
+ }
+
+ private static bool IsSequenceEntry(string content) =>
+ content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] is ' ' or '\t');
+
+ private bool IsFlowCollectionKey(string text, int lineNumber)
+ {
+ if (text[0] is not ('[' or '{'))
+ {
+ return false;
+ }
+
+ // Find the flow node's boundary without resolving its contents as JSON values.
+ var closers = new Stack();
+ bool inPlainScalar = false;
+ for (int i = 0; i < text.Length; i++)
+ {
+ char c = text[i];
+ switch (c)
+ {
+ case '[' or '{':
+ if (closers.Count >= MaxNestingDepth)
+ {
+ return false;
+ }
+
+ closers.Push(c == '[' ? ']' : '}');
+ inPlainScalar = false;
+ break;
+
+ case ']' or '}':
+ if (closers.Count == 0 ||
+ closers.Pop() != c)
+ {
+ return false;
+ }
+
+ if (closers.Count == 0)
+ {
+ int colon = SkipSpaces(text, i + 1);
+ return colon < text.Length &&
+ text[colon] == ':' &&
+ (colon + 1 == text.Length || text[colon + 1] is ' ' or '\t');
+ }
+
+ inPlainScalar = false;
+ break;
+
+ case '"' or '\'' when !inPlainScalar:
+ try
+ {
+ ParseQuotedScalar(text, i, lineNumber, out int end);
+ i = end - 1;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+
+ break;
+
+ case ',':
+ case ':' when !inPlainScalar ||
+ i + 1 == text.Length ||
+ text[i + 1] is ' ' or '\t':
+ inPlainScalar = false;
+ break;
+
+ case '#' when i == 0 || text[i - 1] is ' ' or '\t':
+ return false;
+
+ case ' ' or '\t':
+ break;
+
+ default:
+ inPlainScalar = true;
+ break;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Whether a sequence item's text is a "key: value" line, with a plain or quoted key, that begins a nested
+ /// block mapping.
+ ///
+ private static bool IsCompactMappingEntry(string item, int lineNumber)
+ {
+ if (item[0] is '"' or '\'')
+ {
+ try
+ {
+ ParseQuotedScalar(item, 0, lineNumber, out int afterQuote);
+ int colon = SkipSpaces(item, afterQuote);
+ return colon < item.Length && item[colon] == ':' && (colon + 1 == item.Length || item[colon + 1] is ' ' or '\t');
+ }
+ catch (FormatException)
+ {
+ // Not a well-formed quoted key; the item is parsed as a value and reported there.
+ return false;
+ }
+ }
+
+ return item[0] is not ('[' or '{' or '|' or '>' or '&' or '*' or '!') && FindKeySeparator(item) >= 0;
+ }
+
+ private static void ThrowIfTabIndented(Line line)
+ {
+ if (line.HasTabIndentation)
+ {
+ throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter.");
+ }
+ }
+
+ ///
+ /// Returns what follows the anchor and tag at the start of : empty when the node starts
+ /// on a later line, or null when an anchor has no name or has a flow indicator in it, which YAML does not allow.
+ ///
+ private static string? SkipNodeProperties(string text)
+ {
+ int pos = 0;
+ while (pos < text.Length && text[pos] is '&' or '!')
+ {
+ // A property runs to the next white space; an anchor name may end in ':', as in YAML 1.2.
+ int start = pos;
+ while (pos < text.Length && text[pos] is not (' ' or '\t'))
+ {
+ pos++;
+ }
+
+ if (text[start] == '&' &&
+ (pos == start + 1 || text.IndexOfAny(s_flowIndicators, start, pos - start) >= 0))
+ {
+ return null;
+ }
+
+ pos = SkipSpaces(text, pos);
+ }
+
+ return pos < text.Length && text[pos] == '#' ? string.Empty : text.Substring(pos);
+ }
+
+ ///
+ /// Rejects a plain scalar that starts with a character YAML gives another meaning: an anchor, alias, or tag
+ /// (constructs this reader does not support), a reserved character, a flow indicator, a block scalar header,
+ /// or a block indicator followed by white space.
+ ///
+ private static void ThrowIfReservedPlainStart(string text, int lineNumber)
+ {
+ if (text.Length == 0)
+ {
+ return;
+ }
+
+ char c = text[0];
+ switch (c)
+ {
+ case '&' or '*' or '!':
+ throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter.");
+
+ case '@' or '`' or '%':
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reserves. Quote the value.");
+
+ case '#':
+ // Only a '#' after white space starts a comment; one that starts a scalar is not allowed.
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '#'. Quote the value.");
+
+ case ']' or '}' or ',':
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with the flow indicator '{c}'. Quote the value.");
+
+ case '|' or '>':
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reads as a block scalar header. Quote the value.");
+
+ case '-' or '?' or ':' when text.Length == 1 || text[1] is ' ' or '\t':
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}' followed by white space, which YAML reads as a block indicator. Quote the value.");
+ }
+ }
+
+ ///
+ /// Rejects a flow sequence entry or flow mapping key that starts with an explicit key: '?' followed by white
+ /// space. That is valid YAML this reader does not support. '?' followed by any other character is not an
+ /// explicit key, and '?' at the end of the line continues on a later line like an unterminated collection.
+ ///
+ private static void ThrowIfFlowExplicitKey(string text, int pos, char closer, int lineNumber)
+ {
+ if (text[pos] != '?')
+ {
+ return;
+ }
+
+ if (pos + 1 == text.Length)
+ {
+ string collection = closer == ']' ? "sequence" : "mapping";
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow {collection}.", closer);
+ }
+
+ if (text[pos + 1] is ' ' or '\t')
+ {
+ throw new UnsupportedYamlException($"Line {lineNumber}: explicit mapping keys ('? ') in flow collections are not supported in frontmatter.");
+ }
+ }
+
+ private JsonObject ParseMapping(int indent)
+ {
+ var result = new JsonObject();
+ while (true)
+ {
+ SkipBlank();
+ if (_pos >= _lines.Count)
+ {
+ break;
+ }
+
+ var line = _lines[_pos];
+ if (line.Indent < indent)
+ {
+ break;
+ }
+
+ if (line.Indent > indent)
+ {
+ throw new FormatException($"Line {line.Number}: unexpected indentation.");
+ }
+
+ ThrowIfTabIndented(line);
+ if (IsSequenceEntry(line.Content))
+ {
+ throw new FormatException($"Line {line.Number}: a sequence entry cannot appear directly inside a mapping. Put it under a key.");
+ }
+
+ string content = line.Content;
+ if (content[0] == '?' && (content.Length == 1 || content[1] is ' ' or '\t'))
+ {
+ throw new UnsupportedYamlException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter.");
+ }
+
+ if (content[0] is '[' or '{')
+ {
+ throw new UnsupportedYamlException($"Line {line.Number}: flow collections are not supported as mapping keys in frontmatter.");
+ }
+
+ int consumed;
+ string key;
+ if (content[0] is '"' or '\'')
+ {
+ key = ParseQuotedScalar(content, 0, line.Number, out consumed);
+ int colon = SkipSpaces(content, consumed);
+ if (colon >= content.Length || content[colon] != ':' || (colon + 1 < content.Length && content[colon + 1] is not (' ' or '\t')))
+ {
+ throw new FormatException($"Line {line.Number}: expected ':' after the quoted key.");
+ }
+
+ consumed = colon + 1;
+ }
+ else
+ {
+ int separator = FindKeySeparator(content);
+ if (separator < 0)
+ {
+ throw new FormatException($"Line {line.Number}: expected a 'key: value' pair.");
+ }
+
+ string rawKey = content.Substring(0, separator).TrimEnd(s_yamlWhitespace);
+ if (rawKey.Length == 0)
+ {
+ throw new UnsupportedYamlException($"Line {line.Number}: empty mapping keys (': value') are not supported in frontmatter.");
+ }
+
+ ThrowIfReservedPlainStart(content, line.Number);
+
+ // Keys resolve like values: "TRUE:" is the key "true" and "0x10:" is "16" to a YAML parser.
+ key = KeyToString(ResolvePlainScalar(rawKey, line.Number));
+ consumed = separator + 1;
+ }
+
+ if (result.ContainsKey(key))
+ {
+ throw new FormatException($"Line {line.Number}: duplicate key '{key}'.");
+ }
+
+ string rest = PrepareValue(content.Substring(consumed), out bool commentEndedValue);
+ _pos++;
+ result[key] = ParseValue(rest, commentEndedValue, indent, line.Number, allowSameIndentSequence: true);
+ }
+
+ return result;
+ }
+
+ private JsonArray ParseSequence(int indent)
+ {
+ var result = new JsonArray();
+ while (true)
+ {
+ SkipBlank();
+ if (_pos >= _lines.Count)
+ {
+ break;
+ }
+
+ var line = _lines[_pos];
+ if (line.Indent < indent || (line.Indent == indent && !IsSequenceEntry(line.Content)))
+ {
+ break;
+ }
+
+ if (line.Indent > indent)
+ {
+ throw new FormatException($"Line {line.Number}: unexpected indentation.");
+ }
+
+ ThrowIfTabIndented(line);
+ // Everything after the '-', white space included, so that the item's column is known exactly.
+ string item = line.Content.Substring(1);
+ _pos++;
+
+ string trimmedItem = item.TrimStart(s_yamlWhitespace);
+ if (trimmedItem.Length == 0 || trimmedItem[0] == '#')
+ {
+ // "- " followed by nothing: the value is the more-indented block that follows, or null.
+ var next = PeekSignificant();
+ result.Add(next is { } n && n.Indent > indent ? ParseBlock(n.Indent, indent) : null);
+ continue;
+ }
+
+ int itemIndent = indent + 1 + (item.Length - trimmedItem.Length);
+ bool isExplicitKey = trimmedItem[0] == '?' &&
+ (trimmedItem.Length == 1 || trimmedItem[1] is ' ' or '\t');
+ if (IsSequenceEntry(trimmedItem) ||
+ IsCompactMappingEntry(trimmedItem, line.Number) ||
+ isExplicitKey)
+ {
+ if (item.IndexOf('\t', 0, item.Length - trimmedItem.Length) >= 0)
+ {
+ // The nested node's indentation would be set by a tab.
+ throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter.");
+ }
+
+ // A compact nested node ("- key: value", "- ? key" or "- - x"): re-read this line as if the item
+ // started on its own line at the item's column, then continue with the block at that indentation.
+ _pos--;
+ _lines[_pos] = new Line(new string(' ', itemIndent) + trimmedItem, line.Number);
+ result.Add(ParseBlock(itemIndent));
+ continue;
+ }
+
+ string itemValue = PrepareValue(trimmedItem, out bool itemCommentEndedValue);
+ result.Add(ParseValue(itemValue, itemCommentEndedValue, indent, line.Number, allowSameIndentSequence: false));
+ }
+
+ return result;
+ }
+
+ ///
+ /// Parses the value that follows a key or sequence dash. is the remainder of the
+ /// line, with comments removed; the line itself has already been consumed.
+ ///
+ private JsonNode? ParseValue(string rest, bool commentEndedValue, int parentIndent, int lineNumber, bool allowSameIndentSequence)
+ {
+ if (rest.Length == 0)
+ {
+ var next = PeekSignificant();
+ if (next is { } n)
+ {
+ if (n.Indent > parentIndent)
+ {
+ return ParseBlock(n.Indent, parentIndent);
+ }
+
+ if (allowSameIndentSequence && n.Indent == parentIndent && IsSequenceEntry(n.Content))
+ {
+ return ParseSequence(parentIndent);
+ }
+ }
+
+ return null;
+ }
+
+ switch (rest[0])
+ {
+ case '|' or '>':
+ return ParseBlockScalar(rest, parentIndent, lineNumber);
+
+ case '&' or '*' or '!':
+ throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter.");
+
+ case '[' or '{':
+ try
+ {
+ return rest[0] == '[' ? ParseFlowSequence(rest, lineNumber) : ParseFlowMapping(rest, lineNumber);
+ }
+ catch (UnterminatedFlowException)
+ {
+ // The collection continues on later lines, which this reader does not support. It is checked and
+ // skipped, and the frontmatter is reported as unsupported only after the rest of it is read, so
+ // that only valid YAML is reported that way.
+ _pos = CheckMultiLineFlow(rest, parentIndent, lineNumber);
+ _multiLineFlow ??= new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter.");
+ return null;
+ }
+
+ case '"' or '\'':
+ string quoted;
+ int consumed;
+ try
+ {
+ quoted = ParseQuotedScalar(rest, 0, lineNumber, out consumed);
+ }
+ catch (UnterminatedQuoteException e) when (ClosesLater(e.Quote))
+ {
+ // A later delimiter may indicate an unsupported multi-line quoted scalar. This
+ // classification does not validate its contents or the rest of the frontmatter.
+ throw new UnsupportedYamlException($"Line {lineNumber}: multi-line quoted scalars are not supported in frontmatter.");
+ }
+
+ if (StripComment(rest.Substring(consumed), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0)
+ {
+ throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value.");
+ }
+
+ return JsonValue.Create(quoted);
+
+ default:
+ if (IsSequenceEntry(rest))
+ {
+ throw new FormatException($"Line {lineNumber}: a sequence cannot start on the same line as its key. Put the '- ' entries on the following lines, or quote the value if '-' is meant literally.");
+ }
+
+ ThrowIfReservedPlainStart(rest, lineNumber);
+ if (FindKeySeparator(rest) >= 0)
+ {
+ throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally.");
+ }
+
+ string plain = commentEndedValue ? rest : CollectPlainContinuation(rest, parentIndent, lineNumber, out commentEndedValue);
+
+ // A comment ends a plain scalar, on whichever of its lines it appears. A more-indented line after
+ // it cannot continue the scalar and cannot start a nested block either, so it is an error, as it
+ // is to a YAML parser.
+ if (commentEndedValue && PeekSignificant() is { } following && following.Indent > parentIndent)
+ {
+ throw new FormatException(
+ $"Line {following.Number}: this line cannot continue the value that started on line {lineNumber}, because a comment ended that value.");
+ }
+
+ return ResolvePlainScalar(plain, lineNumber);
+ }
+ }
+
+ private bool ClosesLater(char quote)
+ {
+ for (int i = _pos; i < _lines.Count; i++)
+ {
+ if (_lines[i].Raw.IndexOf(quote) >= 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Checks a flow collection that continues past its first line the way a YAML parser reads it, and throws
+ /// if it is invalid. The rules that span lines are checked here: later lines are
+ /// indented more than the parent, though the closing bracket may sit at the parent's indentation; a comment,
+ /// or a white-space line whose tab falls short of that indentation, ends a plain scalar; and a key in a flow
+ /// sequence stays on one line. The lines, without comments, are then joined and checked like a single-line
+ /// collection. Valid YAML inside the collection that this reader does not support is not an error here.
+ ///
+ /// The index of the line after the collection.
+ private int CheckMultiLineFlow(string first, int parentIndent, int lineNumber)
+ {
+ char outerCloser = first[0] == '[' ? ']' : '}';
+ string collection = outerCloser == ']' ? "sequence" : "mapping";
+ var joined = new StringBuilder();
+ var closers = new Stack();
+ var entryLines = new Stack();
+ char quote = '\0';
+ bool inPlain = false;
+ bool afterQuoted = false;
+ bool plainEnded = false;
+ bool firstLineComment = false;
+ string text = first;
+ int number = lineNumber;
+ int next = _pos;
+ while (true)
+ {
+ int end = text.Length;
+ bool closed = false;
+ for (int i = 0; i < text.Length && !closed; i++)
+ {
+ char c = text[i];
+ if (quote != '\0')
+ {
+ if (quote == '"' && c == '\\')
+ {
+ i++;
+ }
+ else if (c == quote &&
+ quote == '\'' &&
+ i + 1 < text.Length &&
+ text[i + 1] == '\'')
+ {
+ i++;
+ }
+ else if (c == quote)
+ {
+ quote = '\0';
+ afterQuoted = true;
+ }
+
+ continue;
+ }
+
+ if (c is ' ' or '\t')
+ {
+ continue;
+ }
+
+ if (c == '#' &&
+ i > 0 &&
+ text[i - 1] is ' ' or '\t')
+ {
+ // A comment runs to the end of the line and ends a plain scalar.
+ firstLineComment |= number == lineNumber;
+ plainEnded |= inPlain;
+ inPlain = false;
+ end = i;
+ break;
+ }
+
+ bool isValueIndicator = c == ':' &&
+ (afterQuoted ||
+ i + 1 == text.Length ||
+ text[i + 1] is ' ' or '\t' ||
+ Array.IndexOf(s_flowIndicators, text[i + 1]) >= 0);
+ if (plainEnded &&
+ !isValueIndicator &&
+ c is not (',' or ']' or '}'))
+ {
+ throw new FormatException($"Line {number}: the flow entry before this one already ended, so a ',' must separate them.");
+ }
+
+ plainEnded = false;
+ afterQuoted = false;
+ if (entryLines.Count > 0 &&
+ entryLines.Peek() < 0 &&
+ c is not (',' or ']' or '}'))
+ {
+ // The first token of an entry.
+ entryLines.Pop();
+ entryLines.Push(number);
+ }
+
+ switch (c)
+ {
+ case '[' or '{':
+ closers.Push(c == '[' ? ']' : '}');
+ entryLines.Push(-1);
+ inPlain = false;
+ break;
+
+ case ']' or '}':
+ closers.Pop();
+ entryLines.Pop();
+ closed = closers.Count == 0;
+ inPlain = false;
+ break;
+
+ case ',':
+ entryLines.Pop();
+ entryLines.Push(-1);
+ inPlain = false;
+ break;
+
+ case ':' when isValueIndicator:
+ if (closers.Peek() == ']' &&
+ entryLines.Peek() != number)
+ {
+ throw new FormatException($"Line {number}: a key in a flow sequence must be on a single line.");
+ }
+
+ inPlain = false;
+ break;
+
+ case '"' or '\'' when !inPlain:
+ quote = c;
+ break;
+
+ default:
+ inPlain = true;
+ break;
+ }
+ }
+
+ joined.Append(text, 0, end).Append(' ');
+ if (closed)
+ {
+ break;
+ }
+
+ if (next >= _lines.Count)
+ {
+ throw new FormatException(firstLineComment
+ ? $"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 {(outerCloser == ']' ? "bracket" : "brace")}."
+ : $"Line {lineNumber}: unterminated flow {collection}.");
+ }
+
+ var line = _lines[next++];
+ number = line.Number;
+ text = line.Content;
+ bool emptyLine = text.Length == 0;
+ bool indented = line.Indent > parentIndent;
+ if (quote != '\0')
+ {
+ // A quoted scalar continues on more-indented lines. An empty line may be shorter, unless it holds a tab.
+ if (!indented &&
+ (!emptyLine || line.HasTabIndentation))
+ {
+ throw new FormatException($"Line {number}: unterminated quoted scalar in a flow {collection}. Lines that continue it must be indented more than its parent.");
+ }
+
+ continue;
+ }
+
+ if (emptyLine ||
+ text[0] == '#')
+ {
+ // Empty and comment lines may sit at any indentation. A comment line ends a plain scalar, and so
+ // does a white-space line whose tab falls short of the indentation.
+ if (inPlain &&
+ (!emptyLine || (line.HasTabIndentation && !indented)))
+ {
+ plainEnded = true;
+ inPlain = false;
+ }
+
+ text = string.Empty;
+ continue;
+ }
+
+ bool closesAtParentIndent = closers.Count == 1 &&
+ line.Indent == parentIndent &&
+ text[0] == outerCloser;
+ if (!indented &&
+ !closesAtParentIndent)
+ {
+ throw new FormatException($"Line {number}: unterminated flow {collection}. Lines that continue it must be indented more than its parent.");
+ }
+ }
+
+ try
+ {
+ string joinedText = joined.ToString();
+ if (outerCloser == ']')
+ {
+ ParseFlowSequence(joinedText, lineNumber);
+ }
+ else
+ {
+ ParseFlowMapping(joinedText, lineNumber);
+ }
+ }
+ catch (UnsupportedYamlException)
+ {
+ // Valid YAML inside the collection that this reader does not support; the caller reports the collection.
+ }
+ catch (UnterminatedFlowException e)
+ {
+ throw new FormatException(e.Message);
+ }
+
+ return next;
+ }
+
+ ///
+ /// Folds the more-indented continuation lines of a plain multi-line scalar into it. A trailing comment on a
+ /// continuation line ends the scalar, which reports.
+ ///
+ private string CollectPlainContinuation(string first, int parentIndent, int lineNumber, out bool commentEndedValue)
+ {
+ commentEndedValue = false;
+ var builder = new StringBuilder(first);
+ int pendingNewlines = 0;
+ while (_pos < _lines.Count)
+ {
+ var line = _lines[_pos];
+ if (line.Content.Length == 0)
+ {
+ pendingNewlines++;
+ _pos++;
+ continue;
+ }
+
+ if (line.Indent <= parentIndent || line.Content[0] == '#')
+ {
+ break;
+ }
+
+ string stripped = StripComment(line.Content);
+ string text = stripped.Trim(s_yamlWhitespace);
+ if (text.Length == 0)
+ {
+ break;
+ }
+
+ if (FindKeySeparator(text) >= 0)
+ {
+ throw new FormatException(
+ $"Line {line.Number}: a plain scalar cannot contain ': '. If this line is meant to be a key, check its indentation; " +
+ $"the value that started on line {lineNumber} continues onto any more-indented line.");
+ }
+
+ builder.Append(pendingNewlines > 0 ? new string('\n', pendingNewlines) : " ");
+ pendingNewlines = 0;
+ builder.Append(text);
+ _pos++;
+
+ if (stripped.Length != line.Content.Length)
+ {
+ commentEndedValue = true;
+ break;
+ }
+ }
+
+ // Trailing blank lines belong to whatever follows, so give them back.
+ _pos -= pendingNewlines;
+ return builder.ToString();
+ }
+
+ private JsonNode? ParseBlockScalar(string header, int parentIndent, int lineNumber)
+ {
+ bool literal = header[0] == '|';
+ char chomping = 'c';
+ int explicitIndent = 0;
+ int headerPos = 1;
+ for (; headerPos < header.Length && header[headerPos] != ' '; headerPos++)
+ {
+ char c = header[headerPos];
+ if (c is '-' or '+')
+ {
+ if (chomping != 'c')
+ {
+ throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated chomping indicator.");
+ }
+
+ chomping = c == '-' ? 's' : 'k';
+ }
+ else if (c is >= '1' and <= '9')
+ {
+ if (explicitIndent != 0)
+ {
+ throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated indentation indicator.");
+ }
+
+ explicitIndent = c - '0';
+ }
+ else
+ {
+ throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}'.");
+ }
+ }
+
+ if (StripComment(header.Substring(headerPos)).Trim(s_yamlWhitespace).Length != 0)
+ {
+ throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': only a comment may follow the indicators.");
+ }
+
+ // Gather the raw lines of the block: everything blank, plus everything indented more than the parent.
+ var raw = new List();
+ int contentIndent = explicitIndent > 0 ? parentIndent + explicitIndent : -1;
+ int widestLeadingEmptyLine = 0;
+ while (_pos < _lines.Count)
+ {
+ var line = _lines[_pos];
+ if (line.Content.Length == 0)
+ {
+ // A white-space-only line is empty unless it reaches past the content indentation, in which case
+ // the excess is content. Until the indentation is known, the widest such line is remembered:
+ // YAML rejects a leading empty line wider than the first content line. A tab short of the
+ // content indentation is neither indentation nor content.
+ if (line.HasTabIndentation && (contentIndent < 0 || line.Indent < contentIndent))
+ {
+ throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter.");
+ }
+
+ if (contentIndent < 0)
+ {
+ widestLeadingEmptyLine = Math.Max(widestLeadingEmptyLine, line.Raw.Length);
+ raw.Add(string.Empty);
+ }
+ else
+ {
+ raw.Add(line.Raw.Length > contentIndent ? line.Raw.Substring(contentIndent) : string.Empty);
+ }
+
+ _pos++;
+ continue;
+ }
+
+ if (line.Indent <= parentIndent)
+ {
+ break;
+ }
+
+ if (contentIndent < 0)
+ {
+ contentIndent = line.Indent;
+ if (widestLeadingEmptyLine > contentIndent)
+ {
+ throw new FormatException(
+ $"Line {line.Number}: a leading empty line of the block scalar is indented more than its first content line. Use an explicit indentation indicator, or remove the extra white space.");
+ }
+ }
+ else if (line.Indent < contentIndent)
+ {
+ break;
+ }
+
+ raw.Add(line.Raw.Substring(contentIndent));
+ _pos++;
+ }
+
+ // Trailing blank lines are subject to chomping; count and remove them.
+ int trailing = 0;
+ while (raw.Count > 0 && raw[raw.Count - 1].Length == 0)
+ {
+ raw.RemoveAt(raw.Count - 1);
+ trailing++;
+ }
+
+ var builder = new StringBuilder();
+ if (literal)
+ {
+ for (int i = 0; i < raw.Count; i++)
+ {
+ if (i > 0)
+ {
+ builder.Append('\n');
+ }
+
+ builder.Append(raw[i]);
+ }
+ }
+ else
+ {
+ // Folded: adjacent non-empty, non-indented lines join with a space; empty lines become newlines;
+ // more-indented lines keep their line breaks.
+ bool previousMoreIndented = false;
+ int emptyRun = 0;
+ for (int i = 0; i < raw.Count; i++)
+ {
+ string text = raw[i];
+ if (text.Length == 0)
+ {
+ emptyRun++;
+ continue;
+ }
+
+ bool moreIndented = text[0] is ' ' or '\t';
+ if (builder.Length == 0)
+ {
+ // Leading empty lines are content: each becomes a line break.
+ builder.Append('\n', emptyRun);
+ }
+ else if (emptyRun > 0)
+ {
+ builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0));
+ }
+ else
+ {
+ builder.Append(moreIndented || previousMoreIndented ? '\n' : ' ');
+ }
+
+ emptyRun = 0;
+ builder.Append(text);
+ previousMoreIndented = moreIndented;
+ }
+ }
+
+ if (builder.Length > 0 || trailing > 0)
+ {
+ switch (chomping)
+ {
+ case 'c' when builder.Length > 0:
+ builder.Append('\n');
+ break;
+ case 'k':
+ builder.Append('\n', builder.Length > 0 ? trailing + 1 : trailing);
+ break;
+ }
+ }
+
+ return JsonValue.Create(builder.ToString());
+ }
+
+ private static JsonArray ParseFlowSequence(string text, int lineNumber)
+ {
+ var result = new JsonArray();
+ int pos = 1;
+ while (true)
+ {
+ pos = SkipSpaces(text, pos);
+ if (pos >= text.Length)
+ {
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']');
+ }
+
+ if (text[pos] == ']')
+ {
+ pos++;
+ break;
+ }
+
+ if (text[pos] == ',')
+ {
+ throw new FormatException($"Line {lineNumber}: unexpected ',' in flow sequence; every entry needs a value.");
+ }
+
+ ThrowIfFlowExplicitKey(text, pos, ']', lineNumber);
+ result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]"));
+ pos = SkipSpaces(text, pos);
+ if (pos < text.Length && text[pos] == ':')
+ {
+ throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter.");
+ }
+
+ if (pos >= text.Length)
+ {
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']');
+ }
+
+ if (text[pos] == '#' &&
+ text[pos - 1] is ' ' or '\t')
+ {
+ // A comment runs to the end of the line, so the collection continues on a later line.
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']');
+ }
+
+ if (text[pos] == ',')
+ {
+ pos++;
+ }
+ else if (text[pos] != ']')
+ {
+ throw new FormatException($"Line {lineNumber}: expected ',' or ']' in flow sequence.");
+ }
+ }
+
+ if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0)
+ {
+ throw new FormatException($"Line {lineNumber}: unexpected content after the flow sequence.");
+ }
+
+ return result;
+ }
+
+ private static JsonObject ParseFlowMapping(string text, int lineNumber)
+ {
+ var result = new JsonObject();
+ int pos = 1;
+ while (true)
+ {
+ pos = SkipSpaces(text, pos);
+ if (pos >= text.Length)
+ {
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}');
+ }
+
+ if (text[pos] == '}')
+ {
+ pos++;
+ break;
+ }
+
+ if (text[pos] == ',')
+ {
+ throw new FormatException($"Line {lineNumber}: unexpected ',' in flow mapping; every entry needs a key.");
+ }
+
+ JsonNode? keyNode;
+ bool hasValue;
+ if (text[pos] is '"' or '\'')
+ {
+ int afterQuote;
+ try
+ {
+ keyNode = JsonValue.Create(ParseQuotedScalar(text, pos, lineNumber, out afterQuote));
+ }
+ catch (UnterminatedQuoteException e)
+ {
+ // The quoted key, and so the collection, continues on a later line.
+ throw new UnterminatedFlowException(e.Message, '}');
+ }
+
+ pos = SkipSpaces(text, afterQuote);
+ hasValue = pos < text.Length && text[pos] == ':';
+ if (hasValue)
+ {
+ pos++;
+ }
+ }
+ else
+ {
+ ThrowIfFlowExplicitKey(text, pos, '}', lineNumber);
+
+ // A plain key ends at ',' or '}' (giving it a null value) or at a ':' that is followed by a
+ // space, a flow collection indicator, or the end of the text. A ':' followed by anything else is
+ // part of the key, so "{version:1}" is the key "version:1" with a null value.
+ int start = pos;
+ int colonAt = -1;
+ while (pos < text.Length && text[pos] is not (',' or '}'))
+ {
+ if (text[pos] == ':' &&
+ (pos + 1 == text.Length ||
+ text[pos + 1] is ' ' or '\t' ||
+ Array.IndexOf(s_flowIndicators, text[pos + 1]) >= 0))
+ {
+ colonAt = pos;
+ break;
+ }
+
+ if (text[pos] == '#' &&
+ text[pos - 1] is ' ' or '\t')
+ {
+ // A comment runs to the end of the line, so the collection continues on a later line.
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}');
+ }
+
+ pos++;
+ }
+
+ string rawKey = text.Substring(start, pos - start).Trim(s_yamlWhitespace);
+ if (rawKey.Length > 0 && rawKey[0] is '[' or '{')
+ {
+ throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter.");
+ }
+
+ if (rawKey.IndexOfAny(s_flowIndicators) >= 0)
+ {
+ throw new FormatException($"Line {lineNumber}: flow indicators in a mapping key must be quoted.");
+ }
+
+ // The key is trimmed, so the check below cannot see the ':' that makes "-:" the plain key "-".
+ // Followed by white space or the end of the entry, the indicator cannot start a plain key.
+ bool isIndicatorPlainKey = rawKey is "-" or "?" or ":" &&
+ start + 1 < text.Length &&
+ text[start + 1] == ':';
+ if (!isIndicatorPlainKey)
+ {
+ ThrowIfReservedPlainStart(rawKey, lineNumber);
+ }
+
+ keyNode = ResolvePlainScalar(rawKey, lineNumber);
+ hasValue = colonAt >= 0;
+ if (hasValue)
+ {
+ pos = colonAt + 1;
+ }
+ }
+
+ string key = KeyToString(keyNode);
+ if (result.ContainsKey(key))
+ {
+ throw new FormatException($"Line {lineNumber}: duplicate key '{key}'.");
+ }
+
+ result[key] = hasValue ? ParseFlowScalar(text, ref pos, lineNumber, ",}") : null;
+ pos = SkipSpaces(text, pos);
+ if (pos >= text.Length)
+ {
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}');
+ }
+
+ if (text[pos] == '#' &&
+ text[pos - 1] is ' ' or '\t')
+ {
+ // A comment runs to the end of the line, so the collection continues on a later line.
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}');
+ }
+
+ if (text[pos] == ',')
+ {
+ pos++;
+ }
+ else if (text[pos] != '}')
+ {
+ throw new FormatException($"Line {lineNumber}: expected ',' or '}}' in flow mapping.");
+ }
+ }
+
+ if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0)
+ {
+ throw new FormatException($"Line {lineNumber}: unexpected content after the flow mapping.");
+ }
+
+ return result;
+ }
+
+ private static JsonNode? ParseFlowScalar(string text, ref int pos, int lineNumber, string terminators)
+ {
+ char closer = terminators.IndexOf(']') >= 0 ? ']' : '}';
+ pos = SkipSpaces(text, pos);
+ if (pos >= text.Length)
+ {
+ // The value is on a later line, so the collection continues there.
+ throw new UnterminatedFlowException($"Line {lineNumber}: unexpected end of flow collection.", closer);
+ }
+
+ 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;
+ int consumed;
+ try
+ {
+ quoted = ParseQuotedScalar(text, pos, lineNumber, out consumed);
+ }
+ catch (UnterminatedQuoteException e)
+ {
+ // The quoted scalar, and so the collection, continues on a later line.
+ throw new UnterminatedFlowException(e.Message, closer);
+ }
+
+ pos = consumed;
+ return JsonValue.Create(quoted);
+ }
+
+ int start = pos;
+ while (pos < text.Length && terminators.IndexOf(text[pos]) < 0)
+ {
+ if (text[pos] == '#' &&
+ text[pos - 1] is ' ' or '\t')
+ {
+ // A comment runs to the end of the line, so the collection continues on a later line.
+ throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow collection.", closer);
+ }
+
+ pos++;
+ }
+
+ string plain = text.Substring(start, pos - start).Trim(s_yamlWhitespace);
+ int separator = FindKeySeparator(plain, inFlow: true);
+ if (separator >= 0)
+ {
+ // A flow mapping's value cannot itself be a mapping, and a single 'key: value' pair in a flow sequence
+ // cannot hold another one; YAML rejects both. A single pair is valid YAML this reader does not support.
+ if (closer == '}' ||
+ FindKeySeparator(plain.Substring(separator + 1), inFlow: true) >= 0)
+ {
+ throw new FormatException($"Line {lineNumber}: a plain value in a flow collection cannot contain ': '. Quote the value if it is meant literally.");
+ }
+
+ throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter.");
+ }
+
+ if (plain.IndexOfAny(s_flowIndicators) >= 0)
+ {
+ throw new FormatException($"Line {lineNumber}: flow indicators in a plain scalar must be quoted.");
+ }
+
+ ThrowIfReservedPlainStart(plain, lineNumber);
+ return ResolvePlainScalar(plain, lineNumber);
+ }
+
+ /// Parses a quoted scalar starting at ; returns the index after the closing quote.
+ private static string ParseQuotedScalar(string text, int start, int lineNumber, out int end)
+ {
+ char quote = text[start];
+ var builder = new StringBuilder();
+ int i = start + 1;
+ while (i < text.Length)
+ {
+ char c = text[i];
+ if (c == quote)
+ {
+ if (quote == '\'' && i + 1 < text.Length && text[i + 1] == '\'')
+ {
+ builder.Append('\'');
+ i += 2;
+ continue;
+ }
+
+ end = i + 1;
+ string result = builder.ToString();
+ for (int k = 0; k < result.Length; k++)
+ {
+ if (char.IsHighSurrogate(result[k]) && k + 1 < result.Length && char.IsLowSurrogate(result[k + 1]))
+ {
+ k++;
+ }
+ else if (char.IsSurrogate(result[k]))
+ {
+ throw new UnrepresentableValueException($"Line {lineNumber}: the quoted scalar contains an unpaired surrogate escape, which is not a valid Unicode character.");
+ }
+ }
+
+ return result;
+ }
+
+ if (quote == '"' && c == '\\')
+ {
+ if (++i >= text.Length)
+ {
+ break;
+ }
+
+ char e = text[i];
+ switch (e)
+ {
+ case '0': builder.Append('\0'); break;
+ case 'a': builder.Append('\a'); break;
+ case 'b': builder.Append('\b'); break;
+ case 't' or '\t': builder.Append('\t'); break;
+ case 'n': builder.Append('\n'); break;
+ case 'v': builder.Append('\v'); break;
+ case 'f': builder.Append('\f'); break;
+ case 'r': builder.Append('\r'); break;
+ case 'e': builder.Append('\u001B'); break;
+ case ' ': builder.Append(' '); break;
+ case '"': builder.Append('"'); break;
+ case '/': builder.Append('/'); break;
+ case '\\': builder.Append('\\'); break;
+ case 'N': builder.Append('\u0085'); break;
+ case '_': builder.Append('\u00A0'); break;
+ case 'L': builder.Append('\u2028'); break;
+ case 'P': builder.Append('\u2029'); break;
+ case 'x': builder.Append((char)ParseHex(text, i + 1, 2, lineNumber)); i += 2; break;
+ case 'u': builder.Append((char)ParseHex(text, i + 1, 4, lineNumber)); i += 4; break;
+ case 'U':
+ int scalar = ParseHex(text, i + 1, 8, lineNumber);
+ if (scalar is < 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF))
+ {
+ throw new FormatException($"Line {lineNumber}: '\\U{text.Substring(i + 1, 8)}' is not a valid Unicode scalar value.");
+ }
+
+ builder.Append(char.ConvertFromUtf32(scalar));
+ i += 8;
+ break;
+ default:
+ throw new FormatException($"Line {lineNumber}: unsupported escape sequence '\\{e}' in double-quoted scalar.");
+ }
+
+ i++;
+ continue;
+ }
+
+ builder.Append(c);
+ i++;
+ }
+
+ throw new UnterminatedQuoteException($"Line {lineNumber}: unterminated quoted scalar.", quote);
+ }
+
+ private static int ParseHex(string text, int start, int length, int lineNumber)
+ {
+ if (start + length > text.Length ||
+ !int.TryParse(text.Substring(start, length), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int value))
+ {
+ throw new FormatException($"Line {lineNumber}: invalid hexadecimal escape in double-quoted scalar.");
+ }
+
+ return value;
+ }
+
+ ///
+ /// Renders a resolved key as a JSON object key the way reference parsers do: strings as themselves, null as
+ /// the empty string, and booleans and numbers as their JSON text.
+ ///
+ private static string KeyToString(JsonNode? key) =>
+ key is null ? string.Empty
+ : key is JsonValue value && value.TryGetValue(out string? text) ? text
+ : key.ToJsonString();
+
+ /// Resolves an unquoted scalar per the YAML 1.2 core schema.
+ private static JsonNode? ResolvePlainScalar(string text, int lineNumber)
+ {
+ switch (text)
+ {
+ case "" or "~" or "null" or "Null" or "NULL":
+ return null;
+ case "true" or "True" or "TRUE":
+ return JsonValue.Create(true);
+ case "false" or "False" or "FALSE":
+ return JsonValue.Create(false);
+ }
+
+ if (TryResolveNumber(text, lineNumber) is { } number)
+ {
+ return number;
+ }
+
+ return JsonValue.Create(text);
+ }
+
+ private static JsonNode? TryResolveNumber(string text, int lineNumber)
+ {
+ // Hexadecimal and octal integers take no sign in the core schema. As in yaml.v3, their values are exact up
+ // to 64 bits; a wider value is reported as unsupported rather than converted to another type.
+ int bitsPerDigit = 0;
+ if (text.StartsWith("0x", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, IsHexDigit))
+ {
+ bitsPerDigit = 4;
+ }
+ else if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7'))
+ {
+ bitsPerDigit = 3;
+ }
+
+ if (bitsPerDigit > 0)
+ {
+ ulong value = 0;
+ for (int j = 2; j < text.Length; j++)
+ {
+ // Shifting in another digit must not push set bits past 64. Leading zeros keep the value at 0.
+ if (value >> (64 - bitsPerDigit) != 0)
+ {
+ string literal = text.Length > 24 ? text.Substring(0, 24) + "..." : text;
+ throw new UnsupportedYamlException(
+ $"Line {lineNumber}: '{literal}' is a hexadecimal or octal integer wider than 64 bits, which this reader does not support. " +
+ "Supply the frontmatter explicitly, or parse it with a full YAML parser.");
+ }
+
+ char c = text[j];
+ int digit = c switch
+ {
+ <= '9' => c - '0',
+ <= 'F' => c - 'A' + 10,
+ _ => c - 'a' + 10,
+ };
+ value = (value << bitsPerDigit) + (ulong)digit;
+ }
+
+ 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 UnrepresentableValueException($"Line {lineNumber}: '{text}' cannot be represented in JSON.");
+ }
+
+ // Integer: digits only.
+ if (IsAll(body, 0, IsDigit))
+ {
+ string digits = body.TrimStart('0').PadLeft(1, '0');
+ return JsonNode.Parse(negative && digits != "0" ? "-" + digits : digits);
+ }
+
+ // Float: [digits][.digits][e[+-]digits], with at least one digit somewhere in the mantissa.
+ int mantissaEnd = 0;
+ while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd]))
+ {
+ mantissaEnd++;
+ }
+
+ int integerDigits = mantissaEnd;
+ int fractionDigits = 0;
+ if (mantissaEnd < body.Length && body[mantissaEnd] == '.')
+ {
+ mantissaEnd++;
+ while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd]))
+ {
+ mantissaEnd++;
+ fractionDigits++;
+ }
+ }
+
+ if (integerDigits + fractionDigits == 0)
+ {
+ return null;
+ }
+
+ int exponentEnd = mantissaEnd;
+ if (exponentEnd < body.Length && body[exponentEnd] is 'e' or 'E')
+ {
+ exponentEnd++;
+ if (exponentEnd < body.Length && body[exponentEnd] is '+' or '-')
+ {
+ exponentEnd++;
+ }
+
+ int exponentDigits = 0;
+ while (exponentEnd < body.Length && IsDigit(body[exponentEnd]))
+ {
+ exponentEnd++;
+ exponentDigits++;
+ }
+
+ if (exponentDigits == 0)
+ {
+ return null;
+ }
+ }
+
+ if (exponentEnd != body.Length)
+ {
+ return null;
+ }
+
+ if (!double.TryParse(body, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) || double.IsInfinity(parsed))
+ {
+ throw new UnrepresentableValueException($"Line {lineNumber}: '{text}' cannot be represented as a JSON number.");
+ }
+
+ // JSON has no negative zero worth preserving: reference parsers render -0 and -0.0 as 0.
+ return JsonValue.Create(negative && parsed != 0 ? -parsed : parsed);
+ }
+
+ private static bool IsDigit(char c) => c is >= '0' and <= '9';
+
+ private static bool IsHexDigit(char c) => c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F');
+
+ private static bool IsAll(string text, int start, Func predicate)
+ {
+ if (start >= text.Length)
+ {
+ return false;
+ }
+
+ for (int i = start; i < text.Length; i++)
+ {
+ if (!predicate(text[i]))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static int SkipSpaces(string text, int pos)
+ {
+ while (pos < text.Length && text[pos] is ' ' or '\t')
+ {
+ pos++;
+ }
+
+ return pos;
+ }
+
+ /// Finds a plain key's separator; flow context also treats a following flow indicator as separation.
+ private static int FindKeySeparator(string content, bool inFlow = false)
+ {
+ for (int i = 0; i < content.Length; i++)
+ {
+ if (content[i] == ':' &&
+ (i + 1 == content.Length ||
+ content[i + 1] is ' ' or '\t' ||
+ (inFlow && Array.IndexOf(s_flowIndicators, content[i + 1]) >= 0)))
+ {
+ return i;
+ }
+
+ if (content[i] is ' ' or '\t' && i + 1 < content.Length && content[i + 1] == '#')
+ {
+ // Anything after " #" is a comment; a key cannot be separated inside one.
+ return -1;
+ }
+ }
+
+ return -1;
+ }
+
+ ///
+ /// Prepares the remainder of a line for : quoted values and flow collections are
+ /// returned as written, since their own parsers find their end and check what follows; anything else is a
+ /// plain value, from which a trailing comment is removed.
+ ///
+ private static string PrepareValue(string rest, out bool commentEndedValue)
+ {
+ commentEndedValue = false;
+ rest = rest.TrimStart(s_yamlWhitespace);
+ if (rest.Length > 0 && rest[0] is '"' or '\'' or '[' or '{')
+ {
+ return rest.TrimEnd(s_yamlWhitespace);
+ }
+
+ string stripped = StripComment(rest);
+ commentEndedValue = stripped.Length != rest.Length && stripped.Trim(s_yamlWhitespace).Length > 0;
+ return stripped.Trim(s_yamlWhitespace);
+ }
+
+ ///
+ /// Removes a trailing comment from plain text. A comment must follow whitespace unless the text already
+ /// starts at a separation boundary; quotes inside plain text never suppress a comment.
+ ///
+ private static string StripComment(string text, bool allowLeadingComment = true)
+ {
+ for (int i = 0; i < text.Length; i++)
+ {
+ bool isSeparated = i == 0 ? allowLeadingComment : text[i - 1] is ' ' or '\t';
+ if (text[i] == '#' &&
+ isSeparated)
+ {
+ return text.Substring(0, i);
+ }
+ }
+
+ return text;
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs
new file mode 100644
index 000000000..480275dc9
--- /dev/null
+++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs
@@ -0,0 +1,411 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Extensions.Skills;
+
+///
+/// Validates skill entries against the structural requirements of the Skills extension specification, so that
+/// a server never publishes an entry a conforming host would refuse to load.
+///
+internal static class SkillValidation
+{
+ private const string SkillFileSuffix = "/" + SkillsProtocol.SkillFileName;
+
+ ///
+ /// Returns the skill root (the SKILL.md URI with its /SKILL.md suffix removed), or throws if
+ /// does not end in /SKILL.md.
+ ///
+ public static string GetSkillRoot(string skillUri, string paramName)
+ {
+ if (string.IsNullOrEmpty(skillUri))
+ {
+ throw new ArgumentException("A skill URI must not be null or empty.", paramName);
+ }
+
+ if (!skillUri.EndsWith(SkillFileSuffix, StringComparison.Ordinal) || skillUri.Length == SkillFileSuffix.Length)
+ {
+ throw new ArgumentException(
+ $"The skill URI '{skillUri}' must be the URI of the skill's {SkillsProtocol.SkillFileName}, ending in '{SkillFileSuffix}'.",
+ paramName);
+ }
+
+ return skillUri.Substring(0, skillUri.Length - SkillFileSuffix.Length);
+ }
+
+ ///
+ /// Returns the decoded final path segment of a skill root, which the specification requires to equal the skill's name.
+ ///
+ public static string GetNameSegment(string skillRoot)
+ {
+ int start = Math.Max(skillRoot.LastIndexOf('/') + 1, skillRoot.IndexOf(':') + 1);
+ return System.Uri.UnescapeDataString(skillRoot.Substring(start));
+ }
+
+ ///
+ /// Returns whether satisfies the Agent Skills naming rules: 1 to 64 characters of
+ /// Unicode lowercase (or caseless) letters, digits, and hyphens, with no leading, trailing, or consecutive hyphens.
+ ///
+ public static bool IsValidSkillName(string name)
+ {
+ if (name.Length == 0)
+ {
+ return false;
+ }
+
+ int scalarCount = 0;
+ bool previousIsHyphen = true;
+ for (int index = 0; index < name.Length; index++)
+ {
+ char c = name[index];
+ bool isHyphen = c == '-';
+ var category = CharUnicodeInfo.GetUnicodeCategory(name, index);
+ bool isAlphanumeric = category is UnicodeCategory.LowercaseLetter or
+ UnicodeCategory.ModifierLetter or UnicodeCategory.OtherLetter or UnicodeCategory.DecimalDigitNumber;
+ if (!isAlphanumeric &&
+ !isHyphen)
+ {
+ return false;
+ }
+
+ if (isHyphen &&
+ previousIsHyphen)
+ {
+ return false;
+ }
+
+ if (++scalarCount > 64)
+ {
+ return false;
+ }
+
+ if (char.IsHighSurrogate(c))
+ {
+ index++;
+ }
+
+ previousIsHyphen = isHyphen;
+ }
+
+ return !previousIsHyphen;
+ }
+
+ ///
+ /// Returns whether has the form sha256:{hex} with 64 lowercase hexadecimal digits.
+ ///
+ public static bool IsValidDigest(string? digest)
+ {
+ const int HexLength = 64;
+ if (digest is null ||
+ digest.Length != SkillsProtocol.DigestPrefix.Length + HexLength ||
+ !digest.StartsWith(SkillsProtocol.DigestPrefix, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ for (int i = SkillsProtocol.DigestPrefix.Length; i < digest.Length; i++)
+ {
+ if (digest[i] is not ((>= '0' and <= '9') or (>= 'a' and <= 'f')))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Checks that is an absolute URI whose path has no empty, ., or ..
+ /// segments and no query or fragment, so that a prefix comparison against a skill root is meaningful.
+ ///
+ public static void ValidateUriShape(string uri, string what, string paramName)
+ {
+ int schemeEnd = uri.IndexOf(':');
+ if (schemeEnd <= 0 ||
+ !System.Uri.TryCreate(uri, UriKind.Absolute, out _))
+ {
+ throw new ArgumentException($"{what} '{uri}' must be a syntactically valid absolute URI with a scheme, such as skill://name/SKILL.md.", paramName);
+ }
+
+ if (uri.IndexOf('?') >= 0 || uri.IndexOf('#') >= 0)
+ {
+ throw new ArgumentException($"{what} '{uri}' must not contain a query or fragment.", paramName);
+ }
+
+ // Check the raw text rather than System.Uri's view of it, which decodes and compacts dot segments, so that
+ // "%2e%2e" is caught as well as "..". The authority (the first segment) may be empty, as in
+ // file:///name/SKILL.md; path segments may not.
+ int pathStart = schemeEnd + 1;
+ bool hasAuthority =
+ pathStart + 1 < uri.Length &&
+ uri[pathStart] == '/' &&
+ uri[pathStart + 1] == '/';
+ if (hasAuthority)
+ {
+ pathStart += 2;
+ }
+ else if (pathStart < uri.Length &&
+ uri[pathStart] == '/')
+ {
+ pathStart++;
+ }
+
+ string[] segments = uri.Substring(pathStart).Split('/');
+ for (int i = 0; i < segments.Length; i++)
+ {
+ string segment = segments[i];
+ if (segment.Length == 0 &&
+ (i > 0 || !hasAuthority))
+ {
+ throw new ArgumentException($"{what} '{uri}' must not contain empty path segments.", paramName);
+ }
+
+ string decoded = System.Uri.UnescapeDataString(segment);
+ if (decoded == "." || decoded == ".." || decoded.IndexOf('/') >= 0 || decoded.IndexOf('\\') >= 0)
+ {
+ throw new ArgumentException(
+ $"{what} '{uri}' must not contain '.', '..', or encoded separator path segments, which would resolve outside the skill.",
+ paramName);
+ }
+ }
+ }
+
+ ///
+ /// Returns a copy of that shares no mutable state with it.
+ ///
+ public static Skill Snapshot(Skill skill) => new()
+ {
+ Uri = skill.Uri,
+ Frontmatter = (JsonObject)skill.Frontmatter.DeepClone(),
+ Meta = (JsonObject?)skill.Meta?.DeepClone(),
+ Resources = skill.Resources.IsDynamic
+ ? SkillResources.Dynamic
+ : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })),
+ };
+
+ ///
+ /// Validates an entry received from a server, throwing describing the
+ /// first violation found. Hosts must not load invalid entries, so an invalid entry is a verification failure.
+ ///
+ public static void ValidateReceived(Skill skill, string method)
+ {
+ try
+ {
+ Validate(skill, "skill");
+ }
+ catch (ArgumentException e)
+ {
+ throw new SkillVerificationException($"The server returned an invalid skill entry from '{method}': {e.Message}", e);
+ }
+ }
+
+ ///
+ /// Validates a complete skill entry, throwing describing the first violation found.
+ ///
+ public static void Validate(Skill skill, string paramName)
+ {
+ if (skill is null)
+ {
+ throw new ArgumentNullException(paramName);
+ }
+
+ string root = GetSkillRoot(skill.Uri, paramName);
+ ValidateUriShape(skill.Uri, "The skill URI", paramName);
+ string nameSegment = GetNameSegment(root);
+
+ if (skill.Frontmatter is null)
+ {
+ throw new ArgumentException($"Skill '{skill.Uri}' has no frontmatter.", paramName);
+ }
+
+ string? name = skill.Name;
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'name' in its frontmatter.", paramName);
+ }
+
+ if (!IsValidSkillName(name!))
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has the name '{name}', which does not satisfy the Agent Skills naming rules " +
+ "(1 to 64 lowercase or caseless letters, digits, and single hyphens, not starting or ending with a hyphen).",
+ paramName);
+ }
+
+ if (!string.Equals(name, nameSegment, StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has the name '{name}', but the path segment preceding /{SkillsProtocol.SkillFileName} is '{nameSegment}'. " +
+ "The specification requires them to be equal.",
+ paramName);
+ }
+
+ string? description = skill.Description;
+ if (string.IsNullOrEmpty(description))
+ {
+ throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName);
+ }
+
+ int descriptionLength = Encoding.UTF32.GetByteCount(description!) / 4;
+ if (descriptionLength > MaxDescriptionLength)
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has a description of {descriptionLength} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.",
+ paramName);
+ }
+
+ ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName);
+ ValidateOptionalStringType(skill, "license", "a license name or a reference to a bundled license file", paramName);
+
+ if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode))
+ {
+ if (metadataNode is not JsonObject metadata)
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it string key-value entries, or remove the key if it is not needed.",
+ paramName);
+ }
+
+ foreach (var entry in metadata)
+ {
+ if (entry.Value is null ||
+ entry.Value.GetValueKind() != JsonValueKind.String)
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has a 'metadata' value for '{entry.Key}' that is not a string.",
+ paramName);
+ }
+ }
+ }
+
+ ValidateOptionalStringType(skill, "allowed-tools", "a space-separated string of tools", 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);
+ }
+
+ int length = Encoding.UTF32.GetByteCount(text!) / 4;
+ if (length == 0 ||
+ (maxLength is { } max && length > max))
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.",
+ paramName);
+ }
+ }
+
+ // Type-only check for an optional field the Agent Skills specification defines as a string without a length rule.
+ // It uses the same string test as the Skill accessors, so an entry that passes always reads back as a string.
+ private static void ValidateOptionalStringType(Skill skill, string key, string definition, string paramName)
+ {
+ if (skill.Frontmatter.TryGetPropertyValue(key, out var node) &&
+ !(node is JsonValue value && value.TryGetValue(out string? _)))
+ {
+ throw new ArgumentException(
+ $"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string. The Agent Skills specification defines it as {definition}.",
+ 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 a61dfd3ee..062f5a2ee 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/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs
index 98c225dd9..b38bb0ffe 100644
--- a/tests/Common/Utils/NodeHelpers.cs
+++ b/tests/Common/Utils/NodeHelpers.cs
@@ -396,10 +396,8 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version)
return (core, prerelease);
}
-
- /// whether it succeeded along with the captured stdout/stderr. Centralizes the process
- /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback)
- /// shared by the server-side conformance tests.
+ ///
+ /// Runs the server-side conformance process and captures its output, by default within a five-minute deadline.
///
/// Arguments to pass to the conformance runner.
/// Optional callback invoked for each captured stdout/stderr line.
@@ -407,112 +405,214 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version)
/// Forwarded to .
///
/// Token used to cancel the run.
+ /// An alternative process for isolated process-outcome tests.
+ /// The deadline for the process to exit and close its output. Defaults to five minutes.
+ ///
+ /// Success requires the process to exit with code zero and both redirected streams to reach their end, all within
+ /// one deadline. On timeout or cancellation the result is a failure, even if the process had already exited; a
+ /// process that is still running is killed with its child processes.
+ ///
public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync(
string arguments,
Action? onLine = null,
bool appendProtocolVersionFromEnv = true,
- CancellationToken cancellationToken = default)
+ CancellationToken cancellationToken = default,
+ ProcessStartInfo? processStartInfo = null,
+ TimeSpan? timeout = null)
{
- var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv);
+ var startInfo = processStartInfo ?? ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv);
+ var deadline = timeout ?? TimeSpan.FromMinutes(5);
var outputBuilder = new StringBuilder();
var errorBuilder = new StringBuilder();
+ var bufferLock = new object();
+ bool capturing = true;
using var process = new Process { StartInfo = startInfo };
- // Protect callbacks with try/catch so a callback that throws on a background thread
- // (e.g. ITestOutputHelper after the test completes) does not crash the test host.
- DataReceivedEventHandler outputHandler = (sender, e) =>
+ void Capture(string line, StringBuilder builder)
{
- if (e.Data != null)
+ // The lock keeps a late line from changing the buffers after the final snapshot.
+ lock (bufferLock)
{
- try { onLine?.Invoke(e.Data); } catch { }
- outputBuilder.AppendLine(e.Data);
- }
- };
+ if (!capturing)
+ {
+ return;
+ }
- DataReceivedEventHandler errorHandler = (sender, e) =>
- {
- if (e.Data != null)
- {
- try { onLine?.Invoke(e.Data); } catch { }
- errorBuilder.AppendLine(e.Data);
+ builder.AppendLine(line);
}
- };
- process.OutputDataReceived += outputHandler;
- process.ErrorDataReceived += errorHandler;
+ // Protect callbacks with try/catch so a callback that throws (e.g. ITestOutputHelper after the test
+ // completes) does not stop the reader.
+ try { onLine?.Invoke(line); } catch { }
+ }
+
+ // Each stream is read on its own thread. On Windows, Begin*ReadLine block a pool thread per stream while the
+ // pipe is open; with a descendant holding both pipes, a busy pool can have no thread left for the deadline.
+ Task ReadLinesAsync(StreamReader reader, StringBuilder builder) => Task.Factory.StartNew(
+ () =>
+ {
+ using (reader)
+ {
+ string? line;
+ while ((line = reader.ReadLine()) is not null)
+ {
+ Capture(line, builder);
+ }
+ }
+ },
+ CancellationToken.None,
+ TaskCreationOptions.LongRunning,
+ TaskScheduler.Default);
+
+#if !NET
+ // .NET Framework has no cancellable WaitForExitAsync. Register the exit before the process starts, so the wait
+ // below can cover it and the end of both streams under one deadline, as on .NET.
+ var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ EventHandler exitedHandler = (sender, e) => exited.TrySetResult(true);
+
+ process.EnableRaisingEvents = true;
+ process.Exited += exitedHandler;
+#endif
process.Start();
- process.BeginOutputReadLine();
- process.BeginErrorReadLine();
+ var outputRead = ReadLinesAsync(process.StandardOutput, outputBuilder);
+ var errorRead = ReadLinesAsync(process.StandardError, errorBuilder);
+ // One deadline covers the exit and both streams, so output held open by another process cannot extend it.
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
- cts.CancelAfter(TimeSpan.FromMinutes(5));
+ cts.CancelAfter(deadline);
+ bool completed;
+#if NET
try
{
-#if NET
+ // Waits for the exit and then for both redirected streams to end, all under the same token.
await process.WaitForExitAsync(cts.Token);
-#else
- // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill
- // extension and surface a timeout the same way the current target-framework path does.
- await process.WaitForExitAsync(TimeSpan.FromMinutes(5));
- if (!process.HasExited)
- {
- throw new OperationCanceledException();
- }
-#endif
+ await Task.WhenAll(outputRead, errorRead).WaitAsync(cts.Token);
+ completed = true;
}
catch (OperationCanceledException)
{
+ completed = false;
+ }
+#else
+ var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using (cts.Token.Register(() => stopped.TrySetResult(true)))
+ {
+ var finished = Task.WhenAll(exited.Task, outputRead, errorRead);
+ completed = await Task.WhenAny(finished, stopped.Task) == finished;
+ }
+#endif
+
+ bool killed = false;
+ if (!completed)
+ {
+ // Only a process that is still running is killed, with its child processes; it can still exit between the
+ // check and the kill.
+ try
+ {
+ if (!process.HasExited)
+ {
#if NET
- process.Kill(entireProcessTree: true);
+ process.Kill(entireProcessTree: true);
#else
- process.Kill();
+ KillProcessTree(process);
#endif
- process.OutputDataReceived -= outputHandler;
- process.ErrorDataReceived -= errorHandler;
- return (
- false,
- outputBuilder.ToString(),
- errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed.");
+ killed = true;
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // It exited before the kill.
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ // It was already exiting.
+ }
}
- process.OutputDataReceived -= outputHandler;
- process.ErrorDataReceived -= errorHandler;
+#if !NET
+ process.Exited -= exitedHandler;
+#endif
+
+ string stdoutText;
+ string stderrText;
+ lock (bufferLock)
+ {
+ capturing = false;
+ stdoutText = outputBuilder.ToString();
+ stderrText = errorBuilder.ToString();
+ }
- var stdoutText = outputBuilder.ToString();
- var stderrText = errorBuilder.ToString();
+ if (!completed)
+ {
+ stderrText += $"{Environment.NewLine}Process timed out after {deadline.TotalSeconds} seconds before it exited and closed its output" +
+ (killed ? " and was killed." : ".");
+ return (false, stdoutText, stderrText);
+ }
- // The Node.js conformance runner can crash during cleanup on Windows with a libuv
- // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit
- // code even though every conformance check passed. When that happens, fall back to
- // parsing the "Test Results:" summary in stdout to decide success.
- bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText);
+ bool success = process.ExitCode == 0;
+ if (!success)
+ {
+ stderrText += $"{Environment.NewLine}Conformance runner exited with code {process.ExitCode}.";
+ }
return (success, stdoutText, stderrText);
}
+#if !NET
///
- /// Parses the conformance runner output for a "Test Results:" line such as
- /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed
- /// and none failed.
+ /// Kills a process and its child processes, like Process.Kill(entireProcessTree: true) on .NET. On Windows
+ /// this runs taskkill /T /F for at most ten seconds; if that cannot run, does not finish, or fails, only
+ /// the process itself is killed.
///
- private static bool ConformanceOutputIndicatesSuccess(string output)
+ private static void KillProcessTree(Process process)
{
- // Match lines like "Passed: 3/3, 0 failed, 0 warnings"
- var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed");
- if (!match.Success)
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
- return false;
- }
+ try
+ {
+ using var taskkill = new Process
+ {
+ StartInfo = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "taskkill.exe"), $"/T /F /PID {process.Id}")
+ {
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ },
+ };
+ taskkill.Start();
+ taskkill.BeginOutputReadLine();
+ taskkill.BeginErrorReadLine();
+ if (taskkill.WaitForExit(10_000) &&
+ taskkill.ExitCode == 0)
+ {
+ return;
+ }
- int passed = int.Parse(match.Groups[1].Value);
- int total = int.Parse(match.Groups[2].Value);
- int failed = int.Parse(match.Groups[3].Value);
+ if (!taskkill.HasExited)
+ {
+ taskkill.Kill();
+ }
+ }
+ catch (InvalidOperationException)
+ {
+ // taskkill exited before it could be stopped.
+ }
+ catch (System.ComponentModel.Win32Exception)
+ {
+ // taskkill could not be started or stopped.
+ }
+ }
- return passed == total && failed == 0 && total > 0;
+ if (!process.HasExited)
+ {
+ process.Kill();
+ }
}
+#endif
///
/// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult)
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
index 626891314..cf8096b18 100644
--- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj
@@ -19,11 +19,13 @@
+
+
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
index e71c6a6c9..4a8d2098d 100644
--- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
@@ -1,18 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Apps;
+using ModelContextProtocol.Extensions.Skills;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.IO.Pipelines;
+using System.Text.Json.Nodes;
Pipe clientToServerPipe = new(), serverToClientPipe = new();
+var authoredSkill = McpServerSkill.Create(
+ "skill://aot/SKILL.md",
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]);
+var expectedMeta = JsonNode.Parse("""{"example.com/aot":{"values":["native",42,true]}}""")!.AsObject();
+var entry = authoredSkill.ProtocolSkill;
+entry.Meta = (JsonObject)expectedMeta.DeepClone();
+var catalog = new InMemoryMcpSkillCatalog([entry]);
+entry.Meta["example.com/aot"]!["values"]!.AsArray().Clear();
+
var services = new ServiceCollection();
services.AddMcpServer()
.WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())
.WithTools()
- .WithMcpApps();
+ .WithMcpApps()
+ .WithSkills(catalog)
+ .WithResources(authoredSkill.Resources);
await using var serviceProvider = services.BuildServiceProvider();
var server = serviceProvider.GetRequiredService();
@@ -42,6 +55,44 @@
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.");
+}
+
+if (!JsonNode.DeepEquals(expectedMeta, skills[0].Meta))
+{
+ throw new Exception("Unexpected skill entry metadata in the listing.");
+}
+
+var skillResult = await client.GetSkillAsync(new GetSkillRequestParams { Uri = "skill://aot/SKILL.md" });
+if (skillResult.ResultType != "complete" ||
+ skillResult.TimeToLive != TimeSpan.Zero ||
+ skillResult.CacheScope != CacheScope.Private)
+{
+ throw new Exception("Unexpected skill cache hints.");
+}
+
+var skill = skillResult.Skill;
+if (!JsonNode.DeepEquals(expectedMeta, skill.Meta))
+{
+ throw new Exception("Unexpected skill entry metadata in the lookup.");
+}
+
+if (skill.Description != "An AOT-published skill." ||
+ skill.Frontmatter["metadata"]?["note"]?.GetValue() != "First\n\tCode\nLast\n")
+{
+ throw new Exception("Unexpected skill frontmatter.");
+}
+
+var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri);
+if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot"))
+{
+ throw new Exception("Unexpected skill content.");
+}
+
Console.WriteLine("Success!");
[McpServerToolType]
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
index 21fb98c2e..6bd8c749b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using Microsoft.AspNetCore.Builder;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
@@ -7,34 +7,42 @@ namespace ModelContextProtocol.ConformanceTests;
/// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP
/// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at
/// , "/stateless") on one port. A single long-lived server avoids
-/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and
-/// centralizes the port-binding logic that the stateful and stateless conformance tests previously
-/// duplicated. Shared by and
+/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows. Kestrel
+/// assigns a loopback port, and initialization awaits this host's startup before publishing its URL.
+/// Shared by and
/// via .
///
public sealed class ConformanceServerFixture : IAsyncLifetime
{
- // Use different ports for each target framework to allow parallel execution across the
- // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003.
- private static int GetPortForTargetFramework()
+ private readonly Func _createApplication;
+ private readonly TimeProvider _timeProvider;
+ private WebApplication? _app;
+ private string? _serverUrl;
+ private CancellationTokenSource? _startupTimeoutCts;
+ private CancellationTokenSource? _startupCts;
+ private Task? _startupTask;
+ private Task? _stopTask;
+ private Task? _disposeTask;
+ private bool _startupOutcomeObserved;
+
+ public ConformanceServerFixture()
+ : this(() => new ConformanceServer.Program().CreateApplication(["--urls", "http://127.0.0.1:0"]))
{
- var testBinaryDir = AppContext.BaseDirectory;
- var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
+ }
- return targetFramework switch
- {
- "net10.0" => 3001,
- "net9.0" => 3002,
- "net8.0" => 3003,
- _ => 3001 // Default fallback
- };
+ internal ConformanceServerFixture(Func createApplication)
+ : this(createApplication, TimeProvider.System)
+ {
}
- private Task? _serverTask;
- private CancellationTokenSource? _serverCts;
+ internal ConformanceServerFixture(Func createApplication, TimeProvider timeProvider)
+ {
+ _createApplication = createApplication;
+ _timeProvider = timeProvider;
+ }
/// Base URL of the stateful MCP endpoint (mapped at "/").
- public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
+ public string ServerUrl => _serverUrl ?? throw new InvalidOperationException("The conformance server has not started.");
///
/// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28
@@ -42,57 +50,115 @@ private static int GetPortForTargetFramework()
///
public string StatelessServerUrl => $"{ServerUrl}/stateless";
- public async ValueTask InitializeAsync()
- {
- _serverCts = new CancellationTokenSource();
- _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
- ["--urls", ServerUrl], cancellationToken: _serverCts.Token));
+ public ValueTask InitializeAsync() => InitializeAsync(TestContext.Current.CancellationToken);
- // Wait for server to be ready (retry for up to 30 seconds)
- var timeout = TimeSpan.FromSeconds(30);
- var stopwatch = Stopwatch.StartNew();
- using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
+ internal async ValueTask InitializeAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _startupTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30), _timeProvider);
+ _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _startupTimeoutCts.Token);
+ _app = _createApplication();
+ _startupTask = _app.StartAsync(_startupCts.Token);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var registration = _startupCts.Token.Register(() => cancelled.TrySetResult(true));
- while (stopwatch.Elapsed < timeout)
+ try
{
- try
- {
- await httpClient.GetAsync($"{ServerUrl}/health");
- return;
- }
- catch (HttpRequestException)
+ await Task.WhenAny(_startupTask, cancelled.Task);
+ if (_startupTask.IsCompleted)
{
- // Connection refused means server not ready yet
- }
- catch (TaskCanceledException)
- {
- // Timeout means server might be processing, give it more time
+ _startupOutcomeObserved = true;
+ await _startupTask;
}
+ _startupCts.Token.ThrowIfCancellationRequested();
+ _startupTimeoutCts.CancelAfter(Timeout.InfiniteTimeSpan);
+ }
+ catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested &&
+ _startupCts.IsCancellationRequested)
+ {
+ throw new TimeoutException("ConformanceServer failed to start within 30 seconds.", error);
+ }
+ catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested &&
+ _app.Lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ throw new InvalidOperationException("ConformanceServer stopped before startup completed.", error);
+ }
- await Task.Delay(500);
+ if (!_app.Lifetime.ApplicationStarted.IsCancellationRequested ||
+ _app.Lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ throw new InvalidOperationException("ConformanceServer stopped before startup completed.");
}
- throw new InvalidOperationException("ConformanceServer failed to start within the timeout period");
+ _serverUrl = _app.Urls.Single();
}
public async ValueTask DisposeAsync()
{
- if (_serverCts != null)
+ if (_app is null)
+ {
+ DisposeStartupCancellation();
+ return;
+ }
+
+ using var shutdownCts = new CancellationTokenSource(TestConstants.DefaultTimeout, _timeProvider);
+ try
{
- _serverCts.Cancel();
- if (_serverTask != null)
+ if (_startupCts is not null)
{
- try
- {
- await _serverTask.WaitAsync(TestConstants.DefaultTimeout);
- }
- catch
+ await _startupCts.CancelAsync().WaitAsync(shutdownCts.Token);
+ }
+
+ if (_startupTask is not null)
+ {
+ // Wait for completion without throwing its already-reported startup error.
+ // A timed-out, still-running startup retains ownership of the app and tokens.
+ await WaitForCompletionAsync(_startupTask, shutdownCts.Token);
+ }
+
+ shutdownCts.Token.ThrowIfCancellationRequested();
+ _stopTask ??= _app.StopAsync(shutdownCts.Token);
+ await WaitForCompletionAsync(_stopTask, shutdownCts.Token);
+ shutdownCts.Token.ThrowIfCancellationRequested();
+ _disposeTask ??= _app.DisposeAsync().AsTask();
+ await _disposeTask.WaitAsync(shutdownCts.Token);
+
+ var startupOutcome = Task.CompletedTask;
+ if (!_startupOutcomeObserved &&
+ _startupTask is not null)
+ {
+ _startupOutcomeObserved = true;
+ bool expectedCancellation = _startupTask.IsCanceled &&
+ (_startupCts?.IsCancellationRequested).GetValueOrDefault();
+ if (!expectedCancellation)
{
- // Ignore exceptions during shutdown
+ startupOutcome = _startupTask;
}
}
- _serverCts.Dispose();
+
+ _app = null;
+ _serverUrl = null;
+ DisposeStartupCancellation();
+
+ // Observe failures only after safely releasing the host; neither a late startup
+ // fault nor a StopAsync failure is converted into successful cleanup.
+ await Task.WhenAll(_stopTask, startupOutcome);
}
+ catch (OperationCanceledException error) when (shutdownCts.IsCancellationRequested)
+ {
+ throw new TimeoutException("ConformanceServer cleanup did not complete within 60 seconds.", error);
+ }
+ }
+
+ private async Task WaitForCompletionAsync(Task task, CancellationToken cancellationToken)
+ {
+ await Task.WhenAny(task).WaitAsync(cancellationToken);
+ }
+
+ private void DisposeStartupCancellation()
+ {
+ _startupCts?.Dispose();
+ _startupTimeoutCts?.Dispose();
}
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs
new file mode 100644
index 000000000..40bf6f516
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs
@@ -0,0 +1,351 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.ConformanceTests;
+using ModelContextProtocol.Tests.Utils;
+using System.Net;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+public class ConformanceServerFixtureTests(ITestOutputHelper output) : LoggedTest(output)
+{
+ [Fact]
+ public async Task Initialize_ActualConformanceHosts_OwnDistinctReadyEndpoints()
+ {
+ await using var first = new ConformanceServerFixture();
+ await using var second = new ConformanceServerFixture();
+ await first.InitializeAsync(TestContext.Current.CancellationToken);
+ await second.InitializeAsync(TestContext.Current.CancellationToken);
+ Assert.NotEqual(first.ServerUrl, second.ServerUrl);
+
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout };
+ foreach (var fixture in new[] { first, second })
+ {
+ var address = new Uri(fixture.ServerUrl);
+ Assert.True(address.IsLoopback);
+ Assert.NotEqual(0, address.Port);
+ using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+
+ await using var transport = new HttpClientTransport(new() { Endpoint = new Uri(fixture.StatelessServerUrl) });
+ await using var client = await TestMcpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
+ Assert.NotEmpty(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_WaitsForStartupBeforePublishingUrl()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(true);
+ await release.Task.WaitAsync(cancellationToken);
+ })));
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ try
+ {
+ await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ Assert.False(startup.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+ finally
+ {
+ release.TrySetResult(true);
+ }
+ await startup;
+
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout };
+ using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Initialize_StartupFault_SurfacesOriginalError()
+ {
+ var expected = new InvalidOperationException("Synthetic host startup failure.");
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(
+ _ => new StartupService(_ => Task.FromException(expected))));
+
+ var actual = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask());
+ Assert.Same(expected, actual);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+
+ [Fact]
+ public async Task Initialize_HostStopsDuringStartup_DoesNotReportReady()
+ {
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(services =>
+ new StartupService(_ =>
+ {
+ services.GetRequiredService().StopApplication();
+ return Task.CompletedTask;
+ })));
+
+ var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask());
+ Assert.Contains("stopped before startup completed", error.Message);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+
+ [Fact]
+ public async Task Initialize_CallerCancellation_PropagatesToHostStartup()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(cancellationToken);
+ try
+ {
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ await observerRelease.Task;
+ cancelled.TrySetResult(true);
+ throw;
+ }
+ })));
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => startup);
+ Assert.True(startupToken.IsCancellationRequested);
+ Assert.False(cancelled.Task.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ observerRelease.TrySetResult(true);
+ Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ observerRelease.TrySetResult(true);
+ release.TrySetResult(true);
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_StartupTimeout_CancelsHostStartup()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(cancellationToken);
+ try
+ {
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ await observerRelease.Task;
+ cancelled.TrySetResult(true);
+ throw;
+ }
+ })));
+
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ try
+ {
+ var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ var error = await Assert.ThrowsAsync(() => startup);
+ Assert.Contains("30 seconds", error.Message);
+ Assert.True(startupToken.IsCancellationRequested);
+ Assert.False(cancelled.Task.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ observerRelease.TrySetResult(true);
+ Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ observerRelease.TrySetResult(true);
+ release.TrySetResult(true);
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_NoncooperativeStartup_IsBoundedAndOwnedUntilDrain()
+ {
+ var service = new NoncooperativeStartupService();
+ var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true));
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ Task? disposal = null;
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ await service.CancellationObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ var error = await Assert.ThrowsAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+ Assert.Contains("ConformanceServer failed to start within 30 seconds", error.Message);
+
+ disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ Assert.False(service.Disposed);
+ service.Release.TrySetResult(true);
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await Assert.ThrowsAsync(() => startup);
+ if (disposal is not null)
+ {
+ await disposal;
+ }
+ else
+ {
+ await fixture.DisposeAsync();
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Dispose_NoncooperativeStartup_ReportsDeadlineAndRetainsOwnership()
+ {
+ var clock = new FakeTimeProvider();
+ var service = new NoncooperativeStartupService();
+ var fixture = new ConformanceServerFixture(
+ () => CreateApplication(_ => service, useNoSocketServer: true), clock);
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ clock.Advance(TestConstants.DefaultTimeout);
+ var error = await Assert.ThrowsAsync(() =>
+ disposal.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+ Assert.Contains("cleanup did not complete within 60 seconds", error.Message);
+ Assert.False(service.Disposed);
+
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Dispose_LateStartupFault_IsReportedAfterSafeCleanup()
+ {
+ var expected = new InvalidOperationException("Synthetic late startup failure.");
+ var service = new NoncooperativeStartupService { Failure = expected };
+ var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true));
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(service.Disposed);
+ service.Release.TrySetResult(true);
+ var actual = await Assert.ThrowsAsync(() => disposal);
+ Assert.Same(expected, actual);
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ }
+ }
+
+ private WebApplication CreateApplication(Func serviceFactory, bool useNoSocketServer = false)
+ {
+ var builder = WebApplication.CreateBuilder(["--urls", "http://127.0.0.1:0"]);
+ builder.Logging.ClearProviders();
+ builder.Logging.AddProvider(XunitLoggerProvider);
+ if (useNoSocketServer)
+ {
+ builder.Services.AddSingleton();
+ }
+ builder.Services.AddSingleton(serviceFactory);
+ var app = builder.Build();
+ app.MapGet("/health", () => "Healthy");
+ return app;
+ }
+
+ private sealed class StartupService(Func start) : IHostedService
+ {
+ public Task StartAsync(CancellationToken cancellationToken) => start(cancellationToken);
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private sealed class NoncooperativeStartupService : IHostedService, IDisposable
+ {
+ public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public bool StartupCompleted { get; private set; }
+ public bool Disposed { get; private set; }
+ public bool DisposedDuringStartup { get; private set; }
+ public Exception? Failure { get; init; }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ using var registration = cancellationToken.Register(() => CancellationObserved.TrySetResult(true));
+ Entered.TrySetResult(true);
+ await Release.Task;
+ StartupCompleted = true;
+ if (Failure is { } error)
+ {
+ throw error;
+ }
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ DisposedDuringStartup = !StartupCompleted;
+ Disposed = true;
+ }
+ }
+
+ private sealed class NoSocketServer : IServer
+ {
+ public IFeatureCollection Features { get; } = new FeatureCollection();
+ public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public void Dispose() { }
+ }
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 2cb1d736e..d421ffe9d 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -1,4 +1,5 @@
using ModelContextProtocol.Tests.Utils;
+using System.Text.RegularExpressions;
namespace ModelContextProtocol.ConformanceTests;
@@ -15,6 +16,67 @@ public class ServerConformanceTests(
ConformanceServerFixture fixture,
ITestOutputHelper output)
{
+ [Theory]
+ [InlineData("sep-2640-skills-enumeration", "2026-07-28")]
+ [InlineData("sep-2640-skills-manifest", "2026-07-28")]
+ [InlineData("sep-2640-skills-directory", "2026-07-28")]
+ [InlineData("sep-2640-skills-enumeration", "2025-11-25")]
+ [InlineData("sep-2640-skills-manifest", "2025-11-25")]
+ [InlineData("sep-2640-skills-directory", "2025-11-25")]
+ public async Task RunSkillsConformanceTest(string scenario, string protocolVersion)
+ {
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
+ var url = protocolVersion == "2026-07-28" ? fixture.StatelessServerUrl : fixture.ServerUrl;
+
+ var result = await RunConformanceTestsAsync(
+ $"server --url {url} --scenario {scenario} --spec-version {protocolVersion} --force",
+ appendProtocolVersionFromEnv: false);
+
+ Assert.True(result.Success,
+ $"Skills scenario '{scenario}' for '{protocolVersion}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
+ Assert.DoesNotContain("SKIPPED: scenario", result.Output);
+ AssertSkillsSummary(result.Output);
+ }
+
+ [Fact]
+ public async Task InstalledRunnerListsAllRequiredSkillsScenarios()
+ {
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed.");
+
+ var result = await NodeHelpers.RunServerConformanceAsync(
+ "list --server",
+ appendProtocolVersionFromEnv: false,
+ cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.True(result.Success, $"Runner failed.\n{result.Output}\n{result.Error}");
+ Assert.Contains("sep-2640-skills-enumeration", result.Output);
+ Assert.Contains("sep-2640-skills-manifest", result.Output);
+ Assert.Contains("sep-2640-skills-directory", result.Output);
+ }
+
+ [Theory]
+ [InlineData("Passed: 1/1, 0 failed", true)]
+ [InlineData("Test Results:\nPassed: 32/32, 0 failed, 0 warnings\n", true)]
+ [InlineData("Test Results:\r\n Passed:\t6/6, 0 failed, 0 warnings\r\n", true)]
+ [InlineData("Passed: 0/0, 0 failed", false)]
+ [InlineData("Passed: 1/2, 0 failed", false)]
+ [InlineData("Passed: 2/2, 1 failed", false)]
+ [InlineData("Log: Passed: 1/1, 0 failed", false)]
+ [InlineData("Passed: 1/1, 0 failed unexpectedly", false)]
+ [InlineData("Passed: 1/1, 0 failed\nPassed: 0/0, 0 failed", false)]
+ [InlineData("", false)]
+ public void SkillsSummaryRequiresExactlyOnePositiveSuccessfulSummary(string output, bool valid)
+ {
+ if (valid)
+ {
+ AssertSkillsSummary(output);
+ }
+ else
+ {
+ Assert.ThrowsAny(() => AssertSkillsSummary(output));
+ }
+ }
+
[Fact]
public async Task RunConformanceTests()
{
@@ -152,11 +214,25 @@ public async Task RunTasksExtensionConformanceTest(string scenario)
$"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
- private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
+ private void AssertSkillsSummary(string output)
+ {
+ var pattern = new Regex(
+ @"^[ \t]*Passed:[ \t]*(?0|[1-9][0-9]*)/(?0|[1-9][0-9]*),[ \t]*(?0|[1-9][0-9]*)[ \t]+failed(?:,[^\r\n]*)?\r?$",
+ RegexOptions.Multiline | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking,
+ TimeSpan.FromSeconds(1));
+ var summary = Assert.Single(pattern.Matches(output).Cast());
+ Assert.NotEqual("0", summary.Groups["passed"].Value);
+ Assert.Equal(summary.Groups["total"].Value, summary.Groups["passed"].Value);
+ Assert.Equal("0", summary.Groups["failed"].Value);
+ }
+
+ private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(
+ string arguments, bool appendProtocolVersionFromEnv = true)
{
return await NodeHelpers.RunServerConformanceAsync(
arguments,
line => { try { output.WriteLine(line); } catch { } },
+ appendProtocolVersionFromEnv: appendProtocolVersionFromEnv,
cancellationToken: TestContext.Current.CancellationToken);
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs
new file mode 100644
index 000000000..ccf9a5c36
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs
@@ -0,0 +1,192 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.AspNetCore.Tests.Utils;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+using System.Security.Claims;
+using System.Text;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+public class SkillsAuthorizationTests(ITestOutputHelper output) : KestrelInMemoryTest(output)
+{
+ private const string SkillUri = "skill://private/report/SKILL.md";
+ private const string HiddenSkillUri = "skill://private/secret-payroll/SKILL.md";
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task PaginationDoesNotDiscloseDeniedEntriesThroughCursors(bool allowed)
+ {
+ await using var app = await StartServer(allowed, paginated: true);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await TestMcpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+ var seen = new List();
+ string? cursor = null;
+ int pages = 0;
+ do
+ {
+ var page = await client.ListSkillsAsync(
+ new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken);
+ Assert.DoesNotContain(page.Skills, skill => skill.Uri == HiddenSkillUri);
+ seen.AddRange(page.Skills.Select(skill => skill.Uri));
+ cursor = page.NextCursor;
+ if (cursor is not null)
+ {
+ var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor));
+ Assert.DoesNotContain("secret-payroll", decoded);
+ Assert.Equal(Assert.Single(page.Skills).Uri, decoded);
+ }
+ Assert.True(++pages <= 3, "Authorized pagination did not terminate.");
+ }
+ while (cursor is not null);
+
+ Assert.Equal(allowed ? 2 : 1, pages);
+ Assert.Equal(allowed ? new[] { "skill://private/zebra/SKILL.md", "skill://private/zulu/SKILL.md" } : [], seen);
+ var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal(seen, resources.Select(resource => resource.Uri).OrderBy(uri => uri, StringComparer.Ordinal));
+ await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(HiddenSkillUri, TestContext.Current.CancellationToken));
+ await Assert.ThrowsAsync(
+ async () => await client.ReadResourceAsync(HiddenSkillUri, cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed)
+ {
+ await using var app = await StartServer(allowed);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await TestMcpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
+ if (allowed)
+ {
+ Assert.Equal(SkillUri, Assert.Single(skills).Uri);
+ Assert.Equal(SkillUri, Assert.Single(resources).Uri);
+ var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken);
+ var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken);
+ Assert.Single(content.Contents);
+ }
+ else
+ {
+ Assert.Empty(skills);
+ Assert.Empty(resources);
+ var metadataError = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken));
+ Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode);
+ var resourceError = await Assert.ThrowsAsync(
+ async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.Equal(McpErrorCode.InvalidRequest, resourceError.ErrorCode);
+ }
+ }
+
+ [Fact]
+ public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead()
+ {
+ await using var app = await StartServer(allowed: true, hideResourceListing: true);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await TestMcpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken);
+ Assert.Equal(SkillUri, skill.Uri);
+ Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents);
+ }
+
+ [Fact]
+ public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess()
+ {
+ await using var app = await StartServer(allowed: true, authenticated: false);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await TestMcpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken));
+ }
+
+ private async Task StartServer(
+ bool allowed, bool hideResourceListing = false, bool authenticated = true, bool paginated = false)
+ {
+ var skills = paginated
+ ? new[] { CreateSkill("secret-payroll"), CreateSkill("zebra"), CreateSkill("zulu") }
+ : new[] { CreateSkill("report") };
+ var policy = new ReadPolicy();
+ var catalog = new AuthorizedCatalog(skills.Select(skill => skill.ProtocolSkill).ToArray(), policy, pageSize: 1);
+ Builder.Services.AddMcpServer()
+ .WithHttpTransport(options => options.Stateless = true)
+ .WithSkills(catalog)
+ .WithResources(skills.SelectMany(skill => skill.Resources))
+ .WithRequestFilters(filters =>
+ {
+ filters.AddListResourcesFilter(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+ result.Resources = result.Resources
+ .Where(resource => !hideResourceListing && policy.CanRead(context.User, resource.Uri))
+ .ToList();
+ return result;
+ });
+ filters.AddReadResourceFilter(next => (context, cancellationToken) =>
+ {
+ if (!policy.CanRead(context.User, context.Params.Uri))
+ {
+ throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest);
+ }
+ return next(context, cancellationToken);
+ });
+ });
+
+ var app = Builder.Build();
+ app.Use(async (context, next) =>
+ {
+ context.User = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], authenticated ? "Test" : null));
+ await next(context);
+ });
+ app.MapMcp();
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ return app;
+ }
+
+ private McpServerSkill CreateSkill(string name) => McpServerSkill.Create(
+ $"skill://private/{name}/SKILL.md",
+ [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: d\n---\n")]);
+
+ private sealed class ReadPolicy
+ {
+ public bool CanRead(ClaimsPrincipal? user, string uri) =>
+ uri.StartsWith("skill://private/", StringComparison.Ordinal) &&
+ uri != HiddenSkillUri &&
+ (user?.Identity?.IsAuthenticated).GetValueOrDefault() &&
+ (user?.IsInRole("reader")).GetValueOrDefault();
+ }
+
+ private sealed class AuthorizedCatalog(IReadOnlyList skills, ReadPolicy policy, int pageSize) : IMcpSkillCatalog
+ {
+ public ValueTask ListAsync(
+ string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ ForUser(context.User).ListAsync(cursor, context, cancellationToken);
+
+ public ValueTask GetAsync(
+ string uri, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ ForUser(context.User).GetAsync(uri, context, cancellationToken);
+
+ private InMemoryMcpSkillCatalog ForUser(ClaimsPrincipal? user) =>
+ new(skills.Where(skill => policy.CanRead(user, skill.Uri)), pageSize);
+ }
+}
diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
index dffffa9d3..7f2ca8212 100644
--- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
+++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
@@ -15,6 +15,7 @@
+
diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs
index 73f63821e..568644262 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs
@@ -1,8 +1,10 @@
using ConformanceServer.Prompts;
using ConformanceServer.Resources;
using ConformanceServer.Tools;
+using ModelContextProtocol.ConformanceServer.Skills;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Extensions.Skills;
using ModelContextProtocol.Extensions.Tasks;
using System.Collections.Concurrent;
using System.Diagnostics;
@@ -13,6 +15,12 @@ namespace ModelContextProtocol.ConformanceServer;
public class Program
{
public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default)
+ {
+ var app = new Program().CreateApplication(args, loggerProvider);
+ await app.RunAsync(cancellationToken);
+ }
+
+ public WebApplication CreateApplication(string[] args, ILoggerProvider? loggerProvider = null)
{
var builder = WebApplication.CreateBuilder(args);
@@ -37,7 +45,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
app.MapGet("/health", () => "Healthy");
- await app.RunAsync(cancellationToken);
+ return app;
}
// Registers the conformance MCP server (tools, prompts, resources, filters, and handlers)
@@ -53,6 +61,12 @@ private static void ConfigureConformanceMcpServer(
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = stateless)
.WithDistributedCacheEventStreamStore()
+ .WithSkills(ConformanceSkills.CreateCatalog(), options =>
+ {
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ })
+ .WithResources(ConformanceSkills.CreateResources())
.WithTasks(
new InMemoryMcpTaskStore
{
diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs
new file mode 100644
index 000000000..6fc96ed78
--- /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,
+/// dynamically generated skill is included in the same catalog: it is listed and answerable through
+/// skills/get, but its dynamic manifest 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/McpSkillsClientUnreadableTextTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs
new file mode 100644
index 000000000..608567af1
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs
@@ -0,0 +1,139 @@
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
+using System.Net;
+using System.Text;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Client;
+
+///
+/// A JSON string can hold an escaped unpaired surrogate such as "\uD800", which is not text. A server built on
+/// System.Text.Json cannot write one, so these tests answer the client with raw response bodies and check that the
+/// Skills client fails such an entry like any other malformed entry, without an .
+///
+public class McpSkillsClientUnreadableTextTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper)
+{
+ private const string ValidFrontmatter = """{"name":"good","description":"d"}""";
+ private const string UnpairedHighSurrogate = """{"name":"bad","description":"\uD800"}""";
+
+ [Theory]
+ [InlineData(UnpairedHighSurrogate)]
+ [InlineData("""{"name":"bad","description":"\uDC00 after an unpaired low surrogate"}""")]
+ [InlineData("""{"name":"bad","description":"d","\uD800":"in a key"}""")]
+ public async Task ListSkillsWithDiagnosticsAsync_RejectsAnEntryWithUnreadableText_AndKeepsTheValidEntry(string frontmatter)
+ {
+ await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", frontmatter)));
+ await using var client = await ConnectAsync(transport);
+
+ var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Equal("good", Assert.Single(listing.Page.Skills).Name);
+ var rejection = Assert.Single(listing.RejectedEntries);
+ Assert.Equal(1, rejection.Index);
+ AssertUnreadableText(Assert.IsType(rejection.Error));
+ }
+
+ [Fact]
+ public async Task ListSkillsWithDiagnosticsAsync_ThrowsJsonException_WhenThePageHasUnreadableText()
+ {
+ string page = """{"skills":[],"nextCursor":"\uD800"}""";
+ await using var transport = CreateTransport(listResult: page);
+ await using var client = await ConnectAsync(transport);
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken));
+
+ AssertUnreadableText(error);
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_ThrowsJsonException_ForAnEntryWithUnreadableText()
+ {
+ await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", UnpairedHighSurrogate)));
+ await using var client = await ConnectAsync(transport);
+
+ var error = await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ AssertUnreadableText(error);
+ }
+
+ [Fact]
+ public async Task GetSkillAsync_ThrowsJsonException_ForAnEntryWithUnreadableText()
+ {
+ await using var transport = CreateTransport(listResult: Page(), getResult: "{\"skill\":" + Entry("bad", UnpairedHighSurrogate) + "}");
+ await using var client = await ConnectAsync(transport);
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync("skill://bad/SKILL.md", TestContext.Current.CancellationToken));
+
+ AssertUnreadableText(error);
+ }
+
+ private void AssertUnreadableText(JsonException error)
+ {
+ Assert.Contains("not valid Unicode text", error.Message);
+ Assert.IsType(error.InnerException);
+ }
+
+ private string Page(params string[] entries) => "{\"skills\":[" + string.Join(",", entries) + "]}";
+
+ private string Entry(string name, string frontmatter) =>
+ "{\"uri\":\"skill://" + name + "/SKILL.md\",\"frontmatter\":" + frontmatter +
+ ",\"resources\":[{\"uri\":\"skill://" + name + "/SKILL.md\",\"digest\":\"sha256:" + new string('a', 64) + "\",\"size\":1}]}";
+
+ /// Creates a transport to a raw Streamable HTTP server that answers skills/list and skills/get with the given result bodies.
+ private HttpClientTransport CreateTransport(string listResult, string? getResult = null)
+ {
+ var handler = new MockHttpHandler { RequestHandler = request => RespondAsync(request, listResult, getResult) };
+ return new HttpClientTransport(new HttpClientTransportOptions
+ {
+ Endpoint = new Uri("http://localhost"),
+ TransportMode = HttpTransportMode.StreamableHttp,
+ Name = "Raw skills server test client",
+ }, new HttpClient(handler), LoggerFactory, ownsHttpClient: true);
+ }
+
+ private Task ConnectAsync(HttpClientTransport transport) => McpClient.CreateAsync(
+ transport,
+ new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion },
+ LoggerFactory,
+ TestContext.Current.CancellationToken);
+
+ private async Task RespondAsync(HttpRequestMessage request, string listResult, string? getResult)
+ {
+ // The server offers no standalone SSE stream, which the spec permits.
+ // net472 does not populate a default Content, so every response sets one explicitly.
+ if (request.Method != HttpMethod.Post)
+ {
+ var status = request.Method == HttpMethod.Get ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.OK;
+ return new HttpResponseMessage(status) { Content = new StringContent(string.Empty) };
+ }
+
+ using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync());
+ if (!body.RootElement.TryGetProperty("id", out var id))
+ {
+ return new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent(string.Empty) };
+ }
+
+ string method = body.RootElement.GetProperty("method").GetString()!;
+ string result = method switch
+ {
+ RequestMethods.Initialize =>
+ "{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + "\"," +
+ "\"capabilities\":{\"extensions\":{\"" + SkillsProtocol.ExtensionId + "\":{}}}," +
+ "\"serverInfo\":{\"name\":\"raw\",\"version\":\"1.0\"}}",
+ SkillsProtocol.MethodSkillsList => listResult,
+ SkillsProtocol.MethodSkillsGet when getResult is not null => getResult,
+ _ => throw new InvalidOperationException($"The raw test server does not answer '{method}'."),
+ };
+
+ var response = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":" + id.GetRawText() + ",\"result\":" + result + "}", Encoding.UTF8, "application/json"),
+ };
+ response.Headers.Add("mcp-session-id", "raw-session");
+ return response;
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs
new file mode 100644
index 000000000..b78f6a864
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs
@@ -0,0 +1,445 @@
+#pragma warning disable MCPEXP002 // Raw request handlers are the point of this test.
+
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Client;
+
+///
+/// The client extensions validate every entry a server returns against the specification's structural
+/// requirements, since hosts must not load invalid entries. These tests stand up a server whose raw
+/// skills/list and skills/get handlers return entries the SDK's own server side would never publish.
+///
+public class McpSkillsClientValidationTests : ClientServerTestBase
+{
+ private JsonObject? _listResponse;
+ private JsonObject? _getResponse;
+
+ public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ services.Configure(options =>
+ {
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= [];
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Handler = (request, _) =>
+ {
+ if (_listResponse is not null)
+ {
+ return new ValueTask(_listResponse.DeepClone());
+ }
+
+ var json = request.Params?["cursor"]?.GetValue() switch
+ {
+ "no-skills-array" => """{ "skills": null }""",
+ "missing-skills-array" => "{}",
+ "empty-skills-array" => """{ "skills": [] }""",
+ _ => """
+ { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" },
+ "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] },
+ { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" },
+ "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] }
+ """,
+ };
+ return new ValueTask(JsonNode.Parse(json));
+ },
+ });
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsGet,
+ Handler = (request, _) =>
+ {
+ if (_getResponse is not null)
+ {
+ return new ValueTask(_getResponse.DeepClone());
+ }
+
+ // Any request other than the escaping skill is answered with a valid entry for a different URI.
+ string? requested = request.Params?["uri"]?.GetValue();
+ string json = requested == "skill://escape/SKILL.md"
+ ? """
+ { "skill": { "uri": "skill://escape/SKILL.md", "frontmatter": { "name": "escape", "description": "d" },
+ "resources": [ { "uri": "skill://escape/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 },
+ { "uri": "skill://escape/../secret.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } }
+ """
+ : """
+ { "skill": { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" },
+ "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } }
+ """;
+ return new ValueTask(JsonNode.Parse(json));
+ },
+ });
+ });
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_RejectsAnInvalidEntry()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("skills/list", exception.Message);
+ Assert.Contains("skill://bad/SKILL.md", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("no-skills-array")]
+ [InlineData("missing-skills-array")]
+ public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string cursor)
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_AcceptsAnEmptySkillsArray()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Skills);
+ }
+
+ [Theory]
+ [InlineData("\U00010428")]
+ [InlineData("\U00020000")]
+ public async Task ListSkillsAsync_AcceptsSupplementaryUnicodeNames(string name)
+ {
+ _listResponse = CreateListResponse(name);
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(name, Assert.Single(result).Name);
+ }
+
+ [Theory]
+ [InlineData("café")]
+ [InlineData("数据分析")]
+ [InlineData("\U00010428")]
+ public async Task ClientMethodsAcceptPercentEncodedUnicodeNameSegments(string name)
+ {
+ var uri = $"skill://catalog/{Uri.EscapeDataString(name)}/SKILL.md";
+ _listResponse = CreateListResponse(name, uri: uri);
+ var entry = _listResponse["skills"]![0]!;
+ _getResponse = new JsonObject { ["skill"] = entry.DeepClone() };
+ await using var client = await CreateMcpClientForServer();
+
+ var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken);
+ var diagnostic = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Equal(uri, Assert.Single(listed).Uri);
+ Assert.Equal(name, fetched.Name);
+ Assert.Equal(uri, fetched.Uri);
+ Assert.Empty(diagnostic.RejectedEntries);
+ Assert.Equal(uri, Assert.Single(diagnostic.Page.Skills).Uri);
+ }
+
+ [Theory]
+ [InlineData("repo:/skills/alpha/SKILL.md")]
+ [InlineData("repo:skills/alpha/SKILL.md")]
+ [InlineData("repo:alpha/SKILL.md")]
+ [InlineData("urn:alpha/SKILL.md")]
+ public async Task ClientMethodsAcceptNativeAbsoluteResourceUris(string uri)
+ {
+ _listResponse = CreateListResponse("alpha", uri: uri);
+ var entry = _listResponse["skills"]![0]!;
+ _getResponse = new JsonObject { ["skill"] = entry.DeepClone() };
+ await using var client = await CreateMcpClientForServer();
+
+ var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken);
+ Assert.Equal(uri, Assert.Single(listed).Uri);
+ Assert.Equal(uri, fetched.Uri);
+ }
+
+ [Theory]
+ [InlineData("description", 1024, false)]
+ [InlineData("description", 1024, true)]
+ [InlineData("compatibility", 500, false)]
+ [InlineData("compatibility", 500, true)]
+ public async Task ClientMethodsUseScalarFrontmatterLimits(string field, int limit, bool overLimit)
+ {
+ int length = limit + (overLimit ? 1 : 0);
+ var text = "a" + string.Concat(Enumerable.Repeat("\U0001f600", length - 1));
+ _listResponse = CreateListResponse("alpha");
+ var entry = _listResponse["skills"]![0]!.AsObject();
+ entry["frontmatter"]![field] = text;
+ _getResponse = new JsonObject { ["skill"] = entry.DeepClone() };
+ var uri = entry["uri"]!.GetValue();
+ await using var client = await CreateMcpClientForServer();
+
+ if (overLimit)
+ {
+ var listError = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ var getError = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken));
+ Assert.Contains($"{length} characters", listError.Message);
+ Assert.Contains($"{length} characters", getError.Message);
+ }
+ else
+ {
+ var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken);
+ Assert.Equal(text, Assert.Single(listed).Frontmatter[field]!.GetValue());
+ Assert.Equal(text, fetched.Frontmatter[field]!.GetValue());
+ }
+
+ var diagnostic = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+ if (overLimit)
+ {
+ Assert.Empty(diagnostic.Page.Skills);
+ Assert.Contains($"{length} characters", Assert.Single(diagnostic.RejectedEntries).Error.Message);
+ }
+ else
+ {
+ Assert.Empty(diagnostic.RejectedEntries);
+ Assert.Equal(text, Assert.Single(diagnostic.Page.Skills).Frontmatter[field]!.GetValue());
+ }
+ }
+
+ [Fact]
+ public async Task ClientMethodsPreserveEntryMetaSeparatelyFromFrontmatterAndPageMeta()
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" });
+ var entry = _listResponse["skills"]![0]!.AsObject();
+ var meta = JsonNode.Parse("""{"example.com/extension":{"items":[{"value":"original"}]}}""")!.AsObject();
+ entry["_meta"] = meta.DeepClone();
+ _listResponse["_meta"] = new JsonObject { ["scope"] = "page" };
+ _getResponse = new JsonObject { ["skill"] = entry.DeepClone() };
+ var uri = entry["uri"]!.GetValue();
+ await using var client = await CreateMcpClientForServer();
+
+ var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+ var listed = Assert.Single(page.Skills);
+ Assert.True(JsonNode.DeepEquals(meta, listed.Meta));
+ Assert.Equal("page", page.Meta!["scope"]!.GetValue());
+ Assert.Equal("2.1", listed.Frontmatter["metadata"]!["version"]!.GetValue());
+ listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "changed";
+
+ var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken);
+ Assert.True(JsonNode.DeepEquals(meta, fetched.Meta));
+ var diagnostic = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+ Assert.Empty(diagnostic.RejectedEntries);
+ Assert.True(JsonNode.DeepEquals(meta, Assert.Single(diagnostic.Page.Skills).Meta));
+ Assert.Equal("page", diagnostic.Page.Meta!["scope"]!.GetValue());
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_RejectsTitlecaseNames()
+ {
+ _listResponse = CreateListResponse("\u01c5");
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData("1")]
+ [InlineData("true")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("null")]
+ public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value)
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) });
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("metadata", exception.Message);
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionFields()
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" });
+ var frontmatter = _listResponse["skills"]![0]!["frontmatter"]!.AsObject();
+ frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) };
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter));
+ }
+
+ [Fact]
+ public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne()
+ {
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Equal("good", Assert.Single(result.Page.Skills).Name);
+ var rejected = Assert.Single(result.RejectedEntries);
+ Assert.Equal(1, rejected.Index);
+ Assert.IsType(rejected.Error);
+ }
+
+ [Fact]
+ public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints()
+ {
+ _listResponse = CreateListResponse("alpha");
+ var entries = _listResponse["skills"]!.AsArray();
+ entries.Add(null);
+ entries.Add(JsonNode.Parse("42"));
+ entries.Add(new JsonObject());
+ _listResponse["nextCursor"] = "next-page";
+ _listResponse["ttlMs"] = 3000;
+ _listResponse["cacheScope"] = "public";
+ _listResponse["resultType"] = "complete";
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Single(result.Page.Skills);
+ Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index));
+ Assert.Equal("next-page", result.Page.NextCursor);
+ Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive);
+ Assert.Equal(CacheScope.Public, result.Page.CacheScope);
+ Assert.Equal("complete", result.Page.ResultType);
+ Assert.Equal(4, entries.Count);
+ }
+
+ [Theory]
+ [InlineData("no-skills-array")]
+ [InlineData("missing-skills-array")]
+ public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor)
+ {
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task DiagnosticListingReportsAnAllInvalidPageRatherThanHidingItsFailure()
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = 1 });
+ _listResponse["nextCursor"] = "next-page";
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Page.Skills);
+ Assert.Contains("metadata", Assert.Single(result.RejectedEntries).Error.Message);
+ Assert.Equal("next-page", result.Page.NextCursor);
+ }
+
+ [Theory]
+ [InlineData("allowed-tools", "[\"Read\",\"Write\"]")]
+ [InlineData("allowed-tools", "1")]
+ [InlineData("allowed-tools", "{\"Read\":true}")]
+ [InlineData("allowed-tools", "true")]
+ [InlineData("allowed-tools", "null")]
+ [InlineData("license", "[\"MIT\"]")]
+ [InlineData("license", "2")]
+ [InlineData("license", "{\"name\":\"MIT\"}")]
+ [InlineData("license", "false")]
+ [InlineData("license", "null")]
+ public async Task ClientMethodsRejectSpecFieldsThatAreNotStrings(string field, string valueJson)
+ {
+ _listResponse = CreateListResponse("alpha");
+ var entry = _listResponse["skills"]![0]!.AsObject();
+ entry["frontmatter"]![field] = JsonNode.Parse(valueJson);
+ _getResponse = new JsonObject { ["skill"] = entry.DeepClone() };
+ var uri = entry["uri"]!.GetValue();
+ await using var client = await CreateMcpClientForServer();
+
+ var listError = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ var getError = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken));
+ var diagnostic = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ var expected = $"'{field}' frontmatter field that is not a string";
+ Assert.Contains(expected, listError.Message);
+ Assert.Contains(expected, getError.Message);
+ Assert.Empty(diagnostic.Page.Skills);
+ Assert.Contains(expected, Assert.Single(diagnostic.RejectedEntries).Error.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);
+ }
+
+ private JsonObject CreateListResponse(string name, JsonObject? metadata = null, string? uri = null)
+ {
+ uri ??= $"skill://catalog/{name}/SKILL.md";
+ var frontmatter = new JsonObject { ["name"] = name, ["description"] = "d" };
+ if (metadata is not null)
+ {
+ frontmatter["metadata"] = metadata;
+ }
+
+ return new JsonObject
+ {
+ ["skills"] = new JsonArray(new JsonObject
+ {
+ ["uri"] = uri,
+ ["frontmatter"] = frontmatter,
+ ["resources"] = new JsonArray(new JsonObject
+ {
+ ["uri"] = uri,
+ ["digest"] = "sha256:" + new string('a', 64),
+ ["size"] = 1,
+ }),
+ }),
+ };
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs
new file mode 100644
index 000000000..3bdfa0c33
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs
@@ -0,0 +1,190 @@
+#pragma warning disable MCPEXP002
+
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Client;
+
+public class McpSkillsEnumerationTests : ClientServerTestBase
+{
+ private Func? _page;
+ private readonly List _cursors = [];
+
+ public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder)
+ {
+ services.Configure(options =>
+ {
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= [];
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Handler = (request, _) =>
+ {
+ _cursors.Add(request.Params?["cursor"]?.GetValue());
+ return new ValueTask(_page!(_cursors.Count));
+ },
+ });
+ });
+ }
+
+ [Theory]
+ [InlineData("repeat")]
+ [InlineData("")]
+ public async Task RepeatedCursorFailsInsteadOfLooping(string cursor)
+ {
+ _page = number => Page(cursor, $"skill-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("repeated", error.Message);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent()
+ {
+ _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Equal(3, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing()
+ {
+ _page = number => number == 1 ? Page("", "first") : Page(null, "second");
+ await using var client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name));
+ Assert.Equal(2, _cursors.Count);
+ Assert.Null(_cursors[0]);
+ Assert.Equal("", _cursors[1]);
+ }
+
+ [Fact]
+ public async Task EverChangingCursorsStopAtTheRequestedPageLimit()
+ {
+ _page = number => Page($"cursor-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken));
+
+ Assert.Contains("3 pages", error.Message);
+ Assert.Equal(3, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task DefaultPageLimitIsOneHundred()
+ {
+ _page = number => Page($"cursor-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("100 pages", error.Message);
+ Assert.Equal(100, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task EntryLimitFailsWithoutReturningATruncatedList()
+ {
+ _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken));
+
+ Assert.Contains("3 entries", error.Message);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task LastPageMayExactlyReachBothLimits()
+ {
+ _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c");
+ await using var client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, skills.Count);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly()
+ {
+ _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray());
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ Assert.Contains("1000 entries", error.Message);
+
+ var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken);
+ Assert.Equal(1001, skills.Count);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(-1, 1)]
+ [InlineData(1, 0)]
+ [InlineData(1, -1)]
+ public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills)
+ {
+ _page = _ => Page(null);
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken));
+ Assert.Empty(_cursors);
+ }
+
+ [Fact]
+ public async Task CancellationStopsBeforeRequestingAPage()
+ {
+ _page = _ => Page(null);
+ await using var client = await CreateMcpClientForServer();
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ async () => await client.ListSkillsAsync(cancellation.Token));
+ Assert.Empty(_cursors);
+ }
+
+ private JsonObject Page(string? nextCursor, params string[] names)
+ {
+ var entries = new JsonArray();
+ foreach (var name in names)
+ {
+ entries.Add(new JsonObject
+ {
+ ["uri"] = $"skill://{name}/SKILL.md",
+ ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" },
+ ["resources"] = "dynamic",
+ });
+ }
+ return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor };
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs
new file mode 100644
index 000000000..8a176c572
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs
@@ -0,0 +1,202 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text;
+using System.Text.Json;
+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 SkillResource Entry(byte[] content) => new()
+ {
+ Uri = Uri,
+ Digest = SkillVerifier.ComputeDigest(content),
+ Size = content.Length,
+ };
+
+ [Theory]
+ [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")]
+ [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")]
+ public void ComputeDigest_MatchesKnownVectors(string input, string expected)
+ {
+ Assert.Equal(expected, SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(input)));
+ }
+
+ [Fact]
+ public void Verify_AcceptsMatchingBytes()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("# Hello\n");
+
+ SkillVerifier.Verify(Entry(content), content);
+ }
+
+ [Fact]
+ public void Verify_RejectsSizeMismatch()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("# Hello\n");
+ var entry = Entry(content);
+ entry.Size += 1;
+
+ var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content));
+ Assert.Contains("bytes", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_RejectsDigestMismatch()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n"));
+
+ var exception = Assert.Throws(() =>
+ SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n")));
+ Assert.Contains("digest", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_AcceptsUppercaseDigestFromServer()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("x");
+ var entry = Entry(content);
+ entry.Digest = entry.Digest.ToUpperInvariant().Replace("SHA256", "sha256");
+
+ SkillVerifier.Verify(entry, content);
+ }
+
+ [Fact]
+ public void Verify_TextContents_HashesUtf8Encoding()
+ {
+ const string Text = "# Héllo\n";
+ var entry = Entry(Encoding.UTF8.GetBytes(Text));
+
+ SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = Text });
+ }
+
+ [Fact]
+ public void Verify_Contents_RejectsMissingTextAsVerificationFailure()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! }));
+ }
+
+ [Fact]
+ public void Verify_BlobContents_HashesDecodedBytes()
+ {
+ byte[] content = [0x00, 0xFF, 0x10, 0x80];
+ var entry = Entry(content);
+
+ SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri));
+ }
+
+ [Fact]
+ public void Verify_Contents_RejectsUriMismatch()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() =>
+ SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" }));
+ }
+
+ [Fact]
+ public void Verify_Skill_ChecksEveryContentAgainstManifest()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("x");
+ var skill = CreateSkill(content);
+
+ SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] });
+
+ Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] }));
+ }
+
+ [Fact]
+ public void Verify_Skill_RejectsUnlistedFile()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+
+ var exception = Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] }));
+ Assert.Contains("not listed", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_Skill_RejectsEmptyResult()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult()));
+ }
+
+ [Theory]
+ [InlineData("""{"contents":[null]}""")]
+ [InlineData("""{"contents":[null,{"uri":"skill://alpha/SKILL.md","text":"x"}]}""")]
+ [InlineData("""{"contents":[{"uri":"skill://alpha/SKILL.md","text":"x"},null]}""")]
+ public void Verify_Skill_RejectsNullWireContentsAsVerificationFailure(string json)
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+ var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions);
+ Assert.NotNull(result);
+
+ var error = Assert.Throws(() => SkillVerifier.Verify(skill, result));
+ Assert.Contains("null", error.Message);
+ Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result));
+ }
+
+ [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 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..1ac0cbaa7 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -41,6 +41,7 @@
+
@@ -52,6 +53,12 @@
+
+
+
+
+
+
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -84,6 +91,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs
new file mode 100644
index 000000000..aba2221e9
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs
@@ -0,0 +1,436 @@
+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 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 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)));
+ }
+
+ [Theory]
+ [InlineData("{}")]
+ [InlineData("""{"io.modelcontextprotocol.skills/x":{"values":[true,42,null,{"text":"value"}]}}""")]
+ public void Skill_WithMeta_RoundTrips(string meta)
+ {
+ var json = $$"""
+ {
+ "uri": "skill://refunds/SKILL.md",
+ "frontmatter": { "name": "refunds", "description": "d", "metadata": { "version": "2.1" } },
+ "resources": "dynamic",
+ "_meta": {{meta}}
+ }
+ """;
+
+ var skill = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(skill);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(meta), skill.Meta));
+ Assert.Equal("2.1", skill.Frontmatter["metadata"]!["version"]!.GetValue());
+ var reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(reserialized)));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(""", "_meta": null""")]
+ public void Skill_OmitsUnsetOrNullMeta(string metaProperty)
+ {
+ var json = $$"""
+ {
+ "uri": "skill://refunds/SKILL.md",
+ "frontmatter": { "name": "refunds", "description": "d" },
+ "resources": "dynamic"{{metaProperty}}
+ }
+ """;
+
+ var skill = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(skill);
+ Assert.Null(skill.Meta);
+ var reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill);
+ Assert.False(JsonNode.Parse(reserialized)!.AsObject().ContainsKey("_meta"));
+ }
+
+ [Theory]
+ [InlineData("[]")]
+ [InlineData("42")]
+ [InlineData("true")]
+ [InlineData("\"metadata\"")]
+ public void Skill_RejectsNonObjectMeta(string meta)
+ {
+ var json = $$"""
+ {
+ "uri": "skill://refunds/SKILL.md",
+ "frontmatter": { "name": "refunds", "description": "d" },
+ "resources": "dynamic",
+ "_meta": {{meta}}
+ }
+ """;
+
+ Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill));
+ }
+
+ [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);
+ }
+
+ [Fact]
+ public void Skill_TypedAccessors_ReadTheStringFieldsTheSpecificationDefines()
+ {
+ const string Json = """
+ {
+ "uri": "skill://refunds/SKILL.md",
+ "frontmatter": {
+ "name": "refunds",
+ "description": "Process refunds",
+ "license": "Apache-2.0",
+ "compatibility": "Requires git",
+ "allowed-tools": "Bash(git:*) Read"
+ },
+ "resources": "dynamic"
+ }
+ """;
+
+ var skill = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(skill);
+ Assert.Equal("refunds", skill.Name);
+ Assert.Equal("Process refunds", skill.Description);
+ Assert.Equal("Apache-2.0", skill.License);
+ Assert.Equal("Requires git", skill.Compatibility);
+ Assert.Equal("Bash(git:*) Read", skill.AllowedTools);
+
+ // The accessors read the frontmatter; they are not part of the wire shape.
+ string reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized)));
+ }
+
+ [Fact]
+ public void Skill_TypedAccessors_AreNullWhenAbsentOrNotStrings()
+ {
+ var absent = CreateEntry(SkillResources.Dynamic);
+ Assert.Null(absent.License);
+ Assert.Null(absent.Compatibility);
+ Assert.Null(absent.AllowedTools);
+
+ var notStrings = CreateEntry(SkillResources.Dynamic);
+ notStrings.Frontmatter["license"] = 1;
+ notStrings.Frontmatter["compatibility"] = true;
+ notStrings.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write");
+ Assert.Null(notStrings.License);
+ Assert.Null(notStrings.Compatibility);
+ Assert.Null(notStrings.AllowedTools);
+ }
+
+ [Theory]
+ [InlineData("\"static\"")]
+ [InlineData("\"Dynamic\"")]
+ [InlineData("\"\"")]
+ [InlineData("null")]
+ [InlineData("123")]
+ [InlineData("true")]
+ [InlineData("{}")]
+ [InlineData("[null]")]
+ public void Skill_WithInvalidResourcesValue_Throws(string resourcesJson)
+ {
+ string json = $$"""
+ {
+ "uri": "skill://git-workflow/SKILL.md",
+ "frontmatter": { "name": "git-workflow", "description": "d" },
+ "resources": {{resourcesJson}}
+ }
+ """;
+
+ Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill));
+ }
+
+ [Fact]
+ public void Skill_WithoutResources_Throws()
+ {
+ const string Json = """
+ {
+ "uri": "skill://git-workflow/SKILL.md",
+ "frontmatter": { "name": "git-workflow", "description": "d" }
+ }
+ """;
+
+ Assert.ThrowsAny(() => JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill));
+ }
+
+ [Fact]
+ public void SkillResources_FromResources_CopiesTheSource()
+ {
+ var mutable = new List
+ {
+ CreateResource("skill://a/SKILL.md", "sha256:" + new string('c', 64), 1),
+ };
+
+ var resources = SkillResources.FromResources(mutable);
+ mutable.Add(CreateResource("skill://a/extra.md", "sha256:" + new string('d', 64), 2));
+
+ Assert.Single(resources.Resources!);
+ }
+
+ [Fact]
+ public void SkillResources_FromResources_WithNull_Throws() =>
+ Assert.Throws(() => SkillResources.FromResources(null!));
+
+ [Fact]
+ public void ListSkillsResult_RoundTripsCursorAndCacheAttributes()
+ {
+ var original = new ListSkillsResult
+ {
+ Skills = [CreateEntry(SkillResources.Dynamic)],
+ NextCursor = "cursor-1",
+ ResultType = "complete",
+ TimeToLive = TimeSpan.FromMinutes(5),
+ CacheScope = CacheScope.Public,
+ };
+
+ string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsResult);
+ var node = JsonNode.Parse(json)!.AsObject();
+ Assert.Equal(300_000, node["ttlMs"]?.GetValue());
+ Assert.Equal("public", node["cacheScope"]?.GetValue());
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsResult);
+ Assert.NotNull(deserialized);
+ Assert.Equal("cursor-1", deserialized.NextCursor);
+ Assert.Equal("complete", deserialized.ResultType);
+ Assert.Equal(TimeSpan.FromMinutes(5), deserialized.TimeToLive);
+ Assert.Equal(CacheScope.Public, deserialized.CacheScope);
+ Assert.Single(deserialized.Skills);
+ }
+
+ [Fact]
+ public void ListSkillsResult_ToleratesUnknownCacheScopeOnRead()
+ {
+ const string Json = """{ "skills": [], "ttlMs": 0, "cacheScope": "regional" }""";
+
+ var deserialized = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.ListSkillsResult);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.CacheScope);
+ Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
+ }
+
+ [Fact]
+ public void ListSkillsResult_OmitsOptionalFieldsWhenUnset()
+ {
+ string json = JsonSerializer.Serialize(new ListSkillsResult(), McpSkillsJsonContext.Default.ListSkillsResult);
+
+ var node = JsonNode.Parse(json)!.AsObject();
+ Assert.False(node.ContainsKey("ttlMs"));
+ Assert.False(node.ContainsKey("cacheScope"));
+ Assert.False(node.ContainsKey("nextCursor"));
+ Assert.False(node.ContainsKey("resultType"));
+ Assert.Empty(node["skills"]!.AsArray());
+ }
+
+ [Fact]
+ public void GetSkillResult_RoundTrips()
+ {
+ var original = new GetSkillResult
+ {
+ Skill = CreateEntry(SkillResources.FromResources(
+ [
+ CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('e', 64), 10),
+ ])),
+ ResultType = "complete",
+ };
+
+ string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.GetSkillResult);
+ Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor"));
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ Assert.NotNull(deserialized);
+ Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Skill.Uri);
+ Assert.Equal("complete", deserialized.ResultType);
+ }
+
+ [Theory]
+ [InlineData(0, CacheScope.Private)]
+ [InlineData(300_000, CacheScope.Public)]
+ [InlineData(-1, CacheScope.Private)]
+ public void GetSkillResult_RoundTripsCacheAttributes(long milliseconds, CacheScope scope)
+ {
+ var original = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic), ResultType = "complete" };
+ var cacheable = Assert.IsAssignableFrom(original);
+ cacheable.TimeToLive = TimeSpan.FromMilliseconds(milliseconds);
+ cacheable.CacheScope = scope;
+
+ var json = JsonSerializer.SerializeToNode(original, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject();
+ Assert.Equal(milliseconds, json["ttlMs"]?.GetValue());
+ Assert.Equal(scope == CacheScope.Public ? "public" : "private", json["cacheScope"]?.GetValue());
+ Assert.Equal("complete", json["resultType"]?.GetValue());
+ Assert.False(json.ContainsKey("nextCursor"));
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ var deserializedCacheable = Assert.IsAssignableFrom(deserialized);
+ Assert.Equal(cacheable.TimeToLive, deserializedCacheable.TimeToLive);
+ Assert.Equal(scope, deserializedCacheable.CacheScope);
+ }
+
+ [Theory]
+ [InlineData("\"regional\"")]
+ [InlineData("42")]
+ [InlineData("null")]
+ public void GetSkillResult_ToleratesUnknownCacheScopeOnRead(string cacheScope)
+ {
+ var json = $$"""
+ {
+ "skill": {
+ "uri": "skill://alpha/SKILL.md",
+ "frontmatter": { "name": "alpha", "description": "d" },
+ "resources": "dynamic"
+ },
+ "ttlMs": 0,
+ "cacheScope": {{cacheScope}}
+ }
+ """;
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ var cacheable = Assert.IsAssignableFrom(deserialized);
+
+ Assert.Null(cacheable.CacheScope);
+ Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive);
+ }
+
+ [Fact]
+ public void GetSkillResult_OmitsVersionedFieldsWhenUnset()
+ {
+ var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) };
+ var json = JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject();
+
+ Assert.False(json.ContainsKey("ttlMs"));
+ Assert.False(json.ContainsKey("cacheScope"));
+ Assert.False(json.ContainsKey("resultType"));
+ Assert.False(json.ContainsKey("nextCursor"));
+ }
+
+ [Fact]
+ public void RequestParams_RoundTrip()
+ {
+ string listJson = JsonSerializer.Serialize(new ListSkillsRequestParams { Cursor = "abc" }, McpSkillsJsonContext.Default.ListSkillsRequestParams);
+ Assert.Equal("abc", JsonSerializer.Deserialize(listJson, McpSkillsJsonContext.Default.ListSkillsRequestParams)?.Cursor);
+
+ string getJson = JsonSerializer.Serialize(new GetSkillRequestParams { Uri = "skill://a/SKILL.md" }, McpSkillsJsonContext.Default.GetSkillRequestParams);
+ Assert.Equal("skill://a/SKILL.md", JsonSerializer.Deserialize(getJson, McpSkillsJsonContext.Default.GetSkillRequestParams)?.Uri);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs
new file mode 100644
index 000000000..265d0d6be
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs
@@ -0,0 +1,190 @@
+#if NET
+using ModelContextProtocol.Extensions.Skills;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Samples;
+
+///
+/// The SkillsClient sample loads a skill only if the frontmatter it parses from the fetched SKILL.md equals the
+/// entry the server published. These tests run the sample's YamlFrontmatterReader on files the server authored.
+///
+public class SkillsClientFrontmatterReaderTests
+{
+ [Theory]
+ [InlineData("|+", " Kept\n\n", "Kept\n\n", false)]
+ [InlineData("|+", " Kept\n\n", "Kept\n\n", true)]
+ [InlineData("|", " Kept\n\n", "Kept\n", false)]
+ [InlineData("|", " Kept\n", "Kept\n", false)]
+ [InlineData("|-", " Kept\n\n", "Kept", false)]
+ public void BlockScalarAtClosingFence_MatchesTheServer(string header, string content, string expectedDescription, bool crlf)
+ {
+ var markdown = "---\nname: alpha\ndescription: " + header + "\n" + content + "---\n# Alpha\n";
+ if (crlf)
+ {
+ markdown = markdown.Replace("\n", "\r\n");
+ }
+
+ var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter;
+
+ var fetched = new YamlFrontmatterReader().Read(markdown);
+
+ Assert.Equal(expectedDescription, fetched["description"]!.GetValue());
+ Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}.");
+ }
+
+ [Theory]
+ [InlineData("extra: !!str [one]")]
+ [InlineData("extra: !!str {key: value}")]
+ [InlineData("extra: !!str\n - one")]
+ [InlineData("extra: !!str\n key: value")]
+ [InlineData("extra: !!map [one]")]
+ [InlineData("extra: !!seq {key: value}")]
+ [InlineData("extra: !!set {one}")]
+ [InlineData("extra: !custom [one]")]
+ [InlineData("extra: ! [one]")]
+ public void CollectionTagThatChangesTheValue_IsRejected(string entry)
+ {
+ var exception = Assert.Throws(() => new YamlFrontmatterReader().Read("---\nname: alpha\ndescription: d\n" + entry + "\n---\n"));
+
+ Assert.Contains("does not convert", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("!!str")]
+ [InlineData("!!seq")]
+ [InlineData("!custom")]
+ public void RootTagThatChangesTheValue_IsRejected(string tag)
+ {
+ var exception = Assert.Throws(() => new YamlFrontmatterReader().Read("---\n" + tag + "\nname: alpha\ndescription: d\n---\n"));
+
+ Assert.Contains("does not convert", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("extra: !!seq [one]", """["one"]""")]
+ [InlineData("extra: ! [one]", """["one"]""")]
+ [InlineData("extra: !!map {key: value}", """{"key":"value"}""")]
+ [InlineData("extra: ! {key: value}", """{"key":"value"}""")]
+ [InlineData("extra: !!map\n key: value", """{"key":"value"}""")]
+ [InlineData("extra: ! [one]", """["one"]""")]
+ [InlineData("extra: ! {key: value}", """{"key":"value"}""")]
+ public void CollectionTagThatKeepsTheValue_IsAccepted(string entry, string expectedExtra)
+ {
+ var fetched = new YamlFrontmatterReader().Read("---\nname: alpha\ndescription: d\n" + entry + "\n---\n");
+
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedExtra), fetched["extra"]), fetched.ToJsonString());
+ }
+
+ [Theory]
+ [InlineData("!!map")]
+ [InlineData("!")]
+ [InlineData("!")]
+ public void RootMappingTag_IsAccepted(string tag)
+ {
+ var fetched = new YamlFrontmatterReader().Read("---\n" + tag + "\nname: alpha\ndescription: d\n---\n");
+
+ Assert.True(JsonNode.DeepEquals(new JsonObject { ["name"] = "alpha", ["description"] = "d" }, fetched), fetched.ToJsonString());
+ }
+
+ [Theory]
+ [InlineData("description: x{0}y")]
+ [InlineData("description: \"x{0}y\"")]
+ [InlineData("description: 'x{0}y'")]
+ [InlineData("description: |\n x{0}y")]
+ [InlineData("description: d\n# note{0}")]
+ [InlineData("description: d # note{0}")]
+ [InlineData("description: xy{0}\nlicense: MIT")]
+ public void LiteralNul_IsRejectedLikeTheServer(string entry)
+ {
+ AssertRejectedLikeTheServer(string.Format(entry, '\0'), 0x0000);
+ }
+
+ [Theory]
+ [InlineData(0x000B)]
+ [InlineData(0x000C)]
+ [InlineData(0x001B)]
+ [InlineData(0x007F)]
+ [InlineData(0x0080)]
+ [InlineData(0xFFFE)]
+ [InlineData(0xFFFF)]
+ public void RawCharacterOutsideYamlsPrintableSet_IsRejectedLikeTheServer(int codePoint)
+ {
+ AssertRejectedLikeTheServer("description: x" + char.ConvertFromUtf32(codePoint) + "y", codePoint);
+ }
+
+ [Theory]
+ [InlineData("description: d # note{0}allowed-tools: Bash", 0x000D)]
+ [InlineData("description: d\n# note{0}allowed-tools: Bash", 0x2028)]
+ [InlineData("description: d # note{0}allowed-tools: Bash", 0x0085)]
+ [InlineData("description: d # note{0}allowed-tools: Bash", 0x2029)]
+ [InlineData("description: x{0}y", 0x0085)]
+ [InlineData("description: d{0}{0}", 0x000D)]
+ public void RawCharacterSomeParsersReadAsALineBreak_IsRejectedLikeTheServer(string entry, int codePoint)
+ {
+ AssertRejectedLikeTheServer(string.Format(entry, (char)codePoint), codePoint);
+ }
+
+ [Theory]
+ [InlineData(new[] { 0xD800 }, false, 0xD800)]
+ [InlineData(new[] { 0xD800 }, true, 0xD800)]
+ [InlineData(new[] { 0xDC00 }, false, 0xDC00)]
+ [InlineData(new[] { 0xDC00, 0xD800 }, false, 0xDC00)]
+ public void UnpairedSurrogate_IsRejected(int[] codeUnits, bool atLineEnd, int expectedCodeUnit)
+ {
+ var markdown = "---\nname: alpha\ndescription: x" + new string(codeUnits.Select(codeUnit => (char)codeUnit).ToArray()) + (atLineEnd ? "" : "y") + "\n---\n";
+
+ var exception = Assert.Throws(() => new YamlFrontmatterReader().Read(markdown));
+
+ Assert.Contains($"U+{expectedCodeUnit:X4}", exception.Message);
+ }
+
+ [Fact]
+ public void SecondByteOrderMark_IsRejectedLikeTheServer()
+ {
+ var markdown = "\uFEFF\uFEFF---\nname: alpha\ndescription: d\n---\n";
+
+ Assert.Throws(() => new YamlFrontmatterReader().Read(markdown));
+ Assert.Throws(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]));
+ }
+
+ [Fact]
+ public void EscapedNul_MatchesTheServer()
+ {
+ var markdown = "---\nname: alpha\ndescription: \"x\\0y\"\n---\n";
+
+ var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter;
+ var fetched = new YamlFrontmatterReader().Read(markdown);
+
+ Assert.Equal("x\0y", fetched["description"]!.GetValue());
+ Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}.");
+ }
+
+ [Theory]
+ [InlineData("\\r", 0x000D)]
+ [InlineData("\\N", 0x0085)]
+ [InlineData("\\L", 0x2028)]
+ [InlineData("\\P", 0x2029)]
+ public void EscapedCharacterSomeParsersReadAsALineBreak_MatchesTheServer(string escape, int codePoint)
+ {
+ var markdown = "---\nname: alpha\ndescription: \"x" + escape + "y\"\n---\n";
+
+ var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter;
+ var fetched = new YamlFrontmatterReader().Read(markdown);
+
+ Assert.Equal("x" + char.ConvertFromUtf32(codePoint) + "y", fetched["description"]!.GetValue());
+ Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}.");
+ }
+
+ private void AssertRejectedLikeTheServer(string entry, int codePoint)
+ {
+ var markdown = $"---\nname: alpha\n{entry}\n---\n";
+
+ var sampleException = Record.Exception(() => new YamlFrontmatterReader().Read(markdown));
+ var serverException = Record.Exception(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]));
+
+ string character = $"U+{codePoint:X4}";
+ Assert.Contains(character, Assert.IsType(sampleException).Message);
+ Assert.Contains(character, Assert.IsType(serverException).Message);
+ }
+}
+#endif
diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs
new file mode 100644
index 000000000..d5b6f9fa3
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs
@@ -0,0 +1,641 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Tests for : ordering, keyset pagination, lookup, cursor validation, and
+/// the structural validation of entries against the specification.
+///
+public class InMemoryMcpSkillCatalogTests
+{
+ private static readonly string s_validDigest = "sha256:" + new string('a', 64);
+
+ private McpSkillRequestContext Context => new(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList });
+
+ // Static because the InvalidSkills member data, which xUnit requires to be static, builds its cases with it.
+ 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 InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) =>
+ new([.. names.Select(name => CreateSkill(name))], pageSize);
+
+ [Fact]
+ public async Task ListAsync_ReturnsEntriesOrderedByUri()
+ {
+ var catalog = CreateCatalog(10, "zebra", "alpha", "middle");
+
+ var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+
+ Assert.Collection(
+ page.Skills,
+ skill => Assert.Equal("skill://alpha/SKILL.md", skill.Uri),
+ skill => Assert.Equal("skill://middle/SKILL.md", skill.Uri),
+ skill => Assert.Equal("skill://zebra/SKILL.md", skill.Uri));
+ Assert.Null(page.NextCursor);
+ Assert.Equal(3, catalog.Count);
+ }
+
+ [Fact]
+ public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries()
+ {
+ var catalog = CreateCatalog(2, "a", "b", "c", "d", "e");
+
+ var seen = new List();
+ string? cursor = null;
+ int pages = 0;
+ do
+ {
+ var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken);
+ seen.AddRange(page.Skills.Select(skill => skill.Uri));
+ cursor = page.NextCursor;
+ Assert.True(++pages < 20, "Pagination did not terminate.");
+ }
+ while (cursor is not null);
+
+ Assert.Equal(3, pages);
+ Assert.Equal(5, seen.Count);
+ Assert.Equal(seen.Count, seen.Distinct().Count());
+ Assert.Equal(seen.OrderBy(uri => uri, StringComparer.Ordinal), seen);
+ }
+
+ [Fact]
+ public async Task ListAsync_LastPageHasNoNextCursor()
+ {
+ var catalog = CreateCatalog(2, "a", "b", "c", "d");
+
+ var first = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ var second = await catalog.ListAsync(first.NextCursor, Context, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(first.NextCursor);
+ Assert.Equal(2, second.Skills.Count);
+ Assert.Null(second.NextCursor);
+ }
+
+ [Fact]
+ public async Task ListAsync_WithCursorForUnknownUri_ResumesAfterItsPosition()
+ {
+ var catalog = CreateCatalog(10, "a", "c");
+ string cursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("skill://b/SKILL.md"));
+
+ var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken);
+
+ Assert.Single(page.Skills);
+ Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri);
+ }
+
+ [Fact]
+ public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage()
+ {
+ var catalog = CreateCatalog(10);
+
+ var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+
+ Assert.Empty(page.Skills);
+ Assert.Null(page.NextCursor);
+ }
+
+ [Theory]
+ [InlineData("not-base64!!")]
+ [InlineData("/w==")]
+ [InlineData("wK8=")]
+ [InlineData("7aCA")]
+ public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams(string cursor)
+ {
+ var catalog = CreateCatalog(10, "a");
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
+ }
+
+ [Fact]
+ public async Task GetAsync_ReturnsSkillByUri()
+ {
+ var catalog = CreateCatalog(10, "alpha");
+
+ var skill = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(skill);
+ Assert.Equal("alpha", skill.Name);
+ }
+
+ [Theory]
+ [InlineData("skill://missing/SKILL.md")]
+ [InlineData("SKILL://ALPHA/SKILL.md")]
+ [InlineData("skill://alpha")]
+ public async Task GetAsync_WithUnknownUri_ReturnsNull(string uri)
+ {
+ var catalog = CreateCatalog(10, "alpha");
+
+ Assert.Null(await catalog.GetAsync(uri, Context, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Constructor_SnapshotsEntries_SoLaterMutationsAreNotServed()
+ {
+ var source = CreateSkill("alpha");
+ var catalog = new InMemoryMcpSkillCatalog([source]);
+
+ source.Frontmatter["description"] = "changed";
+ source.Resources.Resources![0].Digest = "sha256:" + new string('f', 64);
+ source.Uri = "skill://renamed/SKILL.md";
+
+ var served = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ Assert.NotNull(served);
+ Assert.Equal("A skill", served.Description);
+ Assert.Equal(s_validDigest, served.Resources.Resources![0].Digest);
+ }
+
+ [Fact]
+ public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResponses()
+ {
+ var catalog = new InMemoryMcpSkillCatalog([CreateSkill("alpha")]);
+
+ var first = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ first!.Frontmatter["description"] = "tampered";
+ first.Resources.Resources![0].Digest = "sha256:" + new string('f', 64);
+
+ var listed = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ listed.Skills[0].Frontmatter["description"] = "tampered again";
+
+ var again = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ Assert.Equal("A skill", again!.Description);
+ Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task EntryMetaIsOwnedAcrossConstructionListingAndLookup(bool isDynamic)
+ {
+ var source = CreateSkill("alpha", resources: isDynamic ? SkillResources.Dynamic : null);
+ source.Meta = JsonNode.Parse("""{"example.com/extension":{"items":[{"value":"original"}]},"empty":null}""")!.AsObject();
+ var expected = source.Meta.DeepClone();
+ var catalog = new InMemoryMcpSkillCatalog([source]);
+ source.Meta["example.com/extension"]!["items"]![0]!["value"] = "source changed";
+
+ var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ var listed = Assert.Single(page.Skills);
+ Assert.True(JsonNode.DeepEquals(expected, listed.Meta));
+ listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "listed entry changed";
+
+ var fetched = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken);
+ Assert.NotNull(fetched);
+ Assert.True(JsonNode.DeepEquals(expected, fetched.Meta));
+ fetched.Meta!["example.com/extension"]!["items"]!.AsArray().Clear();
+
+ var listedAgain = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ var fetchedAgain = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken);
+ Assert.True(JsonNode.DeepEquals(expected, Assert.Single(listedAgain.Skills).Meta));
+ Assert.True(JsonNode.DeepEquals(expected, fetchedAgain!.Meta));
+ }
+
+ [Theory]
+ [InlineData("café")]
+ [InlineData("数据分析")]
+ [InlineData("données-2024")]
+ [InlineData("\U00010428")]
+ [InlineData("\U00020000")]
+ public void Constructor_AcceptsUnicodeLowercaseNames(string name)
+ {
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count);
+ }
+
+ [Theory]
+ [InlineData("café")]
+ [InlineData("数据分析")]
+ [InlineData("\U00010428")]
+ public void Constructor_AcceptsPercentEncodedUnicodeNameSegments(string name)
+ {
+ var skill = CreateSkill(name);
+ skill.Uri = $"skill://catalog/{Uri.EscapeDataString(name)}/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]);
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Theory]
+ [InlineData("caf%25C3%25A9")]
+ [InlineData("%2e%2e")]
+ [InlineData("ca%2ffé")]
+ [InlineData("ca%5cfé")]
+ public void Constructor_DoesNotDecodeNamesRepeatedlyOrAllowEncodedTraversal(string segment)
+ {
+ var skill = CreateSkill("café");
+ skill.Uri = $"skill://catalog/{segment}/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]);
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ }
+
+ [Theory]
+ [InlineData("\u01c5")]
+ [InlineData("\U00010400")]
+ public void Constructor_RejectsUppercaseOrTitlecaseNames(string name)
+ {
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)]));
+ }
+
+ [Theory]
+ [InlineData(0xd801)]
+ [InlineData(0xdc28)]
+ public void Constructor_RejectsUnpairedSurrogates(int codeUnit)
+ {
+ var name = new string((char)codeUnit, 1);
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)]));
+ }
+
+ [Theory]
+ [InlineData(64, true)]
+ [InlineData(65, false)]
+ public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid)
+ {
+ var name = string.Concat(Enumerable.Repeat("\U00010428", length));
+ var skill = CreateSkill(name);
+ skill.Uri = $"skill://catalog/{name}/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 10 }]);
+
+ if (valid)
+ {
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+ else
+ {
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ }
+ }
+
+ [Theory]
+ [InlineData("1")]
+ [InlineData("true")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("null")]
+ public void Constructor_RejectsNonStringMetadataValues(string value)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) };
+
+ var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+
+ Assert.Contains("metadata", exception.Message);
+ }
+
+ [Fact]
+ public void Constructor_WithDuplicateUris_Throws()
+ {
+ var duplicate = new[] { CreateSkill("alpha"), CreateSkill("alpha") };
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog(duplicate));
+ }
+
+ [Fact]
+ public void Constructor_WithNullSkills_Throws() =>
+ Assert.Throws(() => new InMemoryMcpSkillCatalog(null!));
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Constructor_WithNonPositivePageSize_Throws(int pageSize) =>
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([], pageSize));
+
+ [Fact]
+ public void Constructor_AcceptsFullAgentSkillsFrontmatter()
+ {
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["license"] = "MIT";
+ skill.Frontmatter["compatibility"] = "Needs git";
+ skill.Frontmatter["allowed-tools"] = "Bash(git:*) Read";
+ skill.Frontmatter["metadata"] = new JsonObject { ["author"] = "acme", ["version"] = "2.1.0" };
+ skill.Frontmatter["description"] = new string('d', 1024);
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Theory]
+ [InlineData("description", 513)]
+ [InlineData("compatibility", 300)]
+ public void Constructor_AcceptsSupplementaryFrontmatterWithinTheLimit(string field, int scalarCount)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter[field] = string.Concat(Enumerable.Repeat("\U0001f600", scalarCount));
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Theory]
+ [InlineData("description", 1024, false)]
+ [InlineData("description", 1024, true)]
+ [InlineData("compatibility", 500, false)]
+ [InlineData("compatibility", 500, true)]
+ public void Constructor_UsesScalarCountsAtFrontmatterBoundaries(string field, int limit, bool mixed)
+ {
+ var text = (mixed ? "a" : "\U0001f600") +
+ string.Concat(Enumerable.Repeat("\U0001f600", limit - 1));
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter[field] = text;
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+
+ skill.Frontmatter[field] = text + "a";
+ var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ Assert.Contains($"{limit + 1} characters", error.Message);
+ }
+
+ [Theory]
+ [InlineData("description", 1024)]
+ [InlineData("compatibility", 500)]
+ public void Constructor_CountsCombiningMarksSeparately(string field, int limit)
+ {
+ var skill = CreateSkill("alpha");
+ var text = string.Concat(Enumerable.Repeat("e\u0301", limit / 2));
+ skill.Frontmatter[field] = text;
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+
+ skill.Frontmatter[field] = text + "a";
+ var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ Assert.Contains($"{limit + 1} characters", error.Message);
+ }
+
+ [Fact]
+ public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization()
+ {
+ var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
+ var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter());
+ var value = JsonValue.Create(1, typeInfo)!;
+ Assert.Equal(JsonValueKind.String, value.GetValueKind());
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value };
+
+ var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+
+ Assert.Contains("metadata", error.Message);
+ }
+
+ private sealed class ChangingMetadataConverter : JsonConverter
+ {
+ private int _writes;
+
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
+ {
+ if (++_writes == 1)
+ {
+ writer.WriteStringValue("initial-string");
+ }
+ else
+ {
+ writer.WriteNumberValue(value);
+ }
+ }
+ }
+
+ [Fact]
+ public void Constructor_PassesThroughFieldsHostsDoNotActOn()
+ {
+ // Unknown fields keep structured values. Fields the Agent Skills specification defines are type-checked, and the
+ // known metadata map has its own string-value requirement.
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["allowed-tools"] = "Read Write";
+ skill.Frontmatter["license"] = "Apache-2.0";
+ skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") };
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+
+ skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write");
+ var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ Assert.Contains("'allowed-tools' frontmatter field that is not a string", exception.Message);
+ }
+
+ [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);
+ }
+
+ [Theory]
+ [InlineData("repo:/skills/alpha/SKILL.md")]
+ [InlineData("repo:skills/alpha/SKILL.md")]
+ [InlineData("repo:alpha/SKILL.md")]
+ [InlineData("urn:alpha/SKILL.md")]
+ public void Constructor_AcceptsNativeAbsoluteResourceUris(string uri)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Uri = uri;
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]);
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Theory]
+ [InlineData("repo:/skills//alpha/SKILL.md")]
+ [InlineData("repo:skills/../alpha/SKILL.md")]
+ [InlineData("repo:skills/%2e%2e/alpha/SKILL.md")]
+ [InlineData("repo:skills/ca%2ffé/alpha/SKILL.md")]
+ [InlineData("repo:/skills/alpha/SKILL.md?query")]
+ [InlineData("repo:/skills/alpha/SKILL.md#fragment")]
+ public void Constructor_RejectsUnsafeNativeUriPaths(string uri)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Uri = uri;
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]);
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ }
+
+ [Theory]
+ [InlineData("repo:/skills/alpha/SKILL.md")]
+ [InlineData("repo:alpha/SKILL.md")]
+ public void Constructor_RejectsTraversalInNativeManifestUris(string uri)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Uri = uri;
+ var root = uri.Substring(0, uri.Length - "/SKILL.md".Length);
+ skill.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = root + "/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 },
+ ]);
+
+ var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ Assert.Contains("encoded separator", error.Message);
+ }
+
+ public static IEnumerable