diff --git a/.github/scripts/prepare_markdown_links.py b/.github/scripts/prepare_markdown_links.py
new file mode 100644
index 000000000..6bda0c192
--- /dev/null
+++ b/.github/scripts/prepare_markdown_links.py
@@ -0,0 +1,41 @@
+import argparse
+from pathlib import Path
+
+
+class RepositoryLinks:
+ def __init__(self, root):
+ self.root = Path(root).resolve()
+
+ def prepare(self):
+ docs = self.root / "docs"
+ if not docs.is_dir():
+ raise ValueError(f"Missing documentation directory: {docs}")
+ if docs.is_symlink():
+ raise ValueError(f"Refusing to rewrite a documentation symlink: {docs}")
+
+ changed = 0
+ prefixes = (
+ "https://github.com/modelcontextprotocol/csharp-sdk/tree/main/",
+ "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/",
+ )
+ for path in docs.rglob("*.md"):
+ if path.is_symlink():
+ raise ValueError(f"Refusing to rewrite a documentation symlink: {path}")
+ text = path.read_text(encoding="utf-8")
+ updated = text
+ for prefix in prefixes:
+ updated = updated.replace(prefix, "/")
+ if updated != text:
+ path.write_text(updated, encoding="utf-8")
+ changed += 1
+ return changed
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Resolve this repository's main-branch links against the proposed checkout for mlc."
+ )
+ parser.add_argument("root")
+ arguments = parser.parse_args()
+ count = RepositoryLinks(arguments.root).prepare()
+ print(f"Prepared repository-local links in {count} Markdown files; mlc still validates each target.")
diff --git a/.github/scripts/test_prepare_markdown_links.py b/.github/scripts/test_prepare_markdown_links.py
new file mode 100644
index 000000000..edfc1ee33
--- /dev/null
+++ b/.github/scripts/test_prepare_markdown_links.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+import tempfile
+import unittest
+
+from prepare_markdown_links import RepositoryLinks
+
+
+class RepositoryLinksTests(unittest.TestCase):
+ def setUp(self):
+ self.directory = tempfile.TemporaryDirectory()
+ self.addCleanup(self.directory.cleanup)
+ self.root = Path(self.directory.name)
+ (self.root / "docs").mkdir()
+
+ def test_main_links_become_checkout_paths_without_ignoring_missing_targets(self):
+ path = self.root / "docs" / "example.md"
+ path.write_text(
+ "[sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer)\n"
+ "[source](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/missing.cs)\n",
+ encoding="utf-8",
+ )
+
+ self.assertEqual(RepositoryLinks(self.root).prepare(), 1)
+ self.assertEqual(
+ path.read_text(encoding="utf-8"),
+ "[sample](/samples/SkillsServer)\n[source](/src/missing.cs)\n",
+ )
+
+ def test_other_repositories_and_pinned_revisions_remain_web_links(self):
+ path = self.root / "docs" / "example.md"
+ original = (
+ "[other](https://github.com/other/project/tree/main/sample)\n"
+ "[pinned](https://github.com/modelcontextprotocol/csharp-sdk/tree/1234567/sample)\n"
+ "[relative](other.md)\n"
+ )
+ path.write_text(original, encoding="utf-8")
+
+ self.assertEqual(RepositoryLinks(self.root).prepare(), 0)
+ self.assertEqual(path.read_text(encoding="utf-8"), original)
+
+ def test_missing_documentation_is_not_a_success(self):
+ with self.assertRaises(ValueError):
+ RepositoryLinks(self.root / "absent").prepare()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml
index 1dbf83b0e..73e01738d 100644
--- a/.github/workflows/markdown-link-check.yml
+++ b/.github/workflows/markdown-link-check.yml
@@ -3,10 +3,18 @@ name: Check Markdown links
on:
push:
branches: [ "main" ]
- paths: "**.md"
+ paths:
+ - "**.md"
+ - ".github/scripts/prepare_markdown_links.py"
+ - ".github/scripts/test_prepare_markdown_links.py"
+ - ".github/workflows/markdown-link-check.yml"
pull_request:
branches: [ "main" ]
- paths: "**.md"
+ paths:
+ - "**.md"
+ - ".github/scripts/prepare_markdown_links.py"
+ - ".github/scripts/test_prepare_markdown_links.py"
+ - ".github/workflows/markdown-link-check.yml"
permissions:
contents: read
@@ -18,6 +26,11 @@ jobs:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Resolve main-branch links against the proposed checkout
+ run: |
+ python3 -m unittest discover -s .github/scripts -p test_prepare_markdown_links.py
+ python3 .github/scripts/prepare_markdown_links.py .
+
- name: Markup Link Checker (mlc)
uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0
with:
@@ -26,6 +39,7 @@ jobs:
# Do not warn for learn.microsoft.com locale redirects.
# Do not warn for modelcontextprotocol.io homepage/spec redirects.
args: >-
+ --root-dir .
--ignore-links "xref:*"
--ignore-links "*samples/*?name=snippet_*"
--do-not-warn-for-redirect-to "https://learn.microsoft.com/en-us/*"
diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx
index 9020d2fbe..97e90457c 100644
--- a/ModelContextProtocol.slnx
+++ b/ModelContextProtocol.slnx
@@ -49,6 +49,8 @@
+
+
@@ -69,6 +71,7 @@
+
diff --git a/README.md b/README.md
index 71902e4e8..7ef8a27ca 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,8 @@ The SDK packages are:
- **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [](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..be4e09d37
--- /dev/null
+++ b/docs/concepts/skills/skills.md
@@ -0,0 +1,296 @@
+---
+title: Skills
+description: Serve and consume Agent Skills over MCP with the Skills extension.
+uid: skills
+---
+
+## Skills
+
+The Skills extension lets an MCP server publish [Agent Skills](https://agentskills.io/) alongside its tools,
+resources, and prompts. A skill is a directory of files, minimally a `SKILL.md` with YAML frontmatter, that gives
+an agent structured workflow instructions. Over MCP, each file is an ordinary resource, and the extension adds two
+methods for discovering skills and obtaining a verifiable manifest of their files:
+
+- `skills/list` enumerates the skills a server serves, with pagination.
+- `skills/get` returns the entry for a single skill by the URI of its `SKILL.md`.
+
+Skills are provided by the `ModelContextProtocol.Extensions.Skills` package. The implementation follows the
+[Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)
+(SEP-2640, extension id `io.modelcontextprotocol/skills`).
+
+### Overview
+
+A skill entry () is a complete, point-in-time snapshot of a skill:
+
+- `uri`: the resource URI of its `SKILL.md`, conventionally `skill:///SKILL.md`. The final segment
+ of `` is the skill's name.
+- `frontmatter`: the `SKILL.md` YAML frontmatter, rendered verbatim as a JSON object. `name` and `description`
+ are always present; every other authored field passes through.
+- `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size
+ (), or the string `"dynamic"` for generated content
+ that cannot be digested.
+
+A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each
+one against the manifest. Before loading a skill, it also parses the fetched `SKILL.md` and compares its
+frontmatter with the entry's; digest verification does not cover that. That verification, and the rule that an
+unlisted file is a change to the skill, is what lets a user's approval bind to specific content.
+
+Keeping the entry and the file in agreement is the server's job, and checking that they agree is the host's:
+
+| Who | Responsibility |
+|---|---|
+| Server, using `McpServerSkill` | Frontmatter is read from `SKILL.md` and the manifest computed from the served bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. |
+| Server, using a custom catalog | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. |
+| Host | Verifies each file against the manifest, then parses the fetched `SKILL.md` and compares its frontmatter with the entry before loading. |
+
+### Serving skills
+
+The simplest way to serve skills is to keep each one in its own directory, as the Agent Skills specification
+lays them out, and point `WithSkillsFromDirectory` at the parent:
+
+```csharp
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+
+builder.Services
+ .AddMcpServer()
+ .WithHttpTransport()
+ .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options =>
+ {
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ });
+```
+
+Every included immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each
+`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources,
+and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix`
+such as `skill://acme/billing/` to place the skills under an organizational path.
+
+Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents
+or counting them toward the published manifest's limits. The `includeHiddenAndSystemFiles: true` overloads of
+`WithSkillsFromDirectory` and `McpServerSkill.CreateFromDirectory` provide an explicit opt-in. For a narrower
+selection, use `McpServerSkill.Create` with explicit files; that API keeps the files the caller selected.
+Included symbolic links remain errors rather than being followed.
+
+For finer control, build skills individually with and
+register them with `WithSkills`:
+
+```csharp
+var gitWorkflow = McpServerSkill.CreateFromDirectory(Path.Combine(skillsRoot, "git-workflow"));
+
+var refunds = McpServerSkill.Create(
+ "skill://acme/billing/refunds/SKILL.md",
+ [
+ McpServerSkillFile.FromText("SKILL.md", skillMarkdown),
+ McpServerSkillFile.FromText("examples/approved.md", approvedTemplate),
+ new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes },
+ ]);
+
+builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]);
+```
+
+All of these validate structural entry and file constraints and throw with a
+specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the
+Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or
+the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`,
+`compatibility`, and the string-to-string `metadata` map pass through verbatim, as the specification requires; hosts compare
+them against the file and ignore fields such as `allowed-tools` for MCP-origin skills. File contents are copied when the skill is created, so
+later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not
+follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File
+names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs.
+
+#### Frontmatter
+
+ reads the YAML frontmatter of a `SKILL.md` into a
+ without a YAML library. It supports block mappings (nested up to 32
+levels), block and flow sequences, plain, quoted, and block scalars, and comments. Unquoted scalars are resolved per
+the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml`
+package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by
+parsing the fetched `SKILL.md` itself and comparing field by field against the published entry; a value that one
+side types as a number and the other as a string is a verification failure. Quote values such as version numbers
+that are meant to be strings.
+
+Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are not
+supported; the reader rejects them with a naming the construct rather than guess.
+For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the
+frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader
+detects, and when the reader can parse the whole block, the supplied object must match it exactly.
+
+The reader stops at the first unsupported construct, though, so for such a file it cannot check the rest of the
+block or compare it with the supplied object. The application must parse the entire frontmatter with a full YAML
+parser and supply the identical JSON; the SDK accepting it is not proof that it matches.
+
+#### Custom catalogs
+
+When skills come from a database, a file share, or a large or generated catalog, implement
+ and pass it to `WithSkills`:
+
+```csharp
+public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog
+{
+ public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ string tenant = GetTenant(context.User);
+ var (skills, nextCursor) = await repository.GetPageAsync(tenant, cursor, pageSize: 50, cancellationToken);
+ return new McpSkillPage { Skills = skills, NextCursor = nextCursor };
+ }
+
+ public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ repository.FindAsync(GetTenant(context.User), uri, cancellationToken);
+}
+```
+
+Both methods receive an with the JSON-RPC request,
+the caller's when the transport supplies one (the ASP.NET Core
+transport does), and any items that incoming-message filters attached to the request.
+
+A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with
+`resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not
+read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as
+`GetAsync` answers for every skill the server serves to the caller. Throw
+ with for a
+cursor the catalog cannot interpret. is the
+built-in implementation over a fixed set of entries and can be composed into a custom one.
+
+#### Dynamic skills
+
+A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to
+ and serve it through a catalog. Such a skill
+offers no content integrity, and hosts may decline to load it. A host that does load one still verifies its
+frontmatter.
+
+#### Protocol versions and caching hints
+
+`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes,
+configured through . Those attributes, and
+`resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later,
+where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in
+list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use
+the same configured hints, so use `public` only when the listing and every entry are identical for every caller.
+
+### Consuming skills
+
+The client extension methods in cover the
+host's side of the protocol:
+
+```csharp
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Nodes;
+
+if (!client.SupportsSkills())
+{
+ return; // Clients issue skills/list and skills/get only after observing the declaration.
+}
+
+// Enumerate, following pagination. The listing may be empty or partial.
+IList skills = await client.ListSkillsAsync();
+
+// Retrieve one skill by URI, for example one referenced from server instructions.
+Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md");
+
+// Read a file and verify it against the held entry's digest and size.
+ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri);
+
+// This example loads text SKILL.md resources and rejects other content forms.
+if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text)
+{
+ throw new SkillVerificationException("A text SKILL.md is required for this example.");
+}
+
+// A parse failure, including unsupported YAML, rejects this load. Do not fall back to skill.Frontmatter.
+var fetchedFrontmatter = SkillFrontmatter.Parse(text.Text);
+if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
+{
+ throw new SkillVerificationException("The fetched frontmatter differs from the held entry.");
+}
+```
+
+`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural
+requirements and throw for an entry a host
+must not load, such as one whose manifest omits its own `SKILL.md`, carries a malformed digest, or lists a file
+outside the skill. `ReadSkillResourceAsync` throws the same exception when the
+content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the
+content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest;
+because the manifest changed, any approval bound to the previous one is revoked and must be obtained again.
+
+The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`,
+return one page or the raw result and expose the caching hints.
+ exposes the verification and digest helpers for hosts
+that read resources through other means.
+
+#### Listing limits and rejected entries
+
+The aggregating `ListSkillsAsync()` overload stops at 100 pages or 1,000 entries by default. Repeated cursors
+and exceeded limits throw `McpException`; no silently truncated list is returned. Empty cursors remain opaque
+values, not an end marker. Use explicit positive limits when appropriate:
+
+```csharp
+var skills = await client.ListSkillsAsync(maxPages: 200, maxSkills: 5000);
+```
+
+Use the paged overload for application-controlled iteration. Strict listing still rejects a page containing
+an invalid entry. To retain valid entries while reporting the others, opt into
+`ListSkillsWithDiagnosticsAsync`:
+
+```csharp
+var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams());
+foreach (var rejected in listing.RejectedEntries)
+{
+ Console.Error.WriteLine($"Skill entry {rejected.Index} was rejected: {rejected.Error.Message}");
+}
+foreach (var listedSkill in listing.Page.Skills)
+{
+ Console.WriteLine(listedSkill.Uri);
+}
+```
+
+`Page` preserves the original cursor and cache hints. `RejectedEntries` identifies each invalid entry by its
+position in the original page and retains the parsing/validation error. An empty `Page.Skills` can mean every
+entry was rejected, so callers must inspect the errors. A malformed page envelope or missing `skills` array
+still fails the request. This API does not load rejected entries or replace file/frontmatter verification.
+
+### Security considerations
+
+Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The
+specification places most of the burden on hosts. In particular:
+
+- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request
+ filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing
+ therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some
+ callers must not see some skills, implement and
+ decide from the request context's user, and guard the corresponding resources the same way. The built-in
+ in-memory catalog serves the same entries to every caller. Use one shared application policy for both
+ metadata and file access; the [HTTP integration example](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs)
+ exercises both allowed and denied callers. Do not derive authorization from `resources/list` membership:
+ listings may intentionally be partial while an authorized direct read remains valid.
+- Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters
+ the model's context.
+- Digests are unsigned and come from the same server as the content. A match proves consistency between the entry
+ and what was fetched, not that either is trustworthy.
+- Bind any persisted per-skill approval to the entry's manifest. A later entry with a different manifest revokes it.
+- Do not honor frontmatter fields that widen the model's permissions, such as `allowed-tools`, for MCP-origin skills
+ without explicit user approval.
+- Skill names are labels, not identifiers, and are not unique across servers. Identify a skill by the pair of server
+ identity and URI.
+
+The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against
+the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows:
+ on the fetched text and
+
+against the entry's frontmatter, treating a parse failure or a difference as a verification failure. A host that
+must accept YAML the reader does not support can parse the file with a full YAML library instead, but never uses
+the entry's frontmatter in place of parsing the file.
+
+### Not implemented
+
+The optional `resources/directory/read` method and its `directoryRead` capability setting are not implemented.
+Servers built with this package do not declare `directoryRead`, and hosts must not call the method against them.
+
+### Samples
+
+- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer): a Streamable
+ HTTP server serving two skills from directories on disk.
+- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient): a client that
+ connects to it and discovers, retrieves, and verifies them.
diff --git a/docs/concepts/toc.yml b/docs/concepts/toc.yml
index 276415e13..58570780e 100644
--- a/docs/concepts/toc.yml
+++ b/docs/concepts/toc.yml
@@ -55,6 +55,8 @@ items:
uid: apps
- name: Tasks
uid: tasks
+ - name: Skills
+ uid: skills
- name: Identity and Roles
uid: identity
- name: API Reference
diff --git a/package-lock.json b/package-lock.json
index 774becc10..bf045e389 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
- "@modelcontextprotocol/conformance": "0.2.0-alpha.9",
+ "@modelcontextprotocol/conformance": "0.2.0-alpha.12",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
@@ -23,13 +23,15 @@
}
},
"node_modules/@modelcontextprotocol/conformance": {
- "version": "0.2.0-alpha.9",
- "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz",
- "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==",
+ "version": "0.2.0-alpha.12",
+ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.12.tgz",
+ "integrity": "sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@octokit/rest": "^22.0.0",
+ "ajv": "^8.20.0",
+ "ajv-formats": "^3.0.1",
"commander": "^14.0.2",
"eventsource-parser": "^3.0.8",
"express": "^5.1.0",
@@ -134,6 +136,7 @@
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.3",
@@ -303,9 +306,9 @@
}
},
"node_modules/ajv": {
- "version": "8.18.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
- "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -628,6 +631,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -848,6 +852,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz",
"integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -1537,6 +1542,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
+ "peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
diff --git a/package.json b/package.json
index 577d3e1f8..abe1fc863 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"private": true,
"description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests",
"dependencies": {
- "@modelcontextprotocol/conformance": "0.2.0-alpha.9",
+ "@modelcontextprotocol/conformance": "0.2.0-alpha.12",
"@modelcontextprotocol/server-everything": "2026.1.26",
"@modelcontextprotocol/server-memory": "2026.1.26"
}
diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs
new file mode 100644
index 000000000..464d58044
--- /dev/null
+++ b/samples/SkillsClient/Program.cs
@@ -0,0 +1,111 @@
+// Demonstrates consuming Agent Skills over MCP with the Skills extension (SEP-2640), from the host's side:
+//
+// 1. Connect over Streamable HTTP and check that the server declares the extension.
+// 2. Enumerate the skills with skills/list (the client extension follows pagination for you).
+// 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions.
+// 4. Verify fetched bytes against the manifest, then independently parse and compare SKILL.md frontmatter.
+// 5. Show that a file the manifest does not list is refused before any request is sent.
+//
+// Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as
+// the first argument to run against another server.
+
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json.Nodes;
+
+var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001");
+
+await using McpClient client = await McpClient.CreateAsync(new HttpClientTransport(new()
+{
+ Name = "Skills Server",
+ Endpoint = endpoint,
+}));
+
+Console.WriteLine($"Connected to {client.ServerInfo.Name} {client.ServerInfo.Version} at {endpoint} (protocol {client.NegotiatedProtocolVersion})");
+Console.WriteLine();
+
+// 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration.
+if (!client.SupportsSkills())
+{
+ Console.WriteLine("The server does not declare the io.modelcontextprotocol/skills extension.");
+ return;
+}
+
+// 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI.
+Console.WriteLine("=== skills/list ===");
+ListSkillsResult firstPage = await client.ListSkillsAsync(new ListSkillsRequestParams());
+Console.WriteLine($" caching hints: ttlMs={(firstPage.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={firstPage.CacheScope?.ToString() ?? "(none)"}");
+
+IList skills = await client.ListSkillsAsync();
+foreach (Skill listed in skills)
+{
+ string manifest = listed.Resources.IsDynamic
+ ? "dynamic (no digests)"
+ : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes";
+ Console.WriteLine($" {listed.Uri}");
+ Console.WriteLine($" name: {listed.Name}");
+ Console.WriteLine($" description: {listed.Description}");
+ Console.WriteLine($" manifest: {manifest}");
+}
+
+Console.WriteLine();
+
+// 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly.
+Console.WriteLine("=== skills/get ===");
+Console.WriteLine($" server instructions: {client.ServerInstructions}");
+const string SkillUri = "skill://git-workflow/SKILL.md";
+Skill skill = await client.GetSkillAsync(SkillUri);
+Console.WriteLine($" {skill.Uri} ({skill.Name})");
+foreach (SkillResource file in skill.Resources.Resources!)
+{
+ Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}");
+}
+
+Console.WriteLine();
+
+// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter.
+// The host must independently verify frontmatter before loading, regardless of the server's authoring path.
+Console.WriteLine("=== resources/read (verified) ===");
+ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri);
+if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText)
+{
+ throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification.");
+}
+
+try
+{
+ var fetchedFrontmatter = SkillFrontmatter.Parse(skillText.Text);
+ if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
+ {
+ throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry.");
+ }
+}
+catch (FormatException exception)
+{
+ // This sample rejects unsupported YAML. A full host may instead use a suitable full parser,
+ // but must never treat the server-provided JSON as verification of the fetched file.
+ throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception);
+}
+
+Console.WriteLine($" {SkillUri} manifest and frontmatter verified:");
+Console.WriteLine(Indent(skillText.Text));
+
+string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri;
+ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri);
+Console.WriteLine($" {supportingFileUri} verified ({((TextResourceContents)supportingFile.Contents[0]).Text.Length} chars)");
+Console.WriteLine();
+
+// 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it.
+Console.WriteLine("=== unlisted file ===");
+try
+{
+ await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/UNLISTED.md");
+}
+catch (SkillVerificationException e)
+{
+ Console.WriteLine($" refused: {e.Message}");
+}
+
+static string Indent(string text) =>
+ string.Join(Environment.NewLine, text.TrimEnd().Split('\n').Select(line => " | " + line.TrimEnd('\r')));
diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md
new file mode 100644
index 000000000..63d6269b3
--- /dev/null
+++ b/samples/SkillsClient/README.md
@@ -0,0 +1,75 @@
+# Skills Client Sample
+
+A console client that consumes [Agent Skills](https://agentskills.io/) over MCP through the Skills extension
+([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)),
+doing what a host's skill-loading path does:
+
+1. Connects over Streamable HTTP and checks that the server declares `io.modelcontextprotocol/skills`
+ (`client.SupportsSkills()`).
+2. Enumerates the catalog with `skills/list` (`client.ListSkillsAsync()` follows up to 100 pages and 1,000
+ entries by default; the per-page overload exposes the `ttlMs` and `cacheScope` hints).
+3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it
+ found in server instructions.
+4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size
+ (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, independently parses its
+ frontmatter and compares every field with the held entry.
+5. Shows that a file the manifest does not list is refused before any request is sent.
+
+## Run
+
+Start the [SkillsServer](../SkillsServer) sample in one terminal:
+
+```bash
+dotnet run --project samples/SkillsServer/SkillsServer.csproj
+```
+
+Then run the client in another:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj
+```
+
+To run against another Streamable HTTP server, pass its endpoint:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp
+```
+
+Expected output (abridged):
+
+```
+=== skills/list ===
+ caching hints: ttlMs=300000 cacheScope=Public
+ skill://git-workflow/SKILL.md
+ name: git-workflow
+ manifest: 3 file(s), 1523 bytes
+ skill://refunds/SKILL.md
+ name: refunds
+ ...
+
+=== skills/get ===
+ skill://git-workflow/SKILL.md (git-workflow)
+ skill://git-workflow/SKILL.md 724 bytes sha256:…
+ ...
+
+=== resources/read (verified) ===
+ skill://git-workflow/SKILL.md manifest and frontmatter verified:
+ | ---
+ | name: git-workflow
+ ...
+
+=== unlisted file ===
+ refused: 'skill://git-workflow/references/UNLISTED.md' is not listed in the manifest of skill ...
+```
+
+## Notes
+
+- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come
+ from the same server. Treat skill content as untrusted model input and tag it with its originating server.
+- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` with
+ `SkillFrontmatter.Parse` and compares it to the entry, rejecting the skill on a difference or on YAML the reader
+ does not support. A host that must accept such YAML can use a full YAML parser instead, but never the entry's
+ own frontmatter.
+- This is not a complete host: approval binding, origin isolation, and permission decisions are left out.
+- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative
+ for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable.
diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj
new file mode 100644
index 000000000..01f44dcb0
--- /dev/null
+++ b/samples/SkillsClient/SkillsClient.csproj
@@ -0,0 +1,16 @@
+
+
+
+ Exe
+ net8.0
+ enable
+ enable
+ $(NoWarn);MCPEXP001
+
+
+
+
+
+
+
+
diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs
new file mode 100644
index 000000000..f16c7c0c1
--- /dev/null
+++ b/samples/SkillsServer/Program.cs
@@ -0,0 +1,40 @@
+// Demonstrates serving Agent Skills over MCP with the Skills extension (SEP-2640) from a Streamable HTTP server.
+//
+// Each skill is a directory under ./Skills containing a SKILL.md and, optionally, supporting files.
+// WithSkillsFromDirectory reads every skill directory, parses the YAML frontmatter of each SKILL.md, computes the
+// SHA-256 digest and size of every file, and registers both the skills' entries (what skills/list and skills/get
+// return) and the resources that serve their files (what resources/read returns).
+
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Every immediate subdirectory of ./Skills that contains a SKILL.md becomes a skill. The frontmatter is read from
+// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkill.CreateFromDirectory or
+// McpServerSkill.Create with WithSkills for finer control, for example an organizational URI prefix per skill.
+string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills");
+
+builder.Services
+ .AddMcpServer(options =>
+ {
+ options.ServerInfo = new Implementation { Name = "SkillsServer", Version = "1.0.0" };
+
+ // A server may point the agent at a skill directly from its instructions. A host confirms the URI with
+ // skills/get and reads it with resources/read; no listing is required.
+ options.ServerInstructions =
+ "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md.";
+ })
+ .WithHttpTransport()
+ .WithSkillsFromDirectory(skillsRoot, configure: options =>
+ {
+ // Every caller sees the same catalog, so the listing may be shared by intermediaries for a while.
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ });
+
+var app = builder.Build();
+
+app.MapMcp();
+
+app.Run();
diff --git a/samples/SkillsServer/Properties/launchSettings.json b/samples/SkillsServer/Properties/launchSettings.json
new file mode 100644
index 000000000..bd31121a4
--- /dev/null
+++ b/samples/SkillsServer/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:3001",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md
new file mode 100644
index 000000000..48e247d9f
--- /dev/null
+++ b/samples/SkillsServer/README.md
@@ -0,0 +1,45 @@
+# Skills Server Sample
+
+A Streamable HTTP MCP server that serves two [Agent Skills](https://agentskills.io/) through the MCP Skills
+extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)).
+
+Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and supporting files:
+
+| Skill URI | Files |
+| ---------------------------------------- | -------------------------------------------------------------- |
+| `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` |
+| `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` |
+
+`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the
+SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the
+resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from
+its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`.
+Directory discovery excludes dot-prefixed, hidden and system entries by default. The explicit
+`includeHiddenAndSystemFiles: true` overload opts in; included links are still rejected.
+
+## Run
+
+```bash
+dotnet run --project samples/SkillsServer/SkillsServer.csproj
+```
+
+The server listens on `http://localhost:3001` (see `Properties/launchSettings.json`). Then, in another terminal,
+run the [SkillsClient](../SkillsClient) sample, which connects to it and walks through discovery, retrieval, and
+verified reads:
+
+```bash
+dotnet run --project samples/SkillsClient/SkillsClient.csproj
+```
+
+Any MCP host that supports Streamable HTTP can also be pointed at `http://localhost:3001`.
+
+## Notes
+
+- Skill URIs are scoped to the server that serves them. A URI may carry an organizational prefix, as in
+ `skill://acme/billing/refunds/SKILL.md`; only the final segment must equal the skill's `name`.
+- The server's instructions point the agent at `skill://git-workflow/SKILL.md` directly. A host can confirm a
+ URI with `skills/get` and read it with `resources/read` without ever listing the catalog.
+- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`.
+ A catalog that varies by principal must not do that.
+- See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough,
+ including how to implement a custom `IMcpSkillCatalog`.
diff --git a/samples/SkillsServer/Skills/git-workflow/SKILL.md b/samples/SkillsServer/Skills/git-workflow/SKILL.md
new file mode 100644
index 000000000..663f1a173
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/SKILL.md
@@ -0,0 +1,25 @@
+---
+name: git-workflow
+description: Follow this team's Git conventions for branching, commit messages, and pull requests.
+license: MIT
+---
+
+# Git workflow
+
+Use this skill whenever you create branches, commits, or pull requests in this repository.
+
+## Branches
+
+- Branch from `main`.
+- Name branches `/`, where `` is one of `feat`, `fix`, `docs`, or `chore`.
+
+## Commits
+
+Write commit messages in the imperative mood, with a subject line of at most 72 characters. See
+`references/COMMIT_STYLE.md` for the full style guide and examples.
+
+## Pull requests
+
+- Open pull requests against `main`.
+- Fill in the template in `templates/PULL_REQUEST.md`.
+- Request review from at least one code owner.
diff --git a/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md
new file mode 100644
index 000000000..f4505cf9b
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md
@@ -0,0 +1,24 @@
+# Commit message style
+
+```
+:
+
+
+```
+
+- `` is one of `feat`, `fix`, `docs`, `refactor`, `test`, or `chore`.
+- `` is imperative ("Add", not "Added" or "Adds") and does not end with a period.
+- The body explains what changed and why, wrapped at 72 columns. Reference issues as `Fixes #123`.
+
+## Examples
+
+```
+feat: Add keyset pagination to the skills catalog
+
+Offset cursors skipped entries when a skill was removed between pages.
+Encode the last URI of each page instead. Fixes #42.
+```
+
+```
+docs: Clarify that frontmatter must match SKILL.md verbatim
+```
diff --git a/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md
new file mode 100644
index 000000000..c0947267b
--- /dev/null
+++ b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md
@@ -0,0 +1,16 @@
+## Summary
+
+
+
+## Changes
+
+-
+
+## Testing
+
+
+
+## Checklist
+
+- [ ] Tests added or updated
+- [ ] Documentation updated
diff --git a/samples/SkillsServer/Skills/refunds/SKILL.md b/samples/SkillsServer/Skills/refunds/SKILL.md
new file mode 100644
index 000000000..50853ef59
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/SKILL.md
@@ -0,0 +1,18 @@
+---
+name: refunds
+description: Process customer refund requests per company policy.
+metadata:
+ owner: billing-team
+ version: 2.1.0
+---
+
+# Refunds
+
+Use this skill when a customer asks for a refund.
+
+1. Confirm the order is within the 30-day refund window.
+2. Check whether the item is refundable under `policy/REFUND_POLICY.md`.
+3. If it is, issue the refund and reply using `examples/approved.md`.
+4. If it is not, reply using `examples/declined.md` and offer store credit where the policy allows it.
+
+Never issue a refund above 500 USD without a second approval.
diff --git a/samples/SkillsServer/Skills/refunds/examples/approved.md b/samples/SkillsServer/Skills/refunds/examples/approved.md
new file mode 100644
index 000000000..a7b966133
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/examples/approved.md
@@ -0,0 +1,9 @@
+Subject: Your refund has been issued
+
+Hi {customer_name},
+
+We've issued a refund of {amount} for order {order_id}. It should appear on your original payment method within
+5 to 10 business days.
+
+Thanks for your patience,
+{agent_name}
diff --git a/samples/SkillsServer/Skills/refunds/examples/declined.md b/samples/SkillsServer/Skills/refunds/examples/declined.md
new file mode 100644
index 000000000..a8b53f7cc
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/examples/declined.md
@@ -0,0 +1,9 @@
+Subject: About your refund request
+
+Hi {customer_name},
+
+Unfortunately order {order_id} isn't eligible for a refund because {reason}. As an alternative, we'd be glad to
+offer you store credit of {credit_amount}.
+
+Let us know how you'd like to proceed,
+{agent_name}
diff --git a/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md
new file mode 100644
index 000000000..f88ee1afa
--- /dev/null
+++ b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md
@@ -0,0 +1,8 @@
+# Refund policy
+
+| Category | Refundable | Window | Notes |
+| ------------------- | ---------- | ------- | -------------------------------------- |
+| Physical goods | Yes | 30 days | Must be unused and in original packaging |
+| Digital downloads | No | | Store credit may be offered |
+| Subscriptions | Prorated | Anytime | Refund the unused portion |
+| Gift cards | No | | |
diff --git a/samples/SkillsServer/SkillsServer.csproj b/samples/SkillsServer/SkillsServer.csproj
new file mode 100644
index 000000000..2833a3dd7
--- /dev/null
+++ b/samples/SkillsServer/SkillsServer.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net9.0
+ enable
+ enable
+ $(NoWarn);MCPEXP001
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets
index 0c3c50923..daa2f43b3 100644
--- a/src/Directory.Build.targets
+++ b/src/Directory.Build.targets
@@ -5,6 +5,7 @@
AfterTargets="_GetProjectReferenceVersions"
Condition="'$(MSBuildProjectName)' == 'ModelContextProtocol'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.AspNetCore'
+ Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Skills'
Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Tasks'">
<_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)">
diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
index 785e3cc2e..d5e5aa213 100644
--- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
+++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs
@@ -191,14 +191,20 @@ public ClientOAuthProvider(
});
}
- internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
+ internal override async Task SendAsync(
+ HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null)
{
bool attemptedRefresh = false;
+ discoverProbe ??= message?.Context?.DiscoverProbe;
- if (request.Headers.Authorization is null && request.RequestUri is not null)
+ if (request.Headers.Authorization is null &&
+ request.RequestUri is not null)
{
string? accessToken;
- (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false);
+ using (discoverProbe?.Suspend())
+ {
+ (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false);
+ }
if (!string.IsNullOrEmpty(accessToken))
{
@@ -213,7 +219,7 @@ internal override async Task SendAsync(HttpRequestMessage r
// Capture the token that produced this challenge so the retry path can detect whether
// another concurrent caller already replaced it in the cache.
var usedAccessToken = request.Headers.Authorization?.Parameter;
- return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false);
+ return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, discoverProbe, cancellationToken).ConfigureAwait(false);
}
return response;
@@ -296,6 +302,7 @@ private async Task HandleUnauthorizedResponseAsync(
HttpResponseMessage response,
bool attemptedRefresh,
string? usedAccessToken,
+ DiscoverProbe? discoverProbe,
CancellationToken cancellationToken)
{
if (response.Headers.WwwAuthenticate.Count == 0)
@@ -308,7 +315,11 @@ private async Task HandleUnauthorizedResponseAsync(
throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}].");
}
- var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false);
+ string accessToken;
+ using (discoverProbe?.Suspend())
+ {
+ accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false);
+ }
using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri);
@@ -514,7 +525,7 @@ private async Task GetAuthServerMetadataAsync(Uri a
AuthorizationServerMetadata? metadata;
try
{
- var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
+ using var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
continue;
@@ -568,14 +579,16 @@ private async Task GetAuthServerMetadataAsync(Uri a
// below, its existence disqualifies the legacy fallback that would otherwise synthesize S256.
metadataDocumentFound = true;
}
- catch (McpException)
+ catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
- // Metadata validation failures are security signals and must not fall back to
- // another well-known endpoint.
- throw;
+ // HttpClient's per-request timeout does not cancel the caller's token.
+ // Another metadata endpoint may still respond within the overall budget.
+ LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint);
+ continue;
}
- catch (Exception ex)
+ catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException)
{
+ cancellationToken.ThrowIfCancellationRequested();
LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint);
continue;
}
@@ -602,10 +615,10 @@ private async Task GetAuthServerMetadataAsync(Uri a
$"Authorization server metadata from '{wellKnownEndpoint}' does not advertise required PKCE method 'S256' in 'code_challenge_methods_supported'. Advertised methods: {advertisedMethods}.");
}
}
- catch (Exception ex)
+ catch (McpException ex)
{
LogAuthServerMetadataMissingPkceSupport(ex, wellKnownEndpoint);
- pkceValidationFailure = ex as McpException ?? new McpException(ex.Message, ex);
+ pkceValidationFailure = ex;
continue;
}
diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
index 7563acd10..ae69392b0 100644
--- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs
@@ -161,7 +161,7 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque
try
{
LogAttemptingSSE(_name);
- await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ await sseTransport.ConnectAsync(cancellationToken, message.Context?.DiscoverProbe).ConfigureAwait(false);
await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false);
LogUsingSSE(_name);
diff --git a/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs
new file mode 100644
index 000000000..6ea4b8390
--- /dev/null
+++ b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs
@@ -0,0 +1,108 @@
+using System.Diagnostics;
+
+namespace ModelContextProtocol.Client;
+
+/// Tracks server-wait time without charging OAuth work to the discovery probe.
+internal sealed class DiscoverProbe : IDisposable
+{
+ private static readonly TimeSpan s_maxTimerInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1);
+ private readonly object _sync = new();
+ private readonly CancellationTokenSource _cancellation;
+ private readonly Stopwatch _elapsed = Stopwatch.StartNew();
+ private readonly TimeSpan _timeout;
+ private readonly Timer _timer;
+ private int _suspensions;
+ private bool _disposed;
+
+ public DiscoverProbe(TimeSpan timeout, CancellationToken cancellationToken)
+ {
+ _timeout = timeout;
+ _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ _timer = new Timer(_ => OnTimeout(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ ScheduleTimer();
+ }
+
+ public CancellationToken Token => _cancellation.Token;
+
+ public IDisposable Suspend()
+ {
+ lock (_sync)
+ {
+ Token.ThrowIfCancellationRequested();
+ if (_suspensions++ == 0)
+ {
+ _elapsed.Stop();
+ _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
+ }
+ }
+ return new Suspension(this);
+ }
+
+ private void Resume()
+ {
+ lock (_sync)
+ {
+ if (_disposed ||
+ --_suspensions != 0)
+ {
+ return;
+ }
+
+ _elapsed.Start();
+ ScheduleTimer();
+ }
+ }
+
+ private void ScheduleTimer()
+ {
+ if (_timeout == Timeout.InfiniteTimeSpan)
+ {
+ return;
+ }
+
+ var remaining = _timeout - _elapsed.Elapsed;
+ var interval = remaining > s_maxTimerInterval ? s_maxTimerInterval : remaining;
+ _timer.Change(interval > TimeSpan.Zero ? interval : TimeSpan.Zero, Timeout.InfiniteTimeSpan);
+ }
+
+ private void OnTimeout()
+ {
+ // Serialize expiry with suspension and disposal, including a timer callback that was
+ // already queued when OAuth suspended the budget.
+ lock (_sync)
+ {
+ if (_disposed ||
+ _suspensions != 0 ||
+ _cancellation.IsCancellationRequested)
+ {
+ return;
+ }
+
+ var remaining = _timeout - _elapsed.Elapsed;
+ if (remaining > TimeSpan.Zero)
+ {
+ ScheduleTimer();
+ return;
+ }
+
+ _cancellation.Cancel();
+ }
+ }
+
+ public void Dispose()
+ {
+ lock (_sync)
+ {
+ _disposed = true;
+ _timer.Dispose();
+ _cancellation.Dispose();
+ }
+ }
+
+ private sealed class Suspension(DiscoverProbe probe) : IDisposable
+ {
+ private DiscoverProbe? _probe = probe;
+
+ public void Dispose() => Interlocked.Exchange(ref _probe, null)?.Resume();
+ }
+}
diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
index d1f2a9d7a..f293f43b6 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs
@@ -309,18 +309,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// Apply a probe timeout so dual-path clients don't block forever waiting for an
// initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules).
- // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is
- // always bounded by InitializationTimeout (only applied when it is the tighter bound).
+ // OAuth temporarily suspends only the server-wait budget. InitializationTimeout
+ // and caller cancellation still bound the entire connection, including authorization.
var probeTimeout = _options.DiscoverProbeTimeout;
- using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token);
- if (_options.InitializationTimeout > probeTimeout)
- {
- probeCts.CancelAfter(probeTimeout);
- }
+ bool initializationBoundsProbe =
+ _options.InitializationTimeout != Timeout.InfiniteTimeSpan &&
+ (probeTimeout == Timeout.InfiniteTimeSpan ||
+ probeTimeout >= _options.InitializationTimeout);
+ using var probe = new DiscoverProbe(
+ initializationBoundsProbe ? Timeout.InfiniteTimeSpan : probeTimeout,
+ initializationCts.Token);
try
{
- discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
+ discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false);
}
catch (UnsupportedProtocolVersionException ex)
{
@@ -346,7 +348,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
}
discoverVersion = retryVersion;
- discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false);
+ discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false);
}
else
{
@@ -391,7 +393,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// server, so fall back. Other statuses stay uncaught and surface to the caller.
fallbackToInitialize = true;
}
- catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested)
+ catch (OperationCanceledException) when (probe.Token.IsCancellationRequested &&
+ !initializationCts.IsCancellationRequested)
{
// Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no
// response within a reasonable timeout means the server requires initialize. Fall back.
@@ -453,19 +456,22 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
_serverInstructions = discoverResult.Instructions;
}
- async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken)
+ async Task SendDiscoverAsync(string protocolVersion)
{
// Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being
// on a per-request metadata revision when SendRequestAsync is invoked for server/discover.
_negotiatedProtocolVersion = protocolVersion;
_sessionHandler.NegotiatedProtocolVersion = protocolVersion;
- return await SendRequestAsync(
- RequestMethods.ServerDiscover,
- new DiscoverRequestParams(),
- McpJsonUtilities.JsonContext.Default.DiscoverRequestParams,
- McpJsonUtilities.JsonContext.Default.DiscoverResult,
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ var request = new JsonRpcRequest
+ {
+ Method = RequestMethods.ServerDiscover,
+ Params = JsonSerializer.SerializeToNode(new DiscoverRequestParams(), McpJsonUtilities.JsonContext.Default.DiscoverRequestParams),
+ Context = new() { DiscoverProbe = probe },
+ };
+ var response = await SendRequestAsync(request, probe.Token).ConfigureAwait(false);
+ return JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.DiscoverResult)
+ ?? throw new JsonException("Unexpected JSON result in discovery response.");
}
}
else
diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
index 61a0613df..eb09c13dc 100644
--- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
+++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs
@@ -121,6 +121,11 @@ public sealed class McpClientOptions
/// greater than or equal to , the probe is effectively bounded by
/// alone.
///
+ ///
+ /// Time spent acquiring OAuth tokens, including authorization metadata discovery and interactive
+ /// authorization, is excluded from the probe timeout but remains subject to
+ /// and caller cancellation.
+ ///
///
///
/// The value is not positive and is not .
diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
index f4df789c7..d2d830084 100644
--- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
+++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs
@@ -14,7 +14,8 @@ internal class McpHttpClient(HttpClient httpClient)
{
internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json");
- internal virtual async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken)
+ internal virtual async Task SendAsync(
+ HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null)
{
Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage");
Debug.Assert(message is null || request.Method == HttpMethod.Post, "All messages should be sent in POST requests.");
diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
index 99bdc1eb9..a08cfd033 100644
--- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
+++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs
@@ -49,13 +49,13 @@ public SseClientSessionTransport(
}
///
- public async Task ConnectAsync(CancellationToken cancellationToken = default)
+ public async Task ConnectAsync(CancellationToken cancellationToken = default, DiscoverProbe? discoverProbe = null)
{
Debug.Assert(!IsConnected);
try
{
// Start message receiving loop
- _receiveTask = ReceiveMessagesAsync(_connectionCts.Token);
+ _receiveTask = ReceiveMessagesAsync(_connectionCts.Token, discoverProbe);
await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false);
}
@@ -149,7 +149,7 @@ public override async ValueTask DisposeAsync()
}
}
- private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
+ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken, DiscoverProbe? discoverProbe)
{
HttpStatusCode? failureStatusCode = null;
try
@@ -158,7 +158,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken)
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream"));
StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null);
- using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false);
+ using var response = await _httpClient.SendAsync(request, message: null, cancellationToken, discoverProbe).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
index ef61df263..0a108ff0a 100644
--- a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
+++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs
@@ -16,12 +16,14 @@ namespace ModelContextProtocol.Protocol;
/// .
///
///
-/// This converter is applied per-property on the cacheable result types. The
-/// enum itself retains a standard string converter for any standalone serialization.
+/// This converter is applied per-property on the cacheable result types, and is public so that extension
+/// packages defining their own types can apply the same read-side leniency. The
+/// enum itself retains a standard string converter for any standalone serialization.
///
///
-internal sealed class CacheScopeConverter : JsonConverter
+public sealed class CacheScopeConverter : JsonConverter
{
+ ///
public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType is JsonTokenType.String)
@@ -52,6 +54,7 @@ internal sealed class CacheScopeConverter : JsonConverter
return null;
}
+ ///
public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options)
{
if (value is null)
diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
index 0b56caa29..478fab0ea 100644
--- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
+++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs
@@ -15,6 +15,8 @@ namespace ModelContextProtocol.Protocol;
///
public sealed class JsonRpcMessageContext
{
+ internal Client.DiscoverProbe? DiscoverProbe { get; set; }
+
///
/// Gets or sets the transport the was received on or should be sent over.
///
diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs
index cc7e33f24..0713feb84 100644
--- a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs
+++ b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs
@@ -10,8 +10,14 @@ namespace ModelContextProtocol.Protocol;
///
public abstract class PaginatedRequestParams : RequestParams
{
- /// Prevent external derivations.
- private protected PaginatedRequestParams()
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// This constructor is rather than private protected so that extension
+ /// packages implementing paginated methods defined outside the core specification can derive from it.
+ ///
+ protected PaginatedRequestParams()
{
}
diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs
index df9dc4475..e496e0921 100644
--- a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs
+++ b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs
@@ -17,7 +17,14 @@ namespace ModelContextProtocol.Protocol;
///
public abstract class PaginatedResult : Result
{
- private protected PaginatedResult()
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ ///
+ /// This constructor is rather than private protected so that extension
+ /// packages implementing paginated methods defined outside the core specification can derive from it.
+ ///
+ protected PaginatedResult()
{
}
diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs
index c755cd9e5..5ded4dac4 100644
--- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs
+++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs
@@ -67,46 +67,59 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
}
private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
- MethodInfo method, McpServerPromptCreateOptions? options) =>
- new()
+ MethodInfo method, McpServerPromptCreateOptions? options)
+ {
+ var schemaOptions = options?.SchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default;
+ return new()
{
Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? AIFunctionMcpServerTool.DeriveName(method),
Description = options?.Description,
MarshalResult = static (result, _, cancellationToken) => new ValueTask
diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
index 708cd9361..7b695d963 100644
--- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
+++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Extensions.Apps;
+using ModelContextProtocol.Extensions.Skills;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.IO.Pipelines;
@@ -11,7 +12,13 @@
services.AddMcpServer()
.WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream())
.WithTools()
- .WithMcpApps();
+ .WithMcpApps()
+ .WithSkills(
+ [
+ McpServerSkill.Create(
+ "skill://aot/SKILL.md",
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]),
+ ]);
await using var serviceProvider = services.BuildServiceProvider();
var server = serviceProvider.GetRequiredService();
@@ -41,6 +48,34 @@
throw new Exception($"Unexpected result: {result}");
}
+// List, get, and verify a skill.
+var skills = await client.ListSkillsAsync();
+if (skills.Count != 1 || skills[0].Name != "aot")
+{
+ throw new Exception($"Unexpected skills listing: {skills.Count} entries.");
+}
+
+var skillResult = await client.GetSkillAsync(new GetSkillRequestParams { Uri = "skill://aot/SKILL.md" });
+if (skillResult.ResultType != "complete" ||
+ skillResult.TimeToLive != TimeSpan.Zero ||
+ skillResult.CacheScope != CacheScope.Private)
+{
+ throw new Exception("Unexpected skill cache hints.");
+}
+
+var skill = skillResult.Skill;
+if (skill.Description != "An AOT-published skill." ||
+ skill.Frontmatter["metadata"]?["note"]?.GetValue() != "First\n\tCode\nLast\n")
+{
+ throw new Exception("Unexpected skill frontmatter.");
+}
+
+var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri);
+if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot"))
+{
+ throw new Exception("Unexpected skill content.");
+}
+
Console.WriteLine("Success!");
[McpServerToolType]
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
index 21fb98c2e..6bd8c749b 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs
@@ -1,4 +1,4 @@
-using System.Diagnostics;
+using Microsoft.AspNetCore.Builder;
using ModelContextProtocol.Tests.Utils;
namespace ModelContextProtocol.ConformanceTests;
@@ -7,34 +7,42 @@ namespace ModelContextProtocol.ConformanceTests;
/// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP
/// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at
/// , "/stateless") on one port. A single long-lived server avoids
-/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and
-/// centralizes the port-binding logic that the stateful and stateless conformance tests previously
-/// duplicated. Shared by and
+/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows. Kestrel
+/// assigns a loopback port, and initialization awaits this host's startup before publishing its URL.
+/// Shared by and
/// via .
///
public sealed class ConformanceServerFixture : IAsyncLifetime
{
- // Use different ports for each target framework to allow parallel execution across the
- // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003.
- private static int GetPortForTargetFramework()
+ private readonly Func _createApplication;
+ private readonly TimeProvider _timeProvider;
+ private WebApplication? _app;
+ private string? _serverUrl;
+ private CancellationTokenSource? _startupTimeoutCts;
+ private CancellationTokenSource? _startupCts;
+ private Task? _startupTask;
+ private Task? _stopTask;
+ private Task? _disposeTask;
+ private bool _startupOutcomeObserved;
+
+ public ConformanceServerFixture()
+ : this(() => new ConformanceServer.Program().CreateApplication(["--urls", "http://127.0.0.1:0"]))
{
- var testBinaryDir = AppContext.BaseDirectory;
- var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar));
+ }
- return targetFramework switch
- {
- "net10.0" => 3001,
- "net9.0" => 3002,
- "net8.0" => 3003,
- _ => 3001 // Default fallback
- };
+ internal ConformanceServerFixture(Func createApplication)
+ : this(createApplication, TimeProvider.System)
+ {
}
- private Task? _serverTask;
- private CancellationTokenSource? _serverCts;
+ internal ConformanceServerFixture(Func createApplication, TimeProvider timeProvider)
+ {
+ _createApplication = createApplication;
+ _timeProvider = timeProvider;
+ }
/// Base URL of the stateful MCP endpoint (mapped at "/").
- public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}";
+ public string ServerUrl => _serverUrl ?? throw new InvalidOperationException("The conformance server has not started.");
///
/// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28
@@ -42,57 +50,115 @@ private static int GetPortForTargetFramework()
///
public string StatelessServerUrl => $"{ServerUrl}/stateless";
- public async ValueTask InitializeAsync()
- {
- _serverCts = new CancellationTokenSource();
- _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync(
- ["--urls", ServerUrl], cancellationToken: _serverCts.Token));
+ public ValueTask InitializeAsync() => InitializeAsync(TestContext.Current.CancellationToken);
- // Wait for server to be ready (retry for up to 30 seconds)
- var timeout = TimeSpan.FromSeconds(30);
- var stopwatch = Stopwatch.StartNew();
- using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout };
+ internal async ValueTask InitializeAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _startupTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30), _timeProvider);
+ _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _startupTimeoutCts.Token);
+ _app = _createApplication();
+ _startupTask = _app.StartAsync(_startupCts.Token);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var registration = _startupCts.Token.Register(() => cancelled.TrySetResult(true));
- while (stopwatch.Elapsed < timeout)
+ try
{
- try
- {
- await httpClient.GetAsync($"{ServerUrl}/health");
- return;
- }
- catch (HttpRequestException)
+ await Task.WhenAny(_startupTask, cancelled.Task);
+ if (_startupTask.IsCompleted)
{
- // Connection refused means server not ready yet
- }
- catch (TaskCanceledException)
- {
- // Timeout means server might be processing, give it more time
+ _startupOutcomeObserved = true;
+ await _startupTask;
}
+ _startupCts.Token.ThrowIfCancellationRequested();
+ _startupTimeoutCts.CancelAfter(Timeout.InfiniteTimeSpan);
+ }
+ catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested &&
+ _startupCts.IsCancellationRequested)
+ {
+ throw new TimeoutException("ConformanceServer failed to start within 30 seconds.", error);
+ }
+ catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested &&
+ _app.Lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ throw new InvalidOperationException("ConformanceServer stopped before startup completed.", error);
+ }
- await Task.Delay(500);
+ if (!_app.Lifetime.ApplicationStarted.IsCancellationRequested ||
+ _app.Lifetime.ApplicationStopping.IsCancellationRequested)
+ {
+ throw new InvalidOperationException("ConformanceServer stopped before startup completed.");
}
- throw new InvalidOperationException("ConformanceServer failed to start within the timeout period");
+ _serverUrl = _app.Urls.Single();
}
public async ValueTask DisposeAsync()
{
- if (_serverCts != null)
+ if (_app is null)
+ {
+ DisposeStartupCancellation();
+ return;
+ }
+
+ using var shutdownCts = new CancellationTokenSource(TestConstants.DefaultTimeout, _timeProvider);
+ try
{
- _serverCts.Cancel();
- if (_serverTask != null)
+ if (_startupCts is not null)
{
- try
- {
- await _serverTask.WaitAsync(TestConstants.DefaultTimeout);
- }
- catch
+ await _startupCts.CancelAsync().WaitAsync(shutdownCts.Token);
+ }
+
+ if (_startupTask is not null)
+ {
+ // Wait for completion without throwing its already-reported startup error.
+ // A timed-out, still-running startup retains ownership of the app and tokens.
+ await WaitForCompletionAsync(_startupTask, shutdownCts.Token);
+ }
+
+ shutdownCts.Token.ThrowIfCancellationRequested();
+ _stopTask ??= _app.StopAsync(shutdownCts.Token);
+ await WaitForCompletionAsync(_stopTask, shutdownCts.Token);
+ shutdownCts.Token.ThrowIfCancellationRequested();
+ _disposeTask ??= _app.DisposeAsync().AsTask();
+ await _disposeTask.WaitAsync(shutdownCts.Token);
+
+ var startupOutcome = Task.CompletedTask;
+ if (!_startupOutcomeObserved &&
+ _startupTask is not null)
+ {
+ _startupOutcomeObserved = true;
+ bool expectedCancellation = _startupTask.IsCanceled &&
+ (_startupCts?.IsCancellationRequested).GetValueOrDefault();
+ if (!expectedCancellation)
{
- // Ignore exceptions during shutdown
+ startupOutcome = _startupTask;
}
}
- _serverCts.Dispose();
+
+ _app = null;
+ _serverUrl = null;
+ DisposeStartupCancellation();
+
+ // Observe failures only after safely releasing the host; neither a late startup
+ // fault nor a StopAsync failure is converted into successful cleanup.
+ await Task.WhenAll(_stopTask, startupOutcome);
}
+ catch (OperationCanceledException error) when (shutdownCts.IsCancellationRequested)
+ {
+ throw new TimeoutException("ConformanceServer cleanup did not complete within 60 seconds.", error);
+ }
+ }
+
+ private async Task WaitForCompletionAsync(Task task, CancellationToken cancellationToken)
+ {
+ await Task.WhenAny(task).WaitAsync(cancellationToken);
+ }
+
+ private void DisposeStartupCancellation()
+ {
+ _startupCts?.Dispose();
+ _startupTimeoutCts?.Dispose();
}
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs
new file mode 100644
index 000000000..214269477
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs
@@ -0,0 +1,351 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Http.Features;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Time.Testing;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.ConformanceTests;
+using ModelContextProtocol.Tests.Utils;
+using System.Net;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+public class ConformanceServerFixtureTests(ITestOutputHelper output) : LoggedTest(output)
+{
+ [Fact]
+ public async Task Initialize_ActualConformanceHosts_OwnDistinctReadyEndpoints()
+ {
+ await using var first = new ConformanceServerFixture();
+ await using var second = new ConformanceServerFixture();
+ await first.InitializeAsync(TestContext.Current.CancellationToken);
+ await second.InitializeAsync(TestContext.Current.CancellationToken);
+ Assert.NotEqual(first.ServerUrl, second.ServerUrl);
+
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout };
+ foreach (var fixture in new[] { first, second })
+ {
+ var address = new Uri(fixture.ServerUrl);
+ Assert.True(address.IsLoopback);
+ Assert.NotEqual(0, address.Port);
+ using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
+
+ await using var transport = new HttpClientTransport(new() { Endpoint = new Uri(fixture.StatelessServerUrl) });
+ await using var client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
+ Assert.NotEmpty(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_WaitsForStartupBeforePublishingUrl()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(true);
+ await release.Task.WaitAsync(cancellationToken);
+ })));
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ try
+ {
+ await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ Assert.False(startup.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+ finally
+ {
+ release.TrySetResult(true);
+ }
+ await startup;
+
+ using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout };
+ using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ }
+
+ [Fact]
+ public async Task Initialize_StartupFault_SurfacesOriginalError()
+ {
+ var expected = new InvalidOperationException("Synthetic host startup failure.");
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(
+ _ => new StartupService(_ => Task.FromException(expected))));
+
+ var actual = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask());
+ Assert.Same(expected, actual);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+
+ [Fact]
+ public async Task Initialize_HostStopsDuringStartup_DoesNotReportReady()
+ {
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(services =>
+ new StartupService(_ =>
+ {
+ services.GetRequiredService().StopApplication();
+ return Task.CompletedTask;
+ })));
+
+ var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask());
+ Assert.Contains("stopped before startup completed", error.Message);
+ Assert.Throws(() => fixture.ServerUrl);
+ }
+
+ [Fact]
+ public async Task Initialize_CallerCancellation_PropagatesToHostStartup()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(cancellationToken);
+ try
+ {
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ await observerRelease.Task;
+ cancelled.TrySetResult(true);
+ throw;
+ }
+ })));
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => startup);
+ Assert.True(startupToken.IsCancellationRequested);
+ Assert.False(cancelled.Task.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ observerRelease.TrySetResult(true);
+ Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ observerRelease.TrySetResult(true);
+ release.TrySetResult(true);
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_StartupTimeout_CancelsHostStartup()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken =>
+ {
+ entered.SetResult(cancellationToken);
+ try
+ {
+ await release.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ await observerRelease.Task;
+ cancelled.TrySetResult(true);
+ throw;
+ }
+ })));
+
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ try
+ {
+ var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ var error = await Assert.ThrowsAsync(() => startup);
+ Assert.Contains("30 seconds", error.Message);
+ Assert.True(startupToken.IsCancellationRequested);
+ Assert.False(cancelled.Task.IsCompleted);
+ Assert.Throws(() => fixture.ServerUrl);
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ observerRelease.TrySetResult(true);
+ Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken));
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ }
+ finally
+ {
+ observerRelease.TrySetResult(true);
+ release.TrySetResult(true);
+ }
+ }
+
+ [Fact]
+ public async Task Initialize_NoncooperativeStartup_IsBoundedAndOwnedUntilDrain()
+ {
+ var service = new NoncooperativeStartupService();
+ var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true));
+ var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask();
+ Task? disposal = null;
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ await service.CancellationObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ var error = await Assert.ThrowsAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+ Assert.Contains("ConformanceServer failed to start within 30 seconds", error.Message);
+
+ disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ Assert.False(service.Disposed);
+ service.Release.TrySetResult(true);
+ await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await Assert.ThrowsAsync(() => startup);
+ if (disposal is not null)
+ {
+ await disposal;
+ }
+ else
+ {
+ await fixture.DisposeAsync();
+ }
+ }
+ }
+
+ [Fact]
+ public async Task Dispose_NoncooperativeStartup_ReportsDeadlineAndRetainsOwnership()
+ {
+ var clock = new FakeTimeProvider();
+ var service = new NoncooperativeStartupService();
+ var fixture = new ConformanceServerFixture(
+ () => CreateApplication(_ => service, useNoSocketServer: true), clock);
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(disposal.IsCompleted);
+ clock.Advance(TestConstants.DefaultTimeout);
+ var error = await Assert.ThrowsAsync(() =>
+ disposal.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+ Assert.Contains("cleanup did not complete within 60 seconds", error.Message);
+ Assert.False(service.Disposed);
+
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ }
+ }
+
+ [Fact]
+ public async Task Dispose_LateStartupFault_IsReportedAfterSafeCleanup()
+ {
+ var expected = new InvalidOperationException("Synthetic late startup failure.");
+ var service = new NoncooperativeStartupService { Failure = expected };
+ var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true));
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ var startup = fixture.InitializeAsync(cancellation.Token).AsTask();
+ try
+ {
+ await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() =>
+ startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken));
+
+ var disposal = fixture.DisposeAsync().AsTask();
+ Assert.False(service.Disposed);
+ service.Release.TrySetResult(true);
+ var actual = await Assert.ThrowsAsync(() => disposal);
+ Assert.Same(expected, actual);
+ Assert.True(service.Disposed);
+ Assert.False(service.DisposedDuringStartup);
+ }
+ finally
+ {
+ service.Release.TrySetResult(true);
+ await fixture.DisposeAsync();
+ }
+ }
+
+ private WebApplication CreateApplication(Func serviceFactory, bool useNoSocketServer = false)
+ {
+ var builder = WebApplication.CreateBuilder(["--urls", "http://127.0.0.1:0"]);
+ builder.Logging.ClearProviders();
+ builder.Logging.AddProvider(XunitLoggerProvider);
+ if (useNoSocketServer)
+ {
+ builder.Services.AddSingleton();
+ }
+ builder.Services.AddSingleton(serviceFactory);
+ var app = builder.Build();
+ app.MapGet("/health", () => "Healthy");
+ return app;
+ }
+
+ private sealed class StartupService(Func start) : IHostedService
+ {
+ public Task StartAsync(CancellationToken cancellationToken) => start(cancellationToken);
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private sealed class NoncooperativeStartupService : IHostedService, IDisposable
+ {
+ public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public bool StartupCompleted { get; private set; }
+ public bool Disposed { get; private set; }
+ public bool DisposedDuringStartup { get; private set; }
+ public Exception? Failure { get; init; }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ using var registration = cancellationToken.Register(() => CancellationObserved.TrySetResult(true));
+ Entered.TrySetResult(true);
+ await Release.Task;
+ StartupCompleted = true;
+ if (Failure is { } error)
+ {
+ throw error;
+ }
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public void Dispose()
+ {
+ DisposedDuringStartup = !StartupCompleted;
+ Disposed = true;
+ }
+ }
+
+ private sealed class NoSocketServer : IServer
+ {
+ public IFeatureCollection Features { get; } = new FeatureCollection();
+ public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask;
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public void Dispose() { }
+ }
+}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
index 2cb1d736e..a47332a0d 100644
--- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs
@@ -15,6 +15,28 @@ public class ServerConformanceTests(
ConformanceServerFixture fixture,
ITestOutputHelper output)
{
+ [Theory]
+ [InlineData("sep-2640-skills-enumeration", "2026-07-28")]
+ [InlineData("sep-2640-skills-manifest", "2026-07-28")]
+ [InlineData("sep-2640-skills-directory", "2026-07-28")]
+ [InlineData("sep-2640-skills-enumeration", "2025-11-25")]
+ [InlineData("sep-2640-skills-manifest", "2025-11-25")]
+ [InlineData("sep-2640-skills-directory", "2025-11-25")]
+ public async Task RunSkillsConformanceTest(string scenario, string protocolVersion)
+ {
+ Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests.");
+ var url = protocolVersion == "2026-07-28" ? fixture.StatelessServerUrl : fixture.ServerUrl;
+
+ var result = await RunConformanceTestsAsync(
+ $"server --url {url} --scenario {scenario} --spec-version {protocolVersion} --force",
+ appendProtocolVersionFromEnv: false);
+
+ Assert.True(result.Success,
+ $"Skills scenario '{scenario}' for '{protocolVersion}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
+ Assert.DoesNotContain("SKIPPED: scenario", result.Output);
+ Assert.Matches(@"Passed:\s*[1-9]\d*/[1-9]\d*,\s*0\s*failed", result.Output);
+ }
+
[Fact]
public async Task RunConformanceTests()
{
@@ -152,11 +174,13 @@ public async Task RunTasksExtensionConformanceTest(string scenario)
$"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}");
}
- private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments)
+ private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(
+ string arguments, bool appendProtocolVersionFromEnv = true)
{
return await NodeHelpers.RunServerConformanceAsync(
arguments,
line => { try { output.WriteLine(line); } catch { } },
+ appendProtocolVersionFromEnv: appendProtocolVersionFromEnv,
cancellationToken: TestContext.Current.CancellationToken);
}
diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs
new file mode 100644
index 000000000..3f93b2821
--- /dev/null
+++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs
@@ -0,0 +1,151 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.AspNetCore.Tests.Utils;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Security.Claims;
+
+namespace ModelContextProtocol.AspNetCore.Tests;
+
+public class SkillsAuthorizationTests(ITestOutputHelper output) : KestrelInMemoryTest(output)
+{
+ private const string SkillUri = "skill://private/report/SKILL.md";
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed)
+ {
+ await using var app = await StartServer(allowed);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await McpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
+ if (allowed)
+ {
+ Assert.Equal(SkillUri, Assert.Single(skills).Uri);
+ Assert.Equal(SkillUri, Assert.Single(resources).Uri);
+ var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken);
+ var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken);
+ Assert.Single(content.Contents);
+ }
+ else
+ {
+ Assert.Empty(skills);
+ Assert.Empty(resources);
+ var metadataError = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken));
+ Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode);
+ var resourceError = await Assert.ThrowsAsync(
+ async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken));
+ Assert.Equal(McpErrorCode.InvalidRequest, resourceError.ErrorCode);
+ }
+ }
+
+ [Fact]
+ public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead()
+ {
+ await using var app = await StartServer(allowed: true, hideResourceListing: true);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await McpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken));
+ var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken);
+ Assert.Equal(SkillUri, skill.Uri);
+ Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents);
+ }
+
+ [Fact]
+ public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess()
+ {
+ await using var app = await StartServer(allowed: true, authenticated: false);
+ await using var transport = new HttpClientTransport(
+ new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory);
+ await using var client = await McpClient.CreateAsync(
+ transport, cancellationToken: TestContext.Current.CancellationToken);
+
+ Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken));
+ }
+
+ private async Task StartServer(
+ bool allowed, bool hideResourceListing = false, bool authenticated = true)
+ {
+ var skill = McpServerSkill.Create(SkillUri,
+ [
+ McpServerSkillFile.FromText("SKILL.md", "---\nname: report\ndescription: Private report\n---\n"),
+ ]);
+ var policy = new ReadPolicy();
+ var catalog = new AuthorizedCatalog(new InMemoryMcpSkillCatalog([skill.ProtocolSkill]), policy);
+ Builder.Services.AddMcpServer()
+ .WithHttpTransport(options => options.Stateless = true)
+ .WithSkills(catalog)
+ .WithResources(skill.Resources)
+ .WithRequestFilters(filters =>
+ {
+ filters.AddListResourcesFilter(next => async (context, cancellationToken) =>
+ {
+ var result = await next(context, cancellationToken);
+ result.Resources = result.Resources
+ .Where(resource => !hideResourceListing && policy.CanRead(context.User, resource.Uri))
+ .ToList();
+ return result;
+ });
+ filters.AddReadResourceFilter(next => (context, cancellationToken) =>
+ {
+ if (!policy.CanRead(context.User, context.Params.Uri))
+ {
+ throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest);
+ }
+ return next(context, cancellationToken);
+ });
+ });
+
+ var app = Builder.Build();
+ app.Use(async (context, next) =>
+ {
+ context.User = new ClaimsPrincipal(new ClaimsIdentity(
+ [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], authenticated ? "Test" : null));
+ await next(context);
+ });
+ app.MapMcp();
+ await app.StartAsync(TestContext.Current.CancellationToken);
+ return app;
+ }
+
+ private sealed class ReadPolicy
+ {
+ public bool CanRead(ClaimsPrincipal? user, string uri) =>
+ uri.StartsWith("skill://private/", StringComparison.Ordinal) &&
+ (user?.Identity?.IsAuthenticated).GetValueOrDefault() &&
+ (user?.IsInRole("reader")).GetValueOrDefault();
+ }
+
+ private sealed class AuthorizedCatalog(InMemoryMcpSkillCatalog catalog, ReadPolicy policy) : IMcpSkillCatalog
+ {
+ public async ValueTask ListAsync(
+ string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ var page = await catalog.ListAsync(cursor, context, cancellationToken);
+ return new McpSkillPage
+ {
+ Skills = page.Skills.Where(skill => policy.CanRead(context.User, skill.Uri)).ToArray(),
+ NextCursor = page.NextCursor,
+ };
+ }
+
+ public ValueTask GetAsync(
+ string uri, McpSkillRequestContext context, CancellationToken cancellationToken) =>
+ policy.CanRead(context.User, uri)
+ ? catalog.GetAsync(uri, context, cancellationToken)
+ : new ValueTask((Skill?)null);
+ }
+}
diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
index dffffa9d3..7f2ca8212 100644
--- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
+++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj
@@ -15,6 +15,7 @@
+
diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs
index 73f63821e..568644262 100644
--- a/tests/ModelContextProtocol.ConformanceServer/Program.cs
+++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs
@@ -1,8 +1,10 @@
using ConformanceServer.Prompts;
using ConformanceServer.Resources;
using ConformanceServer.Tools;
+using ModelContextProtocol.ConformanceServer.Skills;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
+using ModelContextProtocol.Extensions.Skills;
using ModelContextProtocol.Extensions.Tasks;
using System.Collections.Concurrent;
using System.Diagnostics;
@@ -13,6 +15,12 @@ namespace ModelContextProtocol.ConformanceServer;
public class Program
{
public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default)
+ {
+ var app = new Program().CreateApplication(args, loggerProvider);
+ await app.RunAsync(cancellationToken);
+ }
+
+ public WebApplication CreateApplication(string[] args, ILoggerProvider? loggerProvider = null)
{
var builder = WebApplication.CreateBuilder(args);
@@ -37,7 +45,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide
app.MapGet("/health", () => "Healthy");
- await app.RunAsync(cancellationToken);
+ return app;
}
// Registers the conformance MCP server (tools, prompts, resources, filters, and handlers)
@@ -53,6 +61,12 @@ private static void ConfigureConformanceMcpServer(
.AddMcpServer()
.WithHttpTransport(options => options.Stateless = stateless)
.WithDistributedCacheEventStreamStore()
+ .WithSkills(ConformanceSkills.CreateCatalog(), options =>
+ {
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ })
+ .WithResources(ConformanceSkills.CreateResources())
.WithTasks(
new InMemoryMcpTaskStore
{
diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs
new file mode 100644
index 000000000..81fe15827
--- /dev/null
+++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs
@@ -0,0 +1,97 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.ConformanceServer.Skills;
+
+///
+/// Builds the SEP-2640 skill fixture the conformance scenarios run against.
+///
+///
+///
+/// The three static skills are authored through , so their frontmatter is read from
+/// their SKILL.md and their manifests are computed from the same bytes their resources serve. A fourth,
+/// deliberately unenumerable skill is added to the catalog by hand: it is served and answerable through
+/// skills/get, but carries no digests and so cannot be content-bound.
+///
+///
+/// The page size is deliberately small so the scenarios exercise cursor pagination.
+///
+///
+public static class ConformanceSkills
+{
+ private const string GeneratedReportUri = "skill://generated-report/SKILL.md";
+
+ private static readonly McpServerSkill[] s_skills =
+ [
+ McpServerSkill.Create(
+ "skill://git-workflow/SKILL.md",
+ [
+ SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"),
+ ]),
+
+ McpServerSkill.Create(
+ "skill://pdf-processing/SKILL.md",
+ [
+ SkillFile("pdf-processing", "Extract, fill, and assemble PDF documents", "# PDF processing\n"),
+ McpServerSkillFile.FromText("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"),
+ McpServerSkillFile.FromText("scripts/extract.py", "import sys\n\nprint('extract')\n"),
+ McpServerSkillFile.FromText("templates/invoice.md", "# Invoice\n"),
+ McpServerSkillFile.FromText("templates/regional/eu-invoice.md", "# EU Invoice\n"),
+ ]),
+
+ McpServerSkill.Create(
+ "skill://acme/billing/refunds/SKILL.md",
+ [
+ SkillFile("refunds", "Process customer refund requests per company policy", "# Refunds\n"),
+ McpServerSkillFile.FromText("examples/email.md", "Subject: Your refund\n"),
+ ]),
+ ];
+
+ private static readonly Skill s_generatedReport = new()
+ {
+ Uri = GeneratedReportUri,
+ Frontmatter = Frontmatter("generated-report", "Assemble a report from live data"),
+ Resources = SkillResources.Dynamic,
+ };
+
+ ///
+ /// Creates the catalog backing skills/list and skills/get.
+ ///
+ public static IMcpSkillCatalog CreateCatalog() =>
+ new InMemoryMcpSkillCatalog([.. s_skills.Select(s => s.ProtocolSkill), s_generatedReport], pageSize: 2);
+
+ ///
+ /// Creates the resources serving every skill file, so the files are enumerable through resources/list
+ /// and readable through resources/read.
+ ///
+ public static IEnumerable CreateResources()
+ {
+ foreach (var skill in s_skills)
+ {
+ foreach (var resource in skill.Resources)
+ {
+ yield return resource;
+ }
+ }
+
+ yield return McpServerResource.Create(
+ () => $"---\nname: generated-report\ndescription: Assemble a report from live data\n---\n\n# Generated report\n\nGenerated at {DateTimeOffset.UtcNow:O}.\n",
+ new McpServerResourceCreateOptions
+ {
+ UriTemplate = GeneratedReportUri,
+ Name = "generated-report",
+ Description = "Assemble a report from live data",
+ MimeType = "text/markdown",
+ });
+ }
+
+ private static JsonObject Frontmatter(string name, string description) => new()
+ {
+ ["name"] = name,
+ ["description"] = description,
+ };
+
+ private static McpServerSkillFile SkillFile(string name, string description, string body) =>
+ McpServerSkillFile.FromText(SkillsProtocol.SkillFileName, $"---\nname: {name}\ndescription: {description}\n---\n\n{body}");
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs
new file mode 100644
index 000000000..23f82add5
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs
@@ -0,0 +1,493 @@
+using ModelContextProtocol.Authentication;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Tests.Utils;
+using System.IO.Pipelines;
+using System.Net;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Text.Json;
+
+namespace ModelContextProtocol.Tests.Client;
+
+public class DiscoveryAuthorizationTests(ITestOutputHelper output) : LoggedTest(output)
+{
+ [Fact]
+ public async Task Discover_AuthorizesOncePerClient()
+ {
+ using var server = new AuthorizationServer();
+ await ConnectTwiceAsync(server);
+
+ Assert.Equal(2, server.AuthorizationStates.Count);
+ Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(2, server.AuthorizedDiscoverRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData(HttpTransportMode.AutoDetect)]
+ [InlineData(HttpTransportMode.StreamableHttp)]
+ public async Task Discover_MetadataHttpTimeout_TriesNextEndpoint(HttpTransportMode transportMode)
+ {
+ using var gate = new AuthorizationGate();
+ using var server = new AuthorizationServer { Gate = gate, GatedStage = "metadata" };
+ using var httpClient = new HttpClient(server) { Timeout = TimeSpan.FromMilliseconds(400) };
+ using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ caller.CancelAfter(TimeSpan.FromSeconds(8));
+ await using var transport = CreateTransport(server, httpClient, transportMode);
+ var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(3) };
+
+ await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token);
+
+ Assert.False(caller.IsCancellationRequested);
+ Assert.Equal(1, gate.Cancellations);
+ Assert.Equal(2, server.MetadataRequests);
+ Assert.Single(server.AuthorizationStates);
+ Assert.Equal(1, server.AuthorizedDiscoverRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData("large", false)]
+ [InlineData("maximum", false)]
+ [InlineData("infinite", false)]
+ [InlineData("large", true)]
+ [InlineData("maximum", true)]
+ [InlineData("infinite", true)]
+ public async Task Discover_AcceptsFullProbeTimeoutRange(string probeKind, bool infiniteInitialization)
+ {
+ using var server = new AuthorizationServer();
+ using var httpClient = new HttpClient(server);
+ using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ caller.CancelAfter(TestConstants.DefaultTimeout);
+ await using var transport = CreateTransport(server, httpClient);
+ var options = new McpClientOptions
+ {
+ DiscoverProbeTimeout = GetProbeTimeout(probeKind),
+ InitializationTimeout = infiniteInitialization ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(3),
+ };
+
+ await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token);
+
+ Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion);
+ Assert.Equal(1, server.AuthorizedDiscoverRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData("large")]
+ [InlineData("maximum")]
+ [InlineData("infinite")]
+ public async Task Discover_LargeOrInfiniteProbe_StillHonorsInitializationTimeout(string probeKind)
+ {
+ using var server = new AuthorizationServer { SilentBeforeAuthorization = true };
+ using var httpClient = new HttpClient(server);
+ await using var transport = CreateTransport(server, httpClient);
+ var options = new McpClientOptions
+ {
+ DiscoverProbeTimeout = GetProbeTimeout(probeKind),
+ InitializationTimeout = TimeSpan.FromMilliseconds(250),
+ };
+
+ await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, options, LoggerFactory, TestContext.Current.CancellationToken));
+
+ Assert.Equal(1, server.SilentProbeCancellations);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ private TimeSpan GetProbeTimeout(string probeKind) => probeKind switch
+ {
+ "large" => TimeSpan.FromDays(60),
+ "maximum" => TimeSpan.MaxValue,
+ "infinite" => Timeout.InfiniteTimeSpan,
+ _ => throw new ArgumentOutOfRangeException(nameof(probeKind)),
+ };
+
+ [Theory]
+ [InlineData("callback")]
+ [InlineData("metadata")]
+ public async Task Discover_AuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage)
+ {
+ using var gate = new AuthorizationGate();
+ using var server = new AuthorizationServer { Gate = gate, GatedStage = stage };
+ var connection = ConnectTwiceAsync(server);
+ try
+ {
+ await WaitForAuthorizationAsync(gate, connection);
+ // Keep the first authorization pending for longer than the configured probe.
+ // This timer exercises the deadline; the gate controls when authorization can complete.
+ await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout);
+ }
+ finally
+ {
+ gate.Release.TrySetResult(true);
+ }
+ await connection;
+
+ Assert.Equal(0, gate.Cancellations);
+ Assert.Equal(2, server.AuthorizationStates.Count);
+ Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(2, server.AuthorizedDiscoverRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData("callback")]
+ [InlineData("metadata")]
+ public async Task Discover_CallerCancellationDuringAuthorization_Propagates(string stage)
+ {
+ using var gate = new AuthorizationGate();
+ using var server = new AuthorizationServer { Gate = gate, GatedStage = stage };
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ using var httpClient = new HttpClient(server);
+ await using var transport = CreateTransport(server, httpClient);
+
+ var connection = McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: cancellation.Token);
+ await WaitForAuthorizationAsync(gate, connection);
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(() => connection);
+ Assert.Equal(1, gate.Cancellations);
+ Assert.Equal(1, server.MetadataRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData("callback")]
+ [InlineData("metadata")]
+ public async Task Discover_InitializationTimeoutDuringAuthorization_Propagates(string stage)
+ {
+ using var gate = new AuthorizationGate();
+ using var server = new AuthorizationServer { Gate = gate, GatedStage = stage };
+ using var httpClient = new HttpClient(server);
+ await using var transport = CreateTransport(server, httpClient);
+ var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(1) };
+
+ await Assert.ThrowsAsync(() => McpClient.CreateAsync(
+ transport, options, LoggerFactory, TestContext.Current.CancellationToken));
+
+ Assert.True(gate.Entered.Task.IsCompleted);
+ Assert.Equal(1, gate.Cancellations);
+ Assert.Equal(1, server.MetadataRequests);
+ Assert.Equal(0, server.InitializeRequests);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Discover_SilentServer_StillFallsBackWithinProbeBudget(bool afterAuthorization)
+ {
+ using var server = new AuthorizationServer { SilentBeforeAuthorization = !afterAuthorization, SilentAfterAuthorization = afterAuthorization };
+ using var httpClient = new HttpClient(server);
+ await using var transport = CreateTransport(server, httpClient);
+ var options = new McpClientOptions { DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250) };
+
+ await using var client = await McpClient.CreateAsync(
+ transport, options, LoggerFactory, TestContext.Current.CancellationToken);
+
+ Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion);
+ Assert.Equal(1, server.InitializeRequests);
+ Assert.Single(server.AuthorizationStates);
+ Assert.Equal(1, server.SilentProbeCancellations);
+ }
+
+ [Theory]
+ [InlineData("callback")]
+ [InlineData("metadata")]
+ public async Task Discover_SseAuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage)
+ {
+ using var gate = new AuthorizationGate();
+ using var server = new AuthorizationServer { Gate = gate, GatedStage = stage, UseLegacySse = true };
+ var connection = ConnectTwiceAsync(server);
+ try
+ {
+ await WaitForAuthorizationAsync(gate, connection);
+ await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout);
+ }
+ finally
+ {
+ gate.Release.TrySetResult(true);
+ }
+ await connection;
+
+ Assert.Equal(0, gate.Cancellations);
+ Assert.Equal(2, server.AuthorizationStates.Count);
+ Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count());
+ Assert.Equal(2, server.AuthorizedDiscoverRequests);
+ Assert.Equal(2, server.InitializeRequests);
+ }
+
+ private async Task WaitForAuthorizationAsync(AuthorizationGate gate, Task connection)
+ {
+ await Task.WhenAny(gate.Entered.Task, connection).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ if (connection.IsCompleted)
+ {
+ await connection;
+ }
+ Assert.True(gate.Entered.Task.IsCompleted, "The connection completed without entering authorization.");
+ }
+
+ private async Task ConnectTwiceAsync(AuthorizationServer server)
+ {
+ using var httpClient = new HttpClient(server) { Timeout = TestConstants.DefaultTimeout };
+ for (int connection = 0; connection < 2; connection++)
+ {
+ await using var transport = CreateTransport(server, httpClient);
+ await using var client = await McpClient.CreateAsync(
+ transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken);
+ }
+ }
+
+ private HttpClientTransport CreateTransport(
+ AuthorizationServer server, HttpClient httpClient, HttpTransportMode transportMode = HttpTransportMode.AutoDetect)
+ {
+ return new HttpClientTransport(new()
+ {
+ Endpoint = new Uri("http://localhost/mcp"),
+ TransportMode = transportMode,
+ OAuth = new()
+ {
+ ClientId = "synthetic-client",
+ RedirectUri = new Uri("http://localhost/callback"),
+ AuthorizationCallbackHandler = server.AuthorizeAsync,
+ },
+ }, httpClient, LoggerFactory);
+ }
+
+ private sealed class AuthorizationGate : IDisposable
+ {
+ public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ public int Cancellations { get; private set; }
+
+ public async Task WaitAsync(CancellationToken cancellationToken)
+ {
+ if (!Entered.TrySetResult(true))
+ {
+ return;
+ }
+
+ try
+ {
+ await Release.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ Cancellations++;
+ throw;
+ }
+ }
+
+ public async Task HoldPastProbeAsync(TimeSpan probeTimeout)
+ {
+ var elapsed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ using var timer = new Timer(_ => elapsed.TrySetResult(true), null,
+ probeTimeout + TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan);
+ await elapsed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+ }
+
+ public void Dispose() => Release.TrySetResult(true);
+ }
+
+ private sealed class AuthorizationServer : HttpMessageHandler
+ {
+ private readonly List _ssePipes = [];
+ private Pipe? _ssePipe;
+
+ public AuthorizationGate? Gate { get; init; }
+ public string? GatedStage { get; init; }
+ public bool UseLegacySse { get; init; }
+ public bool SilentBeforeAuthorization { get; init; }
+ public bool SilentAfterAuthorization { get; init; }
+ public List AuthorizationStates { get; } = [];
+ public int MetadataRequests { get; private set; }
+ public int AuthorizedDiscoverRequests { get; private set; }
+ public int InitializeRequests { get; private set; }
+ public int SilentProbeCancellations { get; private set; }
+
+ public async Task AuthorizeAsync(AuthorizationCallbackContext context, CancellationToken cancellationToken)
+ {
+ var stateParameter = context.AuthorizationUri.Query.TrimStart('?').Split('&')
+ .Single(parameter => parameter.StartsWith("state=", StringComparison.Ordinal));
+ var state = Uri.UnescapeDataString(stateParameter.Substring("state=".Length));
+ AuthorizationStates.Add(state);
+ if (GatedStage == "callback")
+ {
+ await Assert.IsType(Gate).WaitAsync(cancellationToken);
+ }
+ return new AuthorizationResult { Code = "synthetic-code", State = state };
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var uri = Assert.IsType(request.RequestUri);
+ Assert.True(uri.IsLoopback);
+ switch (uri.AbsolutePath)
+ {
+ case "/.well-known/oauth-protected-resource":
+ return JsonResponse("""{"resource":"http://localhost/mcp","authorization_servers":["http://localhost"]}""");
+
+ case "/.well-known/oauth-authorization-server":
+ case "/.well-known/openid-configuration":
+ MetadataRequests++;
+ if (GatedStage == "metadata")
+ {
+ await Assert.IsType(Gate).WaitAsync(cancellationToken);
+ }
+ return JsonResponse("""
+ {"issuer":"http://localhost","authorization_endpoint":"http://localhost/authorize",
+ "token_endpoint":"http://localhost/token","code_challenge_methods_supported":["S256"]}
+ """);
+
+ case "/token":
+ return JsonResponse("""{"access_token":"synthetic-token","token_type":"Bearer","expires_in":300}""");
+
+ case "/mcp":
+ case "/messages":
+ if (UseLegacySse &&
+ uri.AbsolutePath == "/mcp" &&
+ request.Method == HttpMethod.Post)
+ {
+ return EmptyResponse(HttpStatusCode.MethodNotAllowed);
+ }
+
+ if (UseLegacySse &&
+ request.Method == HttpMethod.Get &&
+ request.Headers.Authorization is not null)
+ {
+ _ssePipe = new Pipe();
+ _ssePipes.Add(_ssePipe);
+ await _ssePipe.Writer.WriteAsync(Encoding.UTF8.GetBytes("event: endpoint\ndata: /messages\n\n"), cancellationToken);
+ return new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(_ssePipe.Reader.AsStream())
+ {
+ Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") },
+ },
+ };
+ }
+
+ JsonRpcMessage? message = null;
+ if (request.Content is { } content)
+ {
+ var body = await content.ReadAsStringAsync(cancellationToken);
+ message = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions);
+ }
+
+ if (SilentBeforeAuthorization &&
+ message is JsonRpcRequest { Method: RequestMethods.ServerDiscover })
+ {
+ await WaitForProbeCancellationAsync(cancellationToken);
+ }
+
+ if (request.Headers.Authorization is null)
+ {
+ var challenge = EmptyResponse(HttpStatusCode.Unauthorized);
+ challenge.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\""));
+ return challenge;
+ }
+
+ if (message is JsonRpcNotification)
+ {
+ return EmptyResponse(HttpStatusCode.Accepted);
+ }
+ var rpc = Assert.IsType(message);
+ switch (rpc.Method)
+ {
+ case RequestMethods.ServerDiscover:
+ AuthorizedDiscoverRequests++;
+ if (SilentAfterAuthorization)
+ {
+ await WaitForProbeCancellationAsync(cancellationToken);
+ }
+ if (UseLegacySse)
+ {
+ return await ReplyAsync(new JsonRpcError
+ {
+ Id = rpc.Id,
+ Error = new() { Code = -32601, Message = "Method not found" },
+ }, cancellationToken);
+ }
+ return await ReplyAsync(RpcResponse(rpc, new DiscoverResult
+ {
+ SupportedVersions = ["2026-07-28"],
+ Capabilities = new(),
+ }), cancellationToken);
+
+ case RequestMethods.Initialize:
+ InitializeRequests++;
+ return await ReplyAsync(RpcResponse(rpc, new InitializeResult
+ {
+ ProtocolVersion = "2025-11-25",
+ ServerInfo = new() { Name = "synthetic-server", Version = "1.0" },
+ Capabilities = new(),
+ }), cancellationToken);
+ }
+ throw new InvalidOperationException($"Unexpected RPC: {rpc.Method}");
+
+ default:
+ throw new InvalidOperationException($"Unexpected synthetic request: {request.Method} {uri}");
+ }
+ }
+
+ private async Task WaitForProbeCancellationAsync(CancellationToken cancellationToken)
+ {
+ var noResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ try
+ {
+ await noResponse.Task.WaitAsync(cancellationToken);
+ }
+ catch (OperationCanceledException)
+ {
+ SilentProbeCancellations++;
+ throw;
+ }
+ throw new InvalidOperationException("A silent probe must be cancelled, not completed.");
+ }
+
+ private JsonRpcResponse RpcResponse(JsonRpcRequest request, TResult result)
+ {
+ return new JsonRpcResponse
+ {
+ Id = request.Id,
+ Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions),
+ };
+ }
+
+ private async Task ReplyAsync(JsonRpcMessage response, CancellationToken cancellationToken)
+ {
+ var json = JsonSerializer.Serialize(response, McpJsonUtilities.DefaultOptions);
+ if (UseLegacySse)
+ {
+ var pipe = Assert.IsType(_ssePipe);
+ await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"event: message\ndata: {json}\n\n"), cancellationToken);
+ return EmptyResponse(HttpStatusCode.Accepted);
+ }
+ return JsonResponse(json);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ foreach (var pipe in _ssePipes)
+ {
+ pipe.Writer.Complete();
+ }
+ _ssePipes.Clear();
+ }
+ base.Dispose(disposing);
+ }
+
+ private HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, Encoding.UTF8, "application/json"),
+ };
+
+ private HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status)
+ {
+ Content = new StringContent(string.Empty),
+ };
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs
new file mode 100644
index 000000000..6501275c5
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs
@@ -0,0 +1,288 @@
+#pragma warning disable MCPEXP002 // Raw request handlers are the point of this test.
+
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Client;
+
+///
+/// The client extensions validate every entry a server returns against the specification's structural
+/// requirements, since hosts must not load invalid entries. These tests stand up a server whose raw
+/// skills/list and skills/get handlers return entries the SDK's own server side would never publish.
+///
+public class McpSkillsClientValidationTests : ClientServerTestBase
+{
+ private JsonObject? _listResponse;
+
+ public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ services.Configure(options =>
+ {
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= [];
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Handler = (request, _) =>
+ {
+ if (_listResponse is not null)
+ {
+ return new ValueTask(_listResponse.DeepClone());
+ }
+
+ var json = request.Params?["cursor"]?.GetValue() switch
+ {
+ "no-skills-array" => """{ "skills": null }""",
+ "missing-skills-array" => "{}",
+ "empty-skills-array" => """{ "skills": [] }""",
+ _ => """
+ { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" },
+ "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] },
+ { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" },
+ "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] }
+ """,
+ };
+ return new ValueTask(JsonNode.Parse(json));
+ },
+ });
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsGet,
+ Handler = (request, _) =>
+ {
+ // Any request other than the escaping skill is answered with a valid entry for a different URI.
+ string? requested = request.Params?["uri"]?.GetValue();
+ string json = requested == "skill://escape/SKILL.md"
+ ? """
+ { "skill": { "uri": "skill://escape/SKILL.md", "frontmatter": { "name": "escape", "description": "d" },
+ "resources": [ { "uri": "skill://escape/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 },
+ { "uri": "skill://escape/../secret.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } }
+ """
+ : """
+ { "skill": { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" },
+ "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } }
+ """;
+ return new ValueTask(JsonNode.Parse(json));
+ },
+ });
+ });
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_RejectsAnInvalidEntry()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("skills/list", exception.Message);
+ Assert.Contains("skill://bad/SKILL.md", exception.Message);
+ }
+
+ [Theory]
+ [InlineData("no-skills-array")]
+ [InlineData("missing-skills-array")]
+ public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string cursor)
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_AcceptsAnEmptySkillsArray()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Skills);
+ }
+
+ [Theory]
+ [InlineData("\U00010428")]
+ [InlineData("\U00020000")]
+ public async Task ListSkillsAsync_AcceptsSupplementaryUnicodeNames(string name)
+ {
+ _listResponse = CreateListResponse(name);
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(name, Assert.Single(result).Name);
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_RejectsTitlecaseNames()
+ {
+ _listResponse = CreateListResponse("\u01c5");
+ await using McpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData("1")]
+ [InlineData("true")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("null")]
+ public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value)
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) });
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("metadata", exception.Message);
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionFields()
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" });
+ var frontmatter = _listResponse["skills"]![0]!["frontmatter"]!.AsObject();
+ frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) };
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter));
+ }
+
+ [Fact]
+ public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne()
+ {
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Equal("good", Assert.Single(result.Page.Skills).Name);
+ var rejected = Assert.Single(result.RejectedEntries);
+ Assert.Equal(1, rejected.Index);
+ Assert.IsType(rejected.Error);
+ }
+
+ [Fact]
+ public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints()
+ {
+ _listResponse = CreateListResponse("alpha");
+ var entries = _listResponse["skills"]!.AsArray();
+ entries.Add(null);
+ entries.Add(JsonNode.Parse("42"));
+ entries.Add(new JsonObject());
+ _listResponse["nextCursor"] = "next-page";
+ _listResponse["ttlMs"] = 3000;
+ _listResponse["cacheScope"] = "public";
+ _listResponse["resultType"] = "complete";
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Single(result.Page.Skills);
+ Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index));
+ Assert.Equal("next-page", result.Page.NextCursor);
+ Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive);
+ Assert.Equal(CacheScope.Public, result.Page.CacheScope);
+ Assert.Equal("complete", result.Page.ResultType);
+ Assert.Equal(4, entries.Count);
+ }
+
+ [Theory]
+ [InlineData("no-skills-array")]
+ [InlineData("missing-skills-array")]
+ public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor)
+ {
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task DiagnosticListingReportsAnAllInvalidPageRatherThanHidingItsFailure()
+ {
+ _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = 1 });
+ _listResponse["nextCursor"] = "next-page";
+ await using var client = await CreateMcpClientForServer();
+
+ var result = await client.ListSkillsWithDiagnosticsAsync(
+ new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+
+ Assert.Empty(result.Page.Skills);
+ Assert.Contains("metadata", Assert.Single(result.RejectedEntries).Error.Message);
+ Assert.Equal("next-page", result.Page.NextCursor);
+ }
+
+ [Fact]
+ public async Task GetSkillAsync_RejectsAValidEntryForADifferentUri()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ // The entry itself is valid; it is just not the skill that was asked for.
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync("skill://other/SKILL.md", TestContext.Current.CancellationToken));
+
+ Assert.Contains("skill://other/SKILL.md", exception.Message);
+ Assert.Contains("skill://good/SKILL.md", exception.Message);
+
+ // Asking for the URI the server actually returns succeeds.
+ var good = await client.GetSkillAsync("skill://good/SKILL.md", TestContext.Current.CancellationToken);
+ Assert.Equal("good", good.Name);
+ }
+
+ [Fact]
+ public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync("skill://escape/SKILL.md", TestContext.Current.CancellationToken));
+
+ Assert.Contains("skills/get", exception.Message);
+ Assert.Contains("'..'", exception.Message);
+ }
+
+ private JsonObject CreateListResponse(string name, JsonObject? metadata = null)
+ {
+ var uri = $"skill://catalog/{name}/SKILL.md";
+ var frontmatter = new JsonObject { ["name"] = name, ["description"] = "d" };
+ if (metadata is not null)
+ {
+ frontmatter["metadata"] = metadata;
+ }
+
+ return new JsonObject
+ {
+ ["skills"] = new JsonArray(new JsonObject
+ {
+ ["uri"] = uri,
+ ["frontmatter"] = frontmatter,
+ ["resources"] = new JsonArray(new JsonObject
+ {
+ ["uri"] = uri,
+ ["digest"] = "sha256:" + new string('a', 64),
+ ["size"] = 1,
+ }),
+ }),
+ };
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs
new file mode 100644
index 000000000..3bdfa0c33
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs
@@ -0,0 +1,190 @@
+#pragma warning disable MCPEXP002
+
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Client;
+
+public class McpSkillsEnumerationTests : ClientServerTestBase
+{
+ private Func? _page;
+ private readonly List _cursors = [];
+
+ public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder)
+ {
+ services.Configure(options =>
+ {
+ options.Capabilities ??= new ServerCapabilities();
+ options.Capabilities.Extensions ??= new Dictionary();
+ options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject();
+ options.RequestHandlers ??= [];
+ options.RequestHandlers.Add(new McpServerRequestHandler
+ {
+ Method = SkillsProtocol.MethodSkillsList,
+ Handler = (request, _) =>
+ {
+ _cursors.Add(request.Params?["cursor"]?.GetValue());
+ return new ValueTask(_page!(_cursors.Count));
+ },
+ });
+ });
+ }
+
+ [Theory]
+ [InlineData("repeat")]
+ [InlineData("")]
+ public async Task RepeatedCursorFailsInsteadOfLooping(string cursor)
+ {
+ _page = number => Page(cursor, $"skill-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("repeated", error.Message);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent()
+ {
+ _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Equal(3, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing()
+ {
+ _page = number => number == 1 ? Page("", "first") : Page(null, "second");
+ await using var client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name));
+ Assert.Equal(2, _cursors.Count);
+ Assert.Null(_cursors[0]);
+ Assert.Equal("", _cursors[1]);
+ }
+
+ [Fact]
+ public async Task EverChangingCursorsStopAtTheRequestedPageLimit()
+ {
+ _page = number => Page($"cursor-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken));
+
+ Assert.Contains("3 pages", error.Message);
+ Assert.Equal(3, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task DefaultPageLimitIsOneHundred()
+ {
+ _page = number => Page($"cursor-{number}");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+
+ Assert.Contains("100 pages", error.Message);
+ Assert.Equal(100, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task EntryLimitFailsWithoutReturningATruncatedList()
+ {
+ _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d");
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken));
+
+ Assert.Contains("3 entries", error.Message);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task LastPageMayExactlyReachBothLimits()
+ {
+ _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c");
+ await using var client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, skills.Count);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Fact]
+ public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly()
+ {
+ _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray());
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken));
+ Assert.Contains("1000 entries", error.Message);
+
+ var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken);
+ Assert.Equal(1001, skills.Count);
+ Assert.Equal(2, _cursors.Count);
+ }
+
+ [Theory]
+ [InlineData(0, 1)]
+ [InlineData(-1, 1)]
+ [InlineData(1, 0)]
+ [InlineData(1, -1)]
+ public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills)
+ {
+ _page = _ => Page(null);
+ await using var client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken));
+ Assert.Empty(_cursors);
+ }
+
+ [Fact]
+ public async Task CancellationStopsBeforeRequestingAPage()
+ {
+ _page = _ => Page(null);
+ await using var client = await CreateMcpClientForServer();
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+
+ await Assert.ThrowsAnyAsync(
+ async () => await client.ListSkillsAsync(cancellation.Token));
+ Assert.Empty(_cursors);
+ }
+
+ private JsonObject Page(string? nextCursor, params string[] names)
+ {
+ var entries = new JsonArray();
+ foreach (var name in names)
+ {
+ entries.Add(new JsonObject
+ {
+ ["uri"] = $"skill://{name}/SKILL.md",
+ ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" },
+ ["resources"] = "dynamic",
+ });
+ }
+ return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor };
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs
new file mode 100644
index 000000000..c8164defc
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs
@@ -0,0 +1,186 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Client;
+
+///
+/// Tests for : digest formatting and content verification against a manifest.
+///
+public class SkillVerifierTests
+{
+ private const string Uri = "skill://alpha/SKILL.md";
+
+ private static SkillResource Entry(byte[] content) => new()
+ {
+ Uri = Uri,
+ Digest = SkillVerifier.ComputeDigest(content),
+ Size = content.Length,
+ };
+
+ [Theory]
+ [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")]
+ [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")]
+ public void ComputeDigest_MatchesKnownVectors(string input, string expected)
+ {
+ Assert.Equal(expected, SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(input)));
+ }
+
+ [Fact]
+ public void Verify_AcceptsMatchingBytes()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("# Hello\n");
+
+ SkillVerifier.Verify(Entry(content), content);
+ }
+
+ [Fact]
+ public void Verify_RejectsSizeMismatch()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("# Hello\n");
+ var entry = Entry(content);
+ entry.Size += 1;
+
+ var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content));
+ Assert.Contains("bytes", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_RejectsDigestMismatch()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n"));
+
+ var exception = Assert.Throws(() =>
+ SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n")));
+ Assert.Contains("digest", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_AcceptsUppercaseDigestFromServer()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("x");
+ var entry = Entry(content);
+ entry.Digest = entry.Digest.ToUpperInvariant().Replace("SHA256", "sha256");
+
+ SkillVerifier.Verify(entry, content);
+ }
+
+ [Fact]
+ public void Verify_TextContents_HashesUtf8Encoding()
+ {
+ const string Text = "# Héllo\n";
+ var entry = Entry(Encoding.UTF8.GetBytes(Text));
+
+ SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = Text });
+ }
+
+ [Fact]
+ public void Verify_Contents_RejectsMissingTextAsVerificationFailure()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! }));
+ }
+
+ [Fact]
+ public void Verify_BlobContents_HashesDecodedBytes()
+ {
+ byte[] content = [0x00, 0xFF, 0x10, 0x80];
+ var entry = Entry(content);
+
+ SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri));
+ }
+
+ [Fact]
+ public void Verify_Contents_RejectsUriMismatch()
+ {
+ var entry = Entry(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() =>
+ SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" }));
+ }
+
+ [Fact]
+ public void Verify_Skill_ChecksEveryContentAgainstManifest()
+ {
+ byte[] content = Encoding.UTF8.GetBytes("x");
+ var skill = CreateSkill(content);
+
+ SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] });
+
+ Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] }));
+ }
+
+ [Fact]
+ public void Verify_Skill_RejectsUnlistedFile()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+
+ var exception = Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] }));
+ Assert.Contains("not listed", exception.Message);
+ }
+
+ [Fact]
+ public void Verify_Skill_RejectsEmptyResult()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+
+ Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult()));
+ }
+
+ [Fact]
+ public void Verify_Skill_RejectsMissingManifestWithoutCrashing()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+ skill.Resources = null!;
+
+ Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }));
+ Assert.Throws(() => SkillVerifier.Verify(skill, Uri,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }));
+ }
+
+ [Fact]
+ public void Verify_Skill_ThrowsForDynamicSkill()
+ {
+ var skill = CreateSkill(Encoding.UTF8.GetBytes("x"));
+ skill.Resources = SkillResources.Dynamic;
+
+ Assert.Throws(() => SkillVerifier.Verify(skill,
+ new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }));
+ }
+
+ [Fact]
+ public void Verify_SkillAndUri_RequiresTheRequestedFileInTheResult()
+ {
+ byte[] skillFile = Encoding.UTF8.GetBytes("x");
+ byte[] other = Encoding.UTF8.GetBytes("other");
+ var skill = CreateSkill(skillFile);
+ skill.Resources = SkillResources.FromResources(
+ [
+ Entry(skillFile),
+ new SkillResource { Uri = "skill://alpha/other.md", Digest = SkillVerifier.ComputeDigest(other), Size = other.Length },
+ ]);
+
+ // A correctly digested but different file of the same skill must not satisfy a read of SKILL.md.
+ var substituted = new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/other.md", Text = "other" }] };
+ SkillVerifier.Verify(skill, substituted);
+ var exception = Assert.Throws(() => SkillVerifier.Verify(skill, Uri, substituted));
+ Assert.Contains("returned no contents for that URI", exception.Message);
+
+ SkillVerifier.Verify(skill, Uri, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] });
+
+ Assert.Throws(() =>
+ SkillVerifier.Verify(skill, "skill://alpha/unlisted.md", new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }));
+ }
+
+ private static Skill CreateSkill(byte[] skillFileContent) => new()
+ {
+ Uri = Uri,
+ Frontmatter = new JsonObject { ["name"] = "alpha", ["description"] = "d" },
+ Resources = SkillResources.FromResources([Entry(skillFileContent)]),
+ };
+}
diff --git a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs
index 4e500b5ab..125d78e63 100644
--- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs
+++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs
@@ -62,7 +62,7 @@ protected McpServer StartServer()
return Server;
}
- public async ValueTask DisposeAsync()
+ public virtual async ValueTask DisposeAsync()
{
await _cts.CancelAsync();
diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
index 677d77357..0ae1d0775 100644
--- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
+++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj
@@ -84,6 +84,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs
new file mode 100644
index 000000000..bca8ae7ee
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs
@@ -0,0 +1,325 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Protocol;
+
+///
+/// Serialization and deserialization tests for the SEP-2640 skills protocol types, with particular attention to
+/// the array-or-"dynamic" union used for a skill's manifest.
+///
+public class SkillSerializationTests
+{
+ private static Skill CreateEntry(SkillResources resources) => new()
+ {
+ Uri = "skill://git-workflow/SKILL.md",
+ Frontmatter = new JsonObject
+ {
+ ["name"] = "git-workflow",
+ ["description"] = "Follow this team's Git conventions",
+ },
+ Resources = resources,
+ };
+
+ private static SkillResource CreateResource(string uri, string digest, long size) => new()
+ {
+ Uri = uri,
+ Digest = digest,
+ Size = size,
+ };
+
+ [Fact]
+ public void Skill_WithEnumeratedResources_RoundTrips()
+ {
+ var original = CreateEntry(SkillResources.FromResources(
+ [
+ CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('a', 64), 2314),
+ CreateResource("skill://git-workflow/examples/email.md", "sha256:" + new string('b', 64), 962),
+ ]));
+
+ string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.Skill);
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(deserialized);
+ Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Uri);
+ Assert.Equal("git-workflow", deserialized.Name);
+ Assert.Equal("Follow this team's Git conventions", deserialized.Description);
+ Assert.False(deserialized.Resources.IsDynamic);
+
+ var resources = deserialized.Resources.Resources;
+ Assert.NotNull(resources);
+ Assert.Equal(2, resources.Count);
+ Assert.Equal("skill://git-workflow/SKILL.md", resources[0].Uri);
+ Assert.Equal(2314, resources[0].Size);
+ Assert.Equal("sha256:" + new string('b', 64), resources[1].Digest);
+ }
+
+ [Fact]
+ public void Skill_WithDynamicResources_SerializesAsTheDynamicString()
+ {
+ string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill);
+
+ var node = JsonNode.Parse(json)!.AsObject();
+ Assert.Equal("dynamic", node["resources"]?.GetValue());
+ }
+
+ [Fact]
+ public void Skill_WithDynamicResources_RoundTrips()
+ {
+ string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill);
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(deserialized);
+ Assert.True(deserialized.Resources.IsDynamic);
+ Assert.Null(deserialized.Resources.Resources);
+ }
+
+ [Fact]
+ public void Skill_WithEmptyResourceArray_RoundTripsAsNonDynamic()
+ {
+ string json = JsonSerializer.Serialize(CreateEntry(SkillResources.FromResources([])), McpSkillsJsonContext.Default.Skill);
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(deserialized);
+ Assert.False(deserialized.Resources.IsDynamic);
+ Assert.Empty(deserialized.Resources.Resources!);
+ }
+
+ [Fact]
+ public void Skill_PreservesArbitraryFrontmatterFields()
+ {
+ const string Json = """
+ {
+ "uri": "skill://refunds/SKILL.md",
+ "frontmatter": {
+ "name": "refunds",
+ "description": "Process refunds",
+ "license": "Apache-2.0",
+ "metadata": { "version": "2.1.0", "owner": "billing" },
+ "allowed-tools": "Bash(git:*)"
+ },
+ "resources": "dynamic"
+ }
+ """;
+
+ var skill = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill);
+
+ Assert.NotNull(skill);
+ Assert.Equal("Apache-2.0", skill.Frontmatter["license"]?.GetValue());
+ Assert.Equal("2.1.0", skill.Frontmatter["metadata"]?["version"]?.GetValue());
+ Assert.Equal("Bash(git:*)", skill.Frontmatter["allowed-tools"]?.GetValue());
+
+ string reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill);
+ Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized)));
+ }
+
+ [Fact]
+ public void Skill_NameAndDescription_AreNullWhenAbsentOrNotStrings()
+ {
+ var skill = new Skill
+ {
+ Uri = "skill://x/SKILL.md",
+ Frontmatter = new JsonObject { ["name"] = 42 },
+ Resources = SkillResources.Dynamic,
+ };
+
+ Assert.Null(skill.Name);
+ Assert.Null(skill.Description);
+ }
+
+ [Theory]
+ [InlineData("\"static\"")]
+ [InlineData("\"Dynamic\"")]
+ [InlineData("\"\"")]
+ [InlineData("null")]
+ [InlineData("123")]
+ [InlineData("true")]
+ [InlineData("{}")]
+ [InlineData("[null]")]
+ public void Skill_WithInvalidResourcesValue_Throws(string resourcesJson)
+ {
+ string json = $$"""
+ {
+ "uri": "skill://git-workflow/SKILL.md",
+ "frontmatter": { "name": "git-workflow", "description": "d" },
+ "resources": {{resourcesJson}}
+ }
+ """;
+
+ Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill));
+ }
+
+ [Fact]
+ public void Skill_WithoutResources_Throws()
+ {
+ const string Json = """
+ {
+ "uri": "skill://git-workflow/SKILL.md",
+ "frontmatter": { "name": "git-workflow", "description": "d" }
+ }
+ """;
+
+ Assert.ThrowsAny(() => JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill));
+ }
+
+ [Fact]
+ public void SkillResources_FromResources_CopiesTheSource()
+ {
+ var mutable = new List
+ {
+ CreateResource("skill://a/SKILL.md", "sha256:" + new string('c', 64), 1),
+ };
+
+ var resources = SkillResources.FromResources(mutable);
+ mutable.Add(CreateResource("skill://a/extra.md", "sha256:" + new string('d', 64), 2));
+
+ Assert.Single(resources.Resources!);
+ }
+
+ [Fact]
+ public void SkillResources_FromResources_WithNull_Throws() =>
+ Assert.Throws(() => SkillResources.FromResources(null!));
+
+ [Fact]
+ public void ListSkillsResult_RoundTripsCursorAndCacheAttributes()
+ {
+ var original = new ListSkillsResult
+ {
+ Skills = [CreateEntry(SkillResources.Dynamic)],
+ NextCursor = "cursor-1",
+ ResultType = "complete",
+ TimeToLive = TimeSpan.FromMinutes(5),
+ CacheScope = CacheScope.Public,
+ };
+
+ string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsResult);
+ var node = JsonNode.Parse(json)!.AsObject();
+ Assert.Equal(300_000, node["ttlMs"]?.GetValue());
+ Assert.Equal("public", node["cacheScope"]?.GetValue());
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsResult);
+ Assert.NotNull(deserialized);
+ Assert.Equal("cursor-1", deserialized.NextCursor);
+ Assert.Equal("complete", deserialized.ResultType);
+ Assert.Equal(TimeSpan.FromMinutes(5), deserialized.TimeToLive);
+ Assert.Equal(CacheScope.Public, deserialized.CacheScope);
+ Assert.Single(deserialized.Skills);
+ }
+
+ [Fact]
+ public void ListSkillsResult_ToleratesUnknownCacheScopeOnRead()
+ {
+ const string Json = """{ "skills": [], "ttlMs": 0, "cacheScope": "regional" }""";
+
+ var deserialized = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.ListSkillsResult);
+
+ Assert.NotNull(deserialized);
+ Assert.Null(deserialized.CacheScope);
+ Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive);
+ }
+
+ [Fact]
+ public void ListSkillsResult_OmitsOptionalFieldsWhenUnset()
+ {
+ string json = JsonSerializer.Serialize(new ListSkillsResult(), McpSkillsJsonContext.Default.ListSkillsResult);
+
+ var node = JsonNode.Parse(json)!.AsObject();
+ Assert.False(node.ContainsKey("ttlMs"));
+ Assert.False(node.ContainsKey("cacheScope"));
+ Assert.False(node.ContainsKey("nextCursor"));
+ Assert.False(node.ContainsKey("resultType"));
+ Assert.Empty(node["skills"]!.AsArray());
+ }
+
+ [Fact]
+ public void GetSkillResult_RoundTrips()
+ {
+ var original = new GetSkillResult
+ {
+ Skill = CreateEntry(SkillResources.FromResources(
+ [
+ CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('e', 64), 10),
+ ])),
+ ResultType = "complete",
+ };
+
+ string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.GetSkillResult);
+ Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor"));
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ Assert.NotNull(deserialized);
+ Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Skill.Uri);
+ Assert.Equal("complete", deserialized.ResultType);
+ }
+
+ [Theory]
+ [InlineData(0, CacheScope.Private)]
+ [InlineData(300_000, CacheScope.Public)]
+ [InlineData(-1, CacheScope.Private)]
+ public void GetSkillResult_RoundTripsCacheAttributes(long milliseconds, CacheScope scope)
+ {
+ var original = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic), ResultType = "complete" };
+ var cacheable = Assert.IsAssignableFrom(original);
+ cacheable.TimeToLive = TimeSpan.FromMilliseconds(milliseconds);
+ cacheable.CacheScope = scope;
+
+ var json = JsonSerializer.SerializeToNode(original, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject();
+ Assert.Equal(milliseconds, json["ttlMs"]?.GetValue());
+ Assert.Equal(scope == CacheScope.Public ? "public" : "private", json["cacheScope"]?.GetValue());
+ Assert.Equal("complete", json["resultType"]?.GetValue());
+ Assert.False(json.ContainsKey("nextCursor"));
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ var deserializedCacheable = Assert.IsAssignableFrom(deserialized);
+ Assert.Equal(cacheable.TimeToLive, deserializedCacheable.TimeToLive);
+ Assert.Equal(scope, deserializedCacheable.CacheScope);
+ }
+
+ [Theory]
+ [InlineData("\"regional\"")]
+ [InlineData("42")]
+ [InlineData("null")]
+ public void GetSkillResult_ToleratesUnknownCacheScopeOnRead(string cacheScope)
+ {
+ var json = $$"""
+ {
+ "skill": {
+ "uri": "skill://alpha/SKILL.md",
+ "frontmatter": { "name": "alpha", "description": "d" },
+ "resources": "dynamic"
+ },
+ "ttlMs": 0,
+ "cacheScope": {{cacheScope}}
+ }
+ """;
+
+ var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult);
+ var cacheable = Assert.IsAssignableFrom(deserialized);
+
+ Assert.Null(cacheable.CacheScope);
+ Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive);
+ }
+
+ [Fact]
+ public void GetSkillResult_OmitsVersionedFieldsWhenUnset()
+ {
+ var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) };
+ var json = JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject();
+
+ Assert.False(json.ContainsKey("ttlMs"));
+ Assert.False(json.ContainsKey("cacheScope"));
+ Assert.False(json.ContainsKey("resultType"));
+ Assert.False(json.ContainsKey("nextCursor"));
+ }
+
+ [Fact]
+ public void RequestParams_RoundTrip()
+ {
+ string listJson = JsonSerializer.Serialize(new ListSkillsRequestParams { Cursor = "abc" }, McpSkillsJsonContext.Default.ListSkillsRequestParams);
+ Assert.Equal("abc", JsonSerializer.Deserialize(listJson, McpSkillsJsonContext.Default.ListSkillsRequestParams)?.Cursor);
+
+ string getJson = JsonSerializer.Serialize(new GetSkillRequestParams { Uri = "skill://a/SKILL.md" }, McpSkillsJsonContext.Default.GetSkillRequestParams);
+ Assert.Equal("skill://a/SKILL.md", JsonSerializer.Deserialize(getJson, McpSkillsJsonContext.Default.GetSkillRequestParams)?.Uri);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs
new file mode 100644
index 000000000..25ecbd5e2
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs
@@ -0,0 +1,478 @@
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Tests for : ordering, keyset pagination, lookup, cursor validation, and
+/// the structural validation of entries against the specification.
+///
+public class InMemoryMcpSkillCatalogTests
+{
+ private static readonly string s_validDigest = "sha256:" + new string('a', 64);
+
+ private static McpSkillRequestContext Context => new(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList });
+
+ private static Skill CreateSkill(string name, string? description = "A skill", SkillResources? resources = null)
+ {
+ string uri = $"skill://{name}/SKILL.md";
+ return new()
+ {
+ Uri = uri,
+ Frontmatter = new JsonObject
+ {
+ ["name"] = name,
+ ["description"] = description,
+ },
+ Resources = resources ?? SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 10 }]),
+ };
+ }
+
+ private static InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) =>
+ new([.. names.Select(name => CreateSkill(name))], pageSize);
+
+ [Fact]
+ public async Task ListAsync_ReturnsEntriesOrderedByUri()
+ {
+ var catalog = CreateCatalog(10, "zebra", "alpha", "middle");
+
+ var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+
+ Assert.Collection(
+ page.Skills,
+ skill => Assert.Equal("skill://alpha/SKILL.md", skill.Uri),
+ skill => Assert.Equal("skill://middle/SKILL.md", skill.Uri),
+ skill => Assert.Equal("skill://zebra/SKILL.md", skill.Uri));
+ Assert.Null(page.NextCursor);
+ Assert.Equal(3, catalog.Count);
+ }
+
+ [Fact]
+ public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries()
+ {
+ var catalog = CreateCatalog(2, "a", "b", "c", "d", "e");
+
+ var seen = new List();
+ string? cursor = null;
+ int pages = 0;
+ do
+ {
+ var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken);
+ seen.AddRange(page.Skills.Select(skill => skill.Uri));
+ cursor = page.NextCursor;
+ Assert.True(++pages < 20, "Pagination did not terminate.");
+ }
+ while (cursor is not null);
+
+ Assert.Equal(3, pages);
+ Assert.Equal(5, seen.Count);
+ Assert.Equal(seen.Count, seen.Distinct().Count());
+ Assert.Equal(seen.OrderBy(uri => uri, StringComparer.Ordinal), seen);
+ }
+
+ [Fact]
+ public async Task ListAsync_LastPageHasNoNextCursor()
+ {
+ var catalog = CreateCatalog(2, "a", "b", "c", "d");
+
+ var first = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ var second = await catalog.ListAsync(first.NextCursor, Context, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(first.NextCursor);
+ Assert.Equal(2, second.Skills.Count);
+ Assert.Null(second.NextCursor);
+ }
+
+ [Fact]
+ public async Task ListAsync_WithCursorForUnknownUri_ResumesAfterItsPosition()
+ {
+ var catalog = CreateCatalog(10, "a", "c");
+ string cursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("skill://b/SKILL.md"));
+
+ var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken);
+
+ Assert.Single(page.Skills);
+ Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri);
+ }
+
+ [Fact]
+ public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage()
+ {
+ var catalog = CreateCatalog(10);
+
+ var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+
+ Assert.Empty(page.Skills);
+ Assert.Null(page.NextCursor);
+ }
+
+ [Theory]
+ [InlineData("not-base64!!")]
+ [InlineData("/w==")]
+ [InlineData("wK8=")]
+ [InlineData("7aCA")]
+ public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams(string cursor)
+ {
+ var catalog = CreateCatalog(10, "a");
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
+ }
+
+ [Fact]
+ public async Task GetAsync_ReturnsSkillByUri()
+ {
+ var catalog = CreateCatalog(10, "alpha");
+
+ var skill = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+
+ Assert.NotNull(skill);
+ Assert.Equal("alpha", skill.Name);
+ }
+
+ [Theory]
+ [InlineData("skill://missing/SKILL.md")]
+ [InlineData("SKILL://ALPHA/SKILL.md")]
+ [InlineData("skill://alpha")]
+ public async Task GetAsync_WithUnknownUri_ReturnsNull(string uri)
+ {
+ var catalog = CreateCatalog(10, "alpha");
+
+ Assert.Null(await catalog.GetAsync(uri, Context, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Constructor_SnapshotsEntries_SoLaterMutationsAreNotServed()
+ {
+ var source = CreateSkill("alpha");
+ var catalog = new InMemoryMcpSkillCatalog([source]);
+
+ source.Frontmatter["description"] = "changed";
+ source.Resources.Resources![0].Digest = "sha256:" + new string('f', 64);
+ source.Uri = "skill://renamed/SKILL.md";
+
+ var served = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ Assert.NotNull(served);
+ Assert.Equal("A skill", served.Description);
+ Assert.Equal(s_validDigest, served.Resources.Resources![0].Digest);
+ }
+
+ [Fact]
+ public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResponses()
+ {
+ var catalog = new InMemoryMcpSkillCatalog([CreateSkill("alpha")]);
+
+ var first = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ first!.Frontmatter["description"] = "tampered";
+ first.Resources.Resources![0].Digest = "sha256:" + new string('f', 64);
+
+ var listed = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken);
+ listed.Skills[0].Frontmatter["description"] = "tampered again";
+
+ var again = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken);
+ Assert.Equal("A skill", again!.Description);
+ Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest);
+ }
+
+ [Theory]
+ [InlineData("café")]
+ [InlineData("数据分析")]
+ [InlineData("données-2024")]
+ [InlineData("\U00010428")]
+ [InlineData("\U00020000")]
+ public void Constructor_AcceptsUnicodeLowercaseNames(string name)
+ {
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count);
+ }
+
+ [Theory]
+ [InlineData("\u01c5")]
+ [InlineData("\U00010400")]
+ public void Constructor_RejectsUppercaseOrTitlecaseNames(string name)
+ {
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)]));
+ }
+
+ [Theory]
+ [InlineData(0xd801)]
+ [InlineData(0xdc28)]
+ public void Constructor_RejectsUnpairedSurrogates(int codeUnit)
+ {
+ var name = new string((char)codeUnit, 1);
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)]));
+ }
+
+ [Theory]
+ [InlineData(64, true)]
+ [InlineData(65, false)]
+ public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid)
+ {
+ var name = string.Concat(Enumerable.Repeat("\U00010428", length));
+ var skill = CreateSkill(name);
+ skill.Uri = $"skill://catalog/{name}/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 10 }]);
+
+ if (valid)
+ {
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+ else
+ {
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+ }
+ }
+
+ [Theory]
+ [InlineData("1")]
+ [InlineData("true")]
+ [InlineData("[]")]
+ [InlineData("{}")]
+ [InlineData("null")]
+ public void Constructor_RejectsNonStringMetadataValues(string value)
+ {
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) };
+
+ var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+
+ Assert.Contains("metadata", exception.Message);
+ }
+
+ [Fact]
+ public void Constructor_WithDuplicateUris_Throws()
+ {
+ var duplicate = new[] { CreateSkill("alpha"), CreateSkill("alpha") };
+
+ Assert.Throws(() => new InMemoryMcpSkillCatalog(duplicate));
+ }
+
+ [Fact]
+ public void Constructor_WithNullSkills_Throws() =>
+ Assert.Throws(() => new InMemoryMcpSkillCatalog(null!));
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Constructor_WithNonPositivePageSize_Throws(int pageSize) =>
+ Assert.Throws(() => new InMemoryMcpSkillCatalog([], pageSize));
+
+ [Fact]
+ public void Constructor_AcceptsFullAgentSkillsFrontmatter()
+ {
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["license"] = "MIT";
+ skill.Frontmatter["compatibility"] = "Needs git";
+ skill.Frontmatter["allowed-tools"] = "Bash(git:*) Read";
+ skill.Frontmatter["metadata"] = new JsonObject { ["author"] = "acme", ["version"] = "2.1.0" };
+ skill.Frontmatter["description"] = new string('d', 1024);
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Fact]
+ public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization()
+ {
+ var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
+ var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter());
+ var value = JsonValue.Create(1, typeInfo)!;
+ Assert.Equal(JsonValueKind.String, value.GetValueKind());
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value };
+
+ var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+
+ Assert.Contains("metadata", error.Message);
+ }
+
+ private sealed class ChangingMetadataConverter : JsonConverter
+ {
+ private int _writes;
+
+ public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
+ throw new NotSupportedException();
+
+ public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options)
+ {
+ if (++_writes == 1)
+ {
+ writer.WriteStringValue("initial-string");
+ }
+ else
+ {
+ writer.WriteNumberValue(value);
+ }
+ }
+ }
+
+ [Fact]
+ public void Constructor_PassesThroughFieldsHostsDoNotActOn()
+ {
+ // Unknown fields retain structured values; the known metadata map has its own string-value requirement.
+ var skill = CreateSkill("alpha");
+ skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write");
+ skill.Frontmatter["license"] = null;
+ skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") };
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Fact]
+ public void Constructor_AcceptsEmptyAuthority()
+ {
+ var skill = CreateSkill("alpha");
+ skill.Uri = "file:///alpha/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]);
+
+ Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count);
+ }
+
+ [Fact]
+ public void Constructor_AcceptsDynamicSkill()
+ {
+ var catalog = new InMemoryMcpSkillCatalog([CreateSkill("generated", resources: SkillResources.Dynamic)]);
+
+ Assert.Equal(1, catalog.Count);
+ }
+
+ [Fact]
+ public void Constructor_AcceptsNestedSkillPath()
+ {
+ var skill = CreateSkill("refunds");
+ skill.Uri = "skill://acme/billing/refunds/SKILL.md";
+ skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]);
+
+ var catalog = new InMemoryMcpSkillCatalog([skill]);
+
+ Assert.Equal(1, catalog.Count);
+ }
+
+ public static IEnumerable InvalidSkills()
+ {
+ static object[] Case(string reason, Action mutate)
+ {
+ var skill = CreateSkill("alpha");
+ mutate(skill);
+ return [reason, skill];
+ }
+
+ yield return Case("uri not ending in /SKILL.md", s => s.Uri = "skill://alpha/skill.md");
+ yield return Case("syntactically invalid uri", s =>
+ {
+ s.Uri = "1 bad://alpha/SKILL.md";
+ s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]);
+ });
+ yield return Case("metadata present but null", s => s.Frontmatter["metadata"] = null);
+ yield return Case("compatibility present but null", s => s.Frontmatter["compatibility"] = null);
+ yield return Case("relative uri", s =>
+ {
+ s.Uri = "alpha/SKILL.md";
+ s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]);
+ });
+ yield return Case("uri with query", s =>
+ {
+ s.Uri = "skill://alpha/SKILL.md?x=1";
+ s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]);
+ });
+ yield return Case("resource escapes the skill through '..'", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha/../secret.md", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("resource with empty segment", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha//x.md", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("resource with fragment", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha/x.md#frag", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("sizes that overflow when summed", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = long.MaxValue },
+ new SkillResource { Uri = "skill://alpha/x.md", Digest = s_validDigest, Size = long.MaxValue },
+ ]));
+ yield return Case("description over 1024 characters", s => s.Frontmatter["description"] = new string('d', 1025));
+ yield return Case("compatibility over 500 characters", s => s.Frontmatter["compatibility"] = new string('c', 501));
+ yield return Case("compatibility empty", s => s.Frontmatter["compatibility"] = "");
+ yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x");
+ yield return Case("name missing", s => s.Frontmatter.Remove("name"));
+ yield return Case("name not a string", s => s.Frontmatter["name"] = 1);
+ yield return Case("name does not match uri segment", s => s.Frontmatter["name"] = "beta");
+ yield return Case("name with an uppercase letter", s =>
+ {
+ s.Uri = "skill://Café/SKILL.md";
+ s.Frontmatter["name"] = "Café";
+ s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]);
+ });
+ yield return Case("resource escapes the skill through an encoded '..'", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("resource with an encoded slash", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha/a%2Fb.md", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("name violates naming rules", s =>
+ {
+ s.Uri = "skill://Alpha/SKILL.md";
+ s.Frontmatter["name"] = "Alpha";
+ s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]);
+ });
+ yield return Case("description missing", s => s.Frontmatter.Remove("description"));
+ yield return Case("description empty", s => s.Frontmatter["description"] = "");
+ yield return Case("empty manifest", s => s.Resources = SkillResources.FromResources([]));
+ yield return Case("manifest omits SKILL.md", s => s.Resources = SkillResources.FromResources(
+ [new SkillResource { Uri = "skill://alpha/other.md", Digest = s_validDigest, Size = 1 }]));
+ yield return Case("manifest lists file outside the skill", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alphabet/x.md", Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("duplicate manifest entry", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ ]));
+ yield return Case("malformed digest (uppercase)", s => s.Resources = SkillResources.FromResources(
+ [new SkillResource { Uri = s.Uri, Digest = "sha256:" + new string('A', 64), Size = 1 }]));
+ yield return Case("malformed digest (wrong length)", s => s.Resources = SkillResources.FromResources(
+ [new SkillResource { Uri = s.Uri, Digest = "sha256:abc", Size = 1 }]));
+ yield return Case("malformed digest (wrong algorithm)", s => s.Resources = SkillResources.FromResources(
+ [new SkillResource { Uri = s.Uri, Digest = "sha512:" + new string('a', 64), Size = 1 }]));
+ yield return Case("negative size", s => s.Resources = SkillResources.FromResources(
+ [new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = -1 }]));
+ yield return Case("too many resources", s => s.Resources = SkillResources.FromResources(
+ Enumerable.Range(0, SkillsProtocol.MaxResourcesPerSkill + 1).Select(i => new SkillResource
+ {
+ Uri = i == 0 ? s.Uri : $"skill://alpha/f{i}.md",
+ Digest = s_validDigest,
+ Size = 1,
+ })));
+ yield return Case("total size over limit", s => s.Resources = SkillResources.FromResources(
+ [
+ new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 },
+ new SkillResource { Uri = "skill://alpha/big.bin", Digest = s_validDigest, Size = SkillsProtocol.MaxTotalSizeBytes },
+ ]));
+ }
+
+ [Theory]
+ [MemberData(nameof(InvalidSkills))]
+ public void Constructor_RejectsInvalidSkill(string reason, Skill skill)
+ {
+ var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill]));
+
+ Assert.Contains("skill://", exception.Message);
+ Assert.NotEmpty(reason);
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs
new file mode 100644
index 000000000..c1304fb77
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs
@@ -0,0 +1,191 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using Moq;
+using System.Globalization;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace ModelContextProtocol.Tests.Server;
+
+public partial class InjectedParameterSchemaTests
+{
+ private RequestContext? _toolRequest;
+ private RequestContext? _promptRequest;
+ private InjectedService? _service;
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Tool_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters)
+ {
+ var method = GetMethod(nameof(EchoTool), freshParameters);
+ var tool = McpServerTool.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options });
+ var properties = tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray();
+ Assert.Equal(["value"], properties);
+
+ var request = new RequestContext(
+ new Mock().Object,
+ new JsonRpcRequest { Id = new RequestId("tool"), Method = RequestMethods.ToolsCall },
+ new()
+ {
+ Name = tool.ProtocolTool.Name,
+ Arguments = new Dictionary
+ {
+ ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String),
+ },
+ });
+ var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Same(request, _toolRequest);
+ Assert.Equal("hello", Assert.IsType(Assert.Single(result.Content)).Text);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Prompt_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters)
+ {
+ var method = GetMethod(nameof(EchoPrompt), freshParameters);
+ var prompt = McpServerPrompt.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options });
+ Assert.Equal(["value"], Assert.IsAssignableFrom>(prompt.ProtocolPrompt.Arguments).Select(argument => argument.Name).ToArray());
+ var request = new RequestContext(
+ new Mock().Object,
+ new JsonRpcRequest { Id = new RequestId("prompt"), Method = RequestMethods.PromptsGet },
+ new()
+ {
+ Name = prompt.ProtocolPrompt.Name,
+ Arguments = new Dictionary
+ {
+ ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String),
+ },
+ });
+
+ await prompt.GetAsync(request, TestContext.Current.CancellationToken);
+
+ Assert.Same(request, _promptRequest);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task Tool_RegisteredServiceRemainsInjectedWhenParametersAreReenumerated(bool freshParameters)
+ {
+ var expected = new InjectedService();
+ await using var services = new ServiceCollection().AddSingleton(expected).BuildServiceProvider();
+ var tool = McpServerTool.Create(GetMethod(nameof(EchoService), freshParameters), this,
+ new() { Services = services, SerializerOptions = WireJsonContext.Default.Options });
+ Assert.Equal(["value"], tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray());
+ var request = new RequestContext(
+ new Mock().Object,
+ new JsonRpcRequest { Id = new RequestId("service"), Method = RequestMethods.ToolsCall },
+ new()
+ {
+ Name = tool.ProtocolTool.Name,
+ Arguments = new Dictionary
+ {
+ ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String),
+ },
+ })
+ {
+ Services = services,
+ };
+
+ await tool.InvokeAsync(request, TestContext.Current.CancellationToken);
+ Assert.Same(expected, _service);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Tool_CustomSchemaFilterIsPreservedWithoutMutatingOptions(bool freshParameters)
+ {
+ var schemaOptions = new AIJsonSchemaCreateOptions { IncludeParameter = parameter => parameter.Name != "value" };
+ var filter = schemaOptions.IncludeParameter;
+ var tool = McpServerTool.Create(GetMethod(nameof(EchoTool), freshParameters), this, new()
+ {
+ SerializerOptions = WireJsonContext.Default.Options,
+ SchemaCreateOptions = schemaOptions,
+ });
+
+ Assert.Empty(tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject());
+ Assert.Same(filter, schemaOptions.IncludeParameter);
+ }
+
+ private string EchoTool(string value, RequestContext context)
+ {
+ _toolRequest = context;
+ return value;
+ }
+
+ private string EchoPrompt(string value, RequestContext context)
+ {
+ _promptRequest = context;
+ return value;
+ }
+
+ private string EchoService(string value, InjectedService service)
+ {
+ _service = service;
+ return value;
+ }
+
+ private sealed class InjectedService;
+
+ private MethodInfo GetMethod(string name, bool freshParameters)
+ {
+ var method = Assert.IsAssignableFrom(GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic));
+ return freshParameters ? new ReenumeratingMethodInfo(method) : method;
+ }
+
+ // ParameterInfo identity is not part of the method signature. Return equivalent but distinct
+ // instances to deterministically exercise separate binding and schema-enumeration passes.
+ private sealed class ReenumeratingMethodInfo(MethodInfo method) : MethodInfo
+ {
+ public override string Name => method.Name;
+ public override Type? DeclaringType => method.DeclaringType;
+ public override Type? ReflectedType => method.ReflectedType;
+ public override Module Module => method.Module;
+ public override int MetadataToken => method.MetadataToken;
+ public override MethodAttributes Attributes => method.Attributes;
+ public override RuntimeMethodHandle MethodHandle => method.MethodHandle;
+ public override Type ReturnType => method.ReturnType;
+ public override ParameterInfo ReturnParameter => method.ReturnParameter;
+ public override ICustomAttributeProvider ReturnTypeCustomAttributes => method.ReturnTypeCustomAttributes;
+ public override bool ContainsGenericParameters => method.ContainsGenericParameters;
+ public override bool IsGenericMethod => method.IsGenericMethod;
+ public override bool IsGenericMethodDefinition => method.IsGenericMethodDefinition;
+ public override MethodInfo GetBaseDefinition() => method.GetBaseDefinition();
+ public override MethodImplAttributes GetMethodImplementationFlags() => method.GetMethodImplementationFlags();
+ public override Type[] GetGenericArguments() => method.GetGenericArguments();
+ public override object[] GetCustomAttributes(bool inherit) => method.GetCustomAttributes(inherit);
+ public override object[] GetCustomAttributes(Type attributeType, bool inherit) => method.GetCustomAttributes(attributeType, inherit);
+ public override IList GetCustomAttributesData() => method.GetCustomAttributesData();
+ public override bool IsDefined(Type attributeType, bool inherit) => method.IsDefined(attributeType, inherit);
+ public override object? Invoke(object? obj, BindingFlags invokeAttr, Binder? binder, object?[]? parameters, CultureInfo? culture) =>
+ method.Invoke(obj, invokeAttr, binder, parameters, culture);
+ public override ParameterInfo[] GetParameters() =>
+ Array.ConvertAll(method.GetParameters(), parameter => new ReenumeratedParameter(parameter));
+ }
+
+ private sealed class ReenumeratedParameter(ParameterInfo parameter) : ParameterInfo
+ {
+ public override string? Name => parameter.Name;
+ public override Type ParameterType => parameter.ParameterType;
+ public override int Position => parameter.Position;
+ public override MemberInfo Member => parameter.Member;
+ public override ParameterAttributes Attributes => parameter.Attributes;
+ public override object? DefaultValue => parameter.DefaultValue;
+ public override object? RawDefaultValue => parameter.RawDefaultValue;
+ public override bool HasDefaultValue => parameter.HasDefaultValue;
+ public override object[] GetCustomAttributes(bool inherit) => parameter.GetCustomAttributes(inherit);
+ public override object[] GetCustomAttributes(Type attributeType, bool inherit) => parameter.GetCustomAttributes(attributeType, inherit);
+ public override IList GetCustomAttributesData() => parameter.GetCustomAttributesData();
+ public override bool IsDefined(Type attributeType, bool inherit) => parameter.IsDefined(attributeType, inherit);
+ }
+
+ [JsonSerializable(typeof(string))]
+ private partial class WireJsonContext : JsonSerializerContext;
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs
new file mode 100644
index 000000000..54cf43dab
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs
@@ -0,0 +1,74 @@
+using ModelContextProtocol.Extensions.Skills;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Server;
+
+public class McpServerSkillAuthoringBoundaryTests
+{
+ private const string SkillUri = "skill://owned/SKILL.md";
+
+ [Theory]
+ [InlineData("---\nname: &name owned\ndescription: d\n---\n")]
+ [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")]
+ [InlineData("# No frontmatter\n")]
+ [InlineData("---\nname: owned\ndescription: d\n")]
+ public void AutomaticAuthoring_RejectsUnsupportedMalformedOrMissingFrontmatter(string markdown)
+ {
+ var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) };
+ Assert.Throws(() => McpServerSkill.Create(files));
+ Assert.Throws(() => McpServerSkill.Create(SkillUri, files));
+
+ WithDirectory(markdown, directory =>
+ {
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory));
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory));
+ });
+ }
+
+ [Fact]
+ public void ExplicitMetadata_DoesNotOverrideSupportedFrontmatterEquality()
+ {
+ const string Markdown = "---\nname: owned\ndescription: From the file\n---\n";
+ var supplied = new JsonObject { ["name"] = "owned", ["description"] = "Not from the file" };
+
+ var filesException = Assert.Throws(() => McpServerSkill.Create(
+ SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", Markdown)]));
+ Assert.Contains("does not match", filesException.Message);
+
+ WithDirectory(Markdown, directory =>
+ {
+ var directoryException = Assert.Throws(() =>
+ McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory));
+ Assert.Contains("does not match", directoryException.Message);
+ });
+ }
+
+ [Theory]
+ [InlineData("# No frontmatter\n")]
+ [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")]
+ public void ExplicitMetadata_DoesNotBypassDetectedBoundaryOrFormatErrors(string markdown)
+ {
+ var supplied = new JsonObject { ["name"] = "owned", ["description"] = "d" };
+ Assert.Throws(() => McpServerSkill.Create(
+ SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", markdown)]));
+ WithDirectory(markdown, directory =>
+ {
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory));
+ });
+ }
+
+ private void WithDirectory(string markdown, Action action)
+ {
+ var directory = Path.Combine(Path.GetTempPath(), "mcp-skill-ownership-" + Guid.NewGuid().ToString("N"));
+ Directory.CreateDirectory(directory);
+ try
+ {
+ File.WriteAllText(Path.Combine(directory, "SKILL.md"), markdown);
+ action(directory);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs
new file mode 100644
index 000000000..1bb510e26
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs
@@ -0,0 +1,523 @@
+using ModelContextProtocol.Extensions.Skills;
+using System.Text;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Tests for : manifest computation, resource creation, path handling, validation, and
+/// loading from a directory.
+///
+public class McpServerSkillTests
+{
+ private const string SkillUri = "skill://git-workflow/SKILL.md";
+ private const string SkillMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n";
+
+ private static JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new()
+ {
+ ["name"] = name,
+ ["description"] = description,
+ };
+
+ [Fact]
+ public void Create_ComputesManifestFromFileBytes()
+ {
+ byte[] guide = Encoding.UTF8.GetBytes("# Guide\n");
+
+ var frontmatter = Frontmatter();
+ var skill = McpServerSkill.Create(SkillUri, frontmatter,
+ [
+ new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide },
+ McpServerSkillFile.FromText("SKILL.md", SkillMarkdown),
+ ]);
+
+ var entry = skill.ProtocolSkill;
+ Assert.Equal(SkillUri, entry.Uri);
+ Assert.True(JsonNode.DeepEquals(frontmatter, entry.Frontmatter));
+ Assert.NotSame(frontmatter, entry.Frontmatter);
+ Assert.False(entry.Resources.IsDynamic);
+
+ var manifest = entry.Resources.Resources!;
+ Assert.Equal(2, manifest.Count);
+
+ // SKILL.md is always first, regardless of input order.
+ Assert.Equal(SkillUri, manifest[0].Uri);
+ Assert.Equal(Encoding.UTF8.GetByteCount(SkillMarkdown), manifest[0].Size);
+ Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), manifest[0].Digest);
+
+ Assert.Equal("skill://git-workflow/references/GUIDE.md", manifest[1].Uri);
+ Assert.Equal(guide.Length, manifest[1].Size);
+ Assert.Equal(SkillVerifier.ComputeDigest(guide), manifest[1].Digest);
+ }
+
+ [Fact]
+ public void Create_ProducesOneResourcePerFileWithSpecMetadata()
+ {
+ var skill = McpServerSkill.Create(SkillUri, Frontmatter(),
+ [
+ McpServerSkillFile.FromText("SKILL.md", SkillMarkdown),
+ McpServerSkillFile.FromText("scripts/run.py", "print('hi')\n"),
+ McpServerSkillFile.FromText("data/table.csv", "a,b\n", mimeType: "text/x-custom"),
+ ]);
+
+ Assert.Equal(3, skill.Resources.Count);
+
+ var skillFile = skill.Resources[0].ProtocolResource;
+ Assert.NotNull(skillFile);
+ Assert.Equal(SkillUri, skillFile.Uri);
+ Assert.Equal("git-workflow", skillFile.Name);
+ Assert.Equal("Git conventions", skillFile.Description);
+ Assert.Equal("text/markdown", skillFile.MimeType);
+
+ var script = skill.Resources[2].ProtocolResource;
+ Assert.NotNull(script);
+ Assert.Equal("skill://git-workflow/scripts/run.py", script.Uri);
+ Assert.Equal("scripts/run.py", script.Name);
+ Assert.Equal("text/x-python", script.MimeType);
+
+ var table = skill.Resources[1].ProtocolResource;
+ Assert.NotNull(table);
+ Assert.Equal("text/x-custom", table.MimeType);
+ }
+
+ [Theory]
+ [InlineData("./SKILL.md", "SKILL.md")]
+ [InlineData("references\\GUIDE.md", "references/GUIDE.md")]
+ public void Create_NormalizesFilePaths(string input, string expectedRelative)
+ {
+ var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") };
+ if (expectedRelative != "SKILL.md")
+ {
+ files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown));
+ }
+
+ var skill = McpServerSkill.Create(SkillUri, Frontmatter(), files);
+
+ Assert.Contains(skill.ProtocolSkill.Resources.Resources!, r => r.Uri == "skill://git-workflow/" + expectedRelative);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData("/SKILL.md")]
+ [InlineData("references/")]
+ [InlineData("../SKILL.md")]
+ [InlineData("a/./b.md")]
+ [InlineData("a//b.md")]
+ public void Create_RejectsUnsafePaths(string path)
+ {
+ Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(),
+ [
+ McpServerSkillFile.FromText("SKILL.md", SkillMarkdown),
+ McpServerSkillFile.FromText(path, "x"),
+ ]));
+ }
+
+ [Fact]
+ public void Create_RejectsMissingSkillFile()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("README.md", "x")]));
+
+ Assert.Contains("SKILL.md", exception.Message);
+ }
+
+ [Fact]
+ public void Create_RejectsDuplicatePaths()
+ {
+ Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(),
+ [
+ McpServerSkillFile.FromText("SKILL.md", SkillMarkdown),
+ McpServerSkillFile.FromText("./SKILL.md", SkillMarkdown),
+ ]));
+ }
+
+ [Fact]
+ public void Create_RejectsUriNotEndingInSkillFile()
+ {
+ Assert.Throws(() =>
+ McpServerSkill.Create("skill://git-workflow", Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]));
+ }
+
+ [Fact]
+ public void Create_RejectsFrontmatterNameMismatch()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, Frontmatter(name: "git-flow"), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]));
+
+ Assert.Equal("frontmatter", exception.ParamName);
+ Assert.Contains("git-flow", exception.Message);
+ }
+
+ [Fact]
+ public void Create_RejectsMissingDescription()
+ {
+ Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, Frontmatter(description: null), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]));
+ }
+
+ [Fact]
+ public void Create_ReadsFrontmatterFromSkillFile()
+ {
+ var skill = McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]);
+
+ Assert.True(JsonNode.DeepEquals(Frontmatter(), skill.ProtocolSkill.Frontmatter));
+ }
+
+ [Fact]
+ public void Create_DerivesUriFromFrontmatterName()
+ {
+ var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), McpServerSkillFile.FromText("a.md", "x")]);
+
+ Assert.Equal(SkillUri, skill.ProtocolSkill.Uri);
+ Assert.Equal("skill://git-workflow/a.md", skill.ProtocolSkill.Resources.Resources![1].Uri);
+ }
+
+ [Fact]
+ public void Create_RejectsUriWhoseNameDiffersFromTheFile()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create("skill://other-name/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]));
+
+ Assert.Contains("git-workflow", exception.Message);
+ }
+
+ [Fact]
+ public void Create_WithUnreadableFrontmatter_ExplainsAndPointsAtTheExplicitOverload()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: &a git-workflow\ndescription: d\n---\n")]));
+
+ Assert.Contains("anchors", exception.Message);
+ Assert.Contains("JsonObject", exception.Message);
+
+ var noFrontmatter = Assert.Throws(() =>
+ McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "# No frontmatter\n")]));
+ Assert.Contains("must begin", noFrontmatter.Message);
+ }
+
+ [Fact]
+ public void Create_WithExplicitFrontmatter_RejectsMismatchWithTheFile()
+ {
+ var mismatched = Frontmatter();
+ mismatched["license"] = "MIT";
+
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, mismatched, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]));
+
+ Assert.Equal("frontmatter", exception.ParamName);
+ Assert.Contains("does not match", exception.Message);
+ Assert.Contains("\"license\"", exception.Message);
+ }
+
+ [Fact]
+ public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile()
+ {
+ // Matches the file: fine.
+ McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]);
+
+ // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object.
+ McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } },
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]);
+
+ // The caller supplies the complete, prevalidated frontmatter for this unsupported YAML form.
+ var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(),
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]);
+ Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name);
+
+ // The caller has also validated this complete multi-line quoted value.
+ var multiLine = McpServerSkill.Create(SkillUri, Frontmatter(),
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")]);
+ Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description);
+
+ // So is a flow collection that closes on a later line.
+ var explicitTools = Frontmatter();
+ explicitTools["allowed-tools"] = new JsonArray("Read", "Write");
+ var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTools,
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\nallowed-tools: [Read,\n Write]\n---\n")]);
+ Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["allowed-tools"]!.AsArray().Count);
+ }
+
+ [Theory]
+ [InlineData("# no frontmatter block\n", "must begin")]
+ [InlineData("---\nname: git-workflow\n", "not closed")]
+ [InlineData("---\nname: a: b\n---\n", "cannot contain")]
+ [InlineData("---\n\tname: x\n---\n", "tabs")]
+ [InlineData("---\nname: git-workflow\ndescription: \"never closed\n---\n", "unterminated")]
+ [InlineData("---\nname: git-workflow\ndescription: d\nallowed-tools: [Read, Write\n---\n", "unterminated flow")]
+ [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")]
+ public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment)
+ {
+ // These errors are detected by the bounded reader; explicit metadata does not bypass them.
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", markdown)]));
+
+ Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase);
+ }
+
+ [Fact]
+ public void Create_WithExplicitFrontmatter_RejectsNonUtf8SkillFile()
+ {
+ var exception = Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(),
+ [new McpServerSkillFile { Path = "SKILL.md", Content = new byte[] { 0x2D, 0x2D, 0x2D, 0x0A, 0xFF, 0xFE } }]));
+
+ Assert.Contains("UTF-8", exception.Message);
+ }
+
+ [Fact]
+ public void Create_RejectsNullArguments()
+ {
+ Assert.Throws(() => McpServerSkill.Create(null!, Frontmatter(), []));
+ Assert.Throws(() => McpServerSkill.Create(SkillUri, null!, []));
+ Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), null!));
+ Assert.Throws(() => McpServerSkill.Create((IEnumerable)null!));
+ Assert.Throws(() => McpServerSkill.Create((string)null!, []));
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory((string)null!));
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, (string)null!));
+ }
+
+ [Fact]
+ public void CreateFromDirectory_LoadsFilesRecursively()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(Path.Combine(directory, "templates", "regional"));
+ File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown);
+ File.WriteAllText(Path.Combine(directory, "templates", "invoice.md"), "# Invoice\n");
+ File.WriteAllBytes(Path.Combine(directory, "templates", "regional", "logo.png"), [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE]);
+
+ // All three overloads agree.
+ var skill = McpServerSkill.CreateFromDirectory(directory);
+ Assert.Equal(SkillUri, skill.ProtocolSkill.Uri);
+ Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Uri);
+ Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory).ProtocolSkill.Uri);
+
+ var uris = skill.ProtocolSkill.Resources.Resources!.Select(r => r.Uri).ToList();
+ Assert.Equal(
+ [
+ "skill://git-workflow/SKILL.md",
+ "skill://git-workflow/templates/invoice.md",
+ "skill://git-workflow/templates/regional/logo.png",
+ ], uris);
+
+ Assert.Equal("image/png", skill.Resources[2].ProtocolResource!.MimeType);
+ Assert.Equal(6, skill.ProtocolSkill.Resources.Resources![2].Size);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void Create_EscapesUriSyntaxCharactersInFilePaths()
+ {
+ var skill = McpServerSkill.Create(SkillUri, Frontmatter(),
+ [
+ McpServerSkillFile.FromText("SKILL.md", SkillMarkdown),
+ McpServerSkillFile.FromText("templates/{name} v2.md", "template"),
+ McpServerSkillFile.FromText("notes/a#b?c.md", "note"),
+ ]);
+
+ var manifest = skill.ProtocolSkill.Resources.Resources!;
+ Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri);
+ Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri);
+
+ // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI.
+ for (int i = 0; i < manifest.Count; i++)
+ {
+ Assert.False(skill.Resources[i].IsTemplated);
+ Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri);
+ }
+
+ Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name);
+ }
+
+ [Fact]
+ public void Create_KeepsItsOwnCopyOfTheEntry()
+ {
+ var frontmatter = Frontmatter();
+ var skill = McpServerSkill.Create(SkillUri, frontmatter, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]);
+ var manifestEntry = skill.ProtocolSkill.Resources.Resources![0];
+
+ // The caller goes on to edit the frontmatter object it passed in, and an entry it was handed back.
+ frontmatter["description"] = "changed";
+ var returned = skill.ProtocolSkill;
+ returned.Frontmatter["name"] = "changed";
+ returned.Resources.Resources![0].Digest = "sha256:" + new string('0', 64);
+ returned.Resources = SkillResources.Dynamic;
+
+ // None of that reaches the skill, so what WithSkills registers is what Create computed. (That the served
+ // bytes are likewise a copy of the caller's buffer is checked end to end in McpServerSkillsIntegrityTests.)
+ var fresh = skill.ProtocolSkill;
+ Assert.Equal("git-workflow", fresh.Name);
+ Assert.Equal("Git conventions", fresh.Description);
+ Assert.Equal(manifestEntry.Digest, fresh.Resources.Resources![0].Digest);
+ Assert.NotSame(returned, fresh);
+ }
+
+ [Fact]
+ public void Create_RequiresAStringNameToDeriveTheUri()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: 123\ndescription: d\n---\n")]));
+
+ Assert.Contains("must declare a string 'name'", exception.Message);
+ }
+
+ [Fact]
+ public void Create_RejectsNullFileEntries()
+ {
+ var exception = Assert.Throws(() =>
+ McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), null!]));
+
+ Assert.Contains("null entries", exception.Message);
+ Assert.Equal("files", exception.ParamName);
+ }
+
+#if NET
+ [Fact]
+ public void CreateFromDirectory_RejectsSymbolicLinks()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string skillDirectory = Path.Combine(root, "skill");
+ Directory.CreateDirectory(skillDirectory);
+ File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown);
+ File.WriteAllText(Path.Combine(root, "outside.txt"), "private data");
+
+ try
+ {
+ File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt"));
+ }
+ catch (Exception e) when (e is UnauthorizedAccessException or IOException)
+ {
+ Assert.Skip($"Cannot create symbolic links here: {e.Message}");
+ }
+
+ var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory));
+ Assert.Contains("linked.txt", exception.Message);
+ Assert.Equal("directoryPath", exception.ParamName);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateFromDirectory_RejectsASymbolicLinkAsTheRoot()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "mcp-skill-rootlink-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string target = Path.Combine(root, "target");
+ Directory.CreateDirectory(target);
+ File.WriteAllText(Path.Combine(target, "SKILL.md"), SkillMarkdown);
+
+ string link = Path.Combine(root, "link");
+ try
+ {
+ Directory.CreateSymbolicLink(link, target);
+ }
+ catch (Exception e) when (e is UnauthorizedAccessException or IOException)
+ {
+ Assert.Skip($"Cannot create symbolic links here: {e.Message}");
+ }
+
+ var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(link));
+ Assert.Equal("directoryPath", exception.ParamName);
+
+ // The target itself is fine.
+ Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(target).ProtocolSkill.Uri);
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateFromDirectory_RejectsDirectorySymbolicLinks()
+ {
+ string root = Path.Combine(Path.GetTempPath(), "mcp-skill-dirlink-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string skillDirectory = Path.Combine(root, "skill");
+ Directory.CreateDirectory(skillDirectory);
+ Directory.CreateDirectory(Path.Combine(root, "outside"));
+ File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown);
+ File.WriteAllText(Path.Combine(root, "outside", "secret.txt"), "private data");
+
+ try
+ {
+ Directory.CreateSymbolicLink(Path.Combine(skillDirectory, "linked"), Path.Combine(root, "outside"));
+ }
+ catch (Exception e) when (e is UnauthorizedAccessException or IOException)
+ {
+ Assert.Skip($"Cannot create symbolic links here: {e.Message}");
+ }
+
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory));
+ }
+ finally
+ {
+ Directory.Delete(root, recursive: true);
+ }
+ }
+#endif
+
+ [Fact]
+ public void CreateFromDirectory_RejectsTooManyFilesBeforeReadingThem()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-many-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown);
+ for (int i = 0; i < SkillsProtocol.MaxResourcesPerSkill; i++)
+ {
+ File.WriteAllText(Path.Combine(directory, $"f{i:D4}.txt"), "x");
+ }
+
+ var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory));
+ Assert.Contains(SkillsProtocol.MaxResourcesPerSkill.ToString(), exception.Message);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateFromDirectory_RejectsOversizedContentBeforeReadingIt()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-big-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ Directory.CreateDirectory(directory);
+ File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown);
+ using (var big = new FileStream(Path.Combine(directory, "big.bin"), FileMode.CreateNew))
+ {
+ // A sparse file: the size check must trip without the bytes ever being read.
+ big.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1);
+ }
+
+ var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory));
+ Assert.Contains(SkillsProtocol.MaxTotalSizeBytes.ToString(), exception.Message);
+ }
+ finally
+ {
+ Directory.Delete(directory, recursive: true);
+ }
+ }
+
+ [Fact]
+ public void CreateFromDirectory_WithMissingDirectory_Throws()
+ {
+ string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-missing-" + Guid.NewGuid().ToString("N"));
+
+ Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory));
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs
new file mode 100644
index 000000000..deba3df28
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs
@@ -0,0 +1,321 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// End-to-end tests for skills served through a custom : pagination, skills that
+/// are served but not listed, dynamic skills, the error contract, and the protocol-version gating of
+/// resultType, ttlMs, and cacheScope (issue #1721).
+///
+public class McpServerSkillsCatalogTests : ClientServerTestBase
+{
+ private const string TamperedUri = "skill://tampered/SKILL.md";
+ private const string UnlistedUri = "skill://hidden/SKILL.md";
+ private const string DynamicUri = "skill://generated/SKILL.md";
+ private const string BrokenUri = "skill://broken/SKILL.md";
+ private const string SwappedUri = "skill://swapped/SKILL.md";
+
+ public McpServerSkillsCatalogTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ var listed = new[] { "alpha", "beta", "gamma" }.Select(name => CreateEntry($"skill://{name}/SKILL.md", name)).ToList();
+
+ // The manifest declares one content, the resource serves another.
+ var tampered = CreateEntry(TamperedUri, "tampered", Encoding.UTF8.GetBytes("the content the host approved"));
+
+ // Served and answerable through skills/get, but withheld from skills/list.
+ var unlisted = CreateEntry(UnlistedUri, "hidden");
+
+ var dynamic = new Skill
+ {
+ Uri = DynamicUri,
+ Frontmatter = new JsonObject { ["name"] = "generated", ["description"] = "Assembled from live data" },
+ Resources = SkillResources.Dynamic,
+ };
+
+ // A catalog bug: an entry whose manifest omits its own SKILL.md. The handler must not publish it.
+ var broken = new Skill
+ {
+ Uri = BrokenUri,
+ Frontmatter = new JsonObject { ["name"] = "broken", ["description"] = "d" },
+ Resources = SkillResources.FromResources([new SkillResource { Uri = "skill://broken/other.md", Digest = "sha256:" + new string('a', 64), Size = 1 }]),
+ };
+
+ var catalog = new PartialCatalog(
+ listed: new InMemoryMcpSkillCatalog([.. listed, tampered, dynamic], pageSize: 2),
+ unlisted: unlisted,
+ broken: broken);
+
+ mcpServerBuilder
+ .WithSkills(catalog, options =>
+ {
+ options.TimeToLive = TimeSpan.FromMinutes(5);
+ options.CacheScope = CacheScope.Public;
+ })
+ .WithResources([McpServerResource.Create(() => "the content the server actually serves", new() { UriTemplate = TamperedUri })]);
+ }
+
+ private static Skill CreateEntry(string uri, string name, byte[]? content = null)
+ {
+ content ??= Encoding.UTF8.GetBytes($"---\nname: {name}\n---\n");
+ return new Skill
+ {
+ Uri = uri,
+ Frontmatter = new JsonObject { ["name"] = name, ["description"] = $"The {name} skill" },
+ Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }]),
+ };
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_FollowsPaginationToTheEnd()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+
+ Assert.Equal(5, skills.Count);
+ Assert.Equal(skills.Select(s => s.Uri).OrderBy(u => u, StringComparer.Ordinal), skills.Select(s => s.Uri));
+ Assert.DoesNotContain(skills, s => s.Uri == UnlistedUri);
+ }
+
+ [Fact]
+ public async Task ListSkillsAsync_PerPage_ExposesCursorAndCacheHints()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+ Assert.Equal(2, first.Skills.Count);
+ Assert.NotNull(first.NextCursor);
+ Assert.Equal(TimeSpan.FromMinutes(5), first.TimeToLive);
+ Assert.Equal(CacheScope.Public, first.CacheScope);
+
+ var second = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = first.NextCursor }, TestContext.Current.CancellationToken);
+ Assert.Equal(2, second.Skills.Count);
+ Assert.NotEqual(first.Skills[0].Uri, second.Skills[0].Uri);
+ }
+
+ [Fact]
+ public async Task GetSkillAsync_AnswersForSkillAbsentFromListing()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var skill = await client.GetSkillAsync(UnlistedUri, TestContext.Current.CancellationToken);
+
+ Assert.Equal("hidden", skill.Name);
+ }
+
+ [Fact]
+ public async Task SkillsGet_WithCatalogAnsweringForADifferentUri_ReturnsInternalError()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(SwappedUri, TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
+ Assert.Contains(SwappedUri, exception.Message);
+ }
+
+ [Fact]
+ public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.GetSkillAsync(BrokenUri, TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode);
+ Assert.Contains("invalid entry", exception.Message);
+ }
+
+ [Fact]
+ public async Task SkillsList_WithInvalidCustomCatalogEntry_ReturnsInternalError()
+ {
+ await using var client = await CreateMcpClientForServer();
+
+ var error = await Assert.ThrowsAsync(
+ async () => await client.ListSkillsAsync(
+ new ListSkillsRequestParams { Cursor = "broken-entry" }, TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InternalError, error.ErrorCode);
+ Assert.Contains("invalid entry", error.Message);
+ }
+
+ [Fact]
+ public async Task DynamicSkill_IsListedWithTheDynamicMarker()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken);
+ var dynamic = Assert.Single(skills, s => s.Uri == DynamicUri);
+
+ Assert.True(dynamic.Resources.IsDynamic);
+ await Assert.ThrowsAsync(
+ async () => await client.ReadSkillResourceAsync(dynamic, DynamicUri, TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task ReadSkillResourceAsync_RejectsContentThatDoesNotMatchTheManifest()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+ var skill = await client.GetSkillAsync(TamperedUri, TestContext.Current.CancellationToken);
+
+ var exception = await Assert.ThrowsAsync(
+ async () => await client.ReadSkillResourceAsync(skill, TamperedUri, TestContext.Current.CancellationToken));
+
+ Assert.Contains(TamperedUri, exception.Message);
+
+ // The unverified read itself still works through the base API; only the verified path refuses it.
+ var raw = await client.ReadResourceAsync(TamperedUri, cancellationToken: TestContext.Current.CancellationToken);
+ Assert.Single(raw.Contents);
+ }
+
+ [Theory]
+ [InlineData("""{ "uri": 42 }""")]
+ [InlineData("""{ }""")]
+ [InlineData("""{ "uri": "" }""")]
+ public async Task SkillsGet_WithInvalidParams_ReturnsInvalidParams(string paramsJson)
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = JsonNode.Parse(paramsJson) },
+ TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
+ }
+
+ [Theory]
+ [InlineData("""{ "cursor": "not-base64!!" }""")]
+ [InlineData("""{ "cursor": 42 }""")]
+ public async Task SkillsList_WithInvalidCursor_ReturnsInvalidParams(string paramsJson)
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList, Params = JsonNode.Parse(paramsJson) },
+ TestContext.Current.CancellationToken));
+
+ Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode);
+ }
+
+ [Fact]
+ public async Task SkillsList_WithoutParams_Succeeds()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var response = await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList },
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, response.Result!["skills"]!.AsArray().Count);
+ }
+
+ [Fact]
+ public async Task SkillsMethods_On2026_07_28Session_IncludeResultTypeAndCacheHints()
+ {
+ await using McpClient client = await CreateMcpClientForServer(
+ new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
+ Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion);
+
+ var list = (await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+ Assert.Equal("complete", list["resultType"]?.GetValue());
+ Assert.Equal(300_000, list["ttlMs"]?.GetValue());
+ Assert.Equal("public", list["cacheScope"]?.GetValue());
+
+ var get = (await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+ Assert.Equal("complete", get["resultType"]?.GetValue());
+ Assert.False(get.ContainsKey("nextCursor"));
+ Assert.Equal(300_000, get["ttlMs"]?.GetValue());
+ Assert.Equal("public", get["cacheScope"]?.GetValue());
+
+ var typed = await client.GetSkillAsync(new GetSkillRequestParams { Uri = UnlistedUri }, TestContext.Current.CancellationToken);
+ var cacheable = Assert.IsAssignableFrom(typed);
+ Assert.Equal(TimeSpan.FromMinutes(5), cacheable.TimeToLive);
+ Assert.Equal(CacheScope.Public, cacheable.CacheScope);
+ }
+
+ [Fact]
+ public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints()
+ {
+ await using McpClient client = await CreateMcpClientForServer(
+ new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion });
+ Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion);
+
+ var list = (await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+ Assert.False(list.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/list result.");
+ Assert.False(list.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/list result.");
+ Assert.False(list.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/list result.");
+ Assert.Equal(2, list["skills"]!.AsArray().Count);
+
+ var get = (await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+ Assert.False(get.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/get result.");
+ Assert.False(get.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/get result.");
+ Assert.False(get.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/get result.");
+ Assert.Equal(UnlistedUri, get["skill"]!["uri"]!.GetValue());
+ }
+
+ ///
+ /// A catalog that lists some skills and serves one more by URI only.
+ ///
+ private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted, Skill broken) : IMcpSkillCatalog
+ {
+ public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ AssertContext(context, SkillsProtocol.MethodSkillsList);
+ if (string.Equals(cursor, "broken-entry", StringComparison.Ordinal))
+ {
+ return new ValueTask(new McpSkillPage { Skills = [broken] });
+ }
+ return listed.ListAsync(cursor, context, cancellationToken);
+ }
+
+ public async ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken)
+ {
+ AssertContext(context, SkillsProtocol.MethodSkillsGet);
+ if (string.Equals(uri, unlisted.Uri, StringComparison.Ordinal))
+ {
+ return unlisted;
+ }
+
+ if (string.Equals(uri, broken.Uri, StringComparison.Ordinal))
+ {
+ return broken;
+ }
+
+ if (string.Equals(uri, SwappedUri, StringComparison.Ordinal))
+ {
+ // A catalog bug: a valid entry, for the wrong skill.
+ return await listed.GetAsync("skill://alpha/SKILL.md", context, cancellationToken);
+ }
+
+ return await listed.GetAsync(uri, context, cancellationToken);
+ }
+
+ // The catalog receives the request it is answering, so a per-caller catalog can decide from it.
+ private static void AssertContext(McpSkillRequestContext context, string method)
+ {
+ Assert.Equal(method, context.JsonRpcRequest.Method);
+ Assert.Null(context.User);
+ }
+ }
+}
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs
new file mode 100644
index 000000000..22211794c
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs
@@ -0,0 +1,101 @@
+using Microsoft.Extensions.DependencyInjection;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Extensions.Skills;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using System.Text.Json.Nodes;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Verifies that a server configured with WithSkills and no options still satisfies the 2026-07-28
+/// requirement that skills/list and skills/get carry ttlMs and cacheScope, using the same conservative
+/// defaults the SDK applies to the built-in list methods.
+///
+public class McpServerSkillsDefaultsTests : ClientServerTestBase
+{
+ public McpServerSkillsDefaultsTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ mcpServerBuilder.WithSkills(
+ [
+ McpServerSkill.Create(
+ "skill://alpha/SKILL.md",
+ new JsonObject { ["name"] = "alpha", ["description"] = "d" },
+ [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: d\n---\n")]),
+ ]);
+ }
+
+ [Fact]
+ public async Task SkillsList_WithDefaultOptions_On2026_07_28_CarriesConservativeCacheHints()
+ {
+ await using McpClient client = await CreateMcpClientForServer(
+ new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion });
+
+ var result = (await client.SendRequestAsync(
+ new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+
+ Assert.Equal("complete", result["resultType"]?.GetValue());
+ Assert.Equal(0, result["ttlMs"]?.GetValue());
+ Assert.Equal("private", result["cacheScope"]?.GetValue());
+
+ var typed = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken);
+ Assert.Equal(TimeSpan.Zero, typed.TimeToLive);
+ Assert.Equal(CacheScope.Private, typed.CacheScope);
+ }
+
+ [Theory]
+ [InlineData(McpProtocolVersions.July2026ProtocolVersion, true)]
+ [InlineData(McpProtocolVersions.November2025ProtocolVersion, false)]
+ public async Task SkillsGet_WithDefaultOptions_GatesCacheHintsByProtocolVersion(string protocolVersion, bool includesCacheHints)
+ {
+ await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion });
+ var result = (await client.SendRequestAsync(
+ new JsonRpcRequest
+ {
+ Method = SkillsProtocol.MethodSkillsGet,
+ Params = new JsonObject { ["uri"] = "skill://alpha/SKILL.md" },
+ },
+ TestContext.Current.CancellationToken)).Result!.AsObject();
+ var typed = await client.GetSkillAsync(
+ new GetSkillRequestParams { Uri = "skill://alpha/SKILL.md" }, TestContext.Current.CancellationToken);
+ var cacheable = Assert.IsAssignableFrom(typed);
+
+ Assert.Equal("skill://alpha/SKILL.md", result["skill"]!["uri"]!.GetValue());
+ Assert.False(result.ContainsKey("nextCursor"));
+ if (includesCacheHints)
+ {
+ Assert.Equal("complete", result["resultType"]?.GetValue());
+ Assert.Equal(0, result["ttlMs"]?.GetValue