Skip to content

Commit 6b0870c

Browse files
committed
Repair Skills cache hints and frontmatter parsing
Add ICacheableResult and source-generated cache-hint serialization to skills/get. Apply the configured hints or zero/private defaults on 2026-07-28 requests, and keep all versioned fields off 2025-11-25 replies. Read scalar values starting on the next line, preserve folded line breaks around TAB-led content, and reject comments without whitespace after a quoted scalar or flow collection. Keep plain hashes, existing chomping rules and non-JSON number rejection intact. Add parser, authoring, wire and serialization regressions. Exercise the new cache fields and authoring cases in the native AOT app. Update the cache option docs and pin both sample links to their published source.
1 parent f3d028b commit 6b0870c

24 files changed

Lines changed: 977 additions & 117 deletions

‎docs/concepts/skills/skills.md‎

Lines changed: 60 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,19 @@ A skill entry (<xref:ModelContextProtocol.Extensions.Skills.Skill>) is a complet
3131
that cannot be digested.
3232

3333
A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each
34-
one against the manifest. That verification, and the rule that an unlisted file is a change to the skill, is what
35-
lets a user's approval bind to specific content.
34+
one against the manifest. Before loading `SKILL.md`, it must also parse the fetched YAML and compare every
35+
frontmatter field with the held entry. Digest and size verification alone does not establish that equality.
36+
37+
### Responsibility boundaries
38+
39+
The protocol carries structured metadata; it does not require a particular parser or provider.
40+
41+
| Layer | Responsibility |
42+
|---|---|
43+
| Application / catalog | Supply frontmatter identical to the complete frontmatter in the file, and serve matching file bytes and manifests. Structural entry validation does not read or validate the YAML on a catalog's behalf. |
44+
| Optional automatic authoring helper | `McpServerSkill.Create` / `CreateFromDirectory` without explicit metadata use the bounded `SkillFrontmatter` reader. Unsupported or malformed input is rejected, not replaced with guessed metadata. |
45+
| Explicit-metadata authoring | Keep the helper's enforced checks. If the bounded reader cannot fully read unsupported YAML, the application must parse and validate the entire frontmatter with a suitable mechanism and supply the correct JSON. |
46+
| Host | Independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before loading, regardless of server authoring path. Reject unsupported input or use a suitable full parser; server-provided JSON is not verification. |
3647

3748
### Serving skills
3849

@@ -75,7 +86,7 @@ var refunds = McpServerSkill.Create(
7586
builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]);
7687
```
7788

78-
All of these validate the skill against the specification and throw <xref:System.ArgumentException> with a
89+
All of these validate structural entry and file constraints and throw <xref:System.ArgumentException> with a
7990
specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the
8091
Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or
8192
the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`,
@@ -88,20 +99,28 @@ names containing characters with URI syntax (such as `{`, `?`, or a space) are p
8899
#### Frontmatter
89100

90101
<xref:ModelContextProtocol.Extensions.Skills.SkillFrontmatter> reads the YAML frontmatter of a `SKILL.md` into a
91-
<xref:System.Text.Json.Nodes.JsonObject> without a YAML library. It accepts the subset of YAML that Agent Skills
92-
frontmatter uses: block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block
102+
<xref:System.Text.Json.Nodes.JsonObject> without a YAML library. This is an optional bounded reader, not a
103+
requirement for serving structured metadata through a catalog. It supports block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block
93104
scalars, and comments. Unquoted scalars are resolved per the YAML 1.2 core schema (`null`, booleans, integers, finite
94-
floats, otherwise strings), matching the YAML libraries used by other SDKs and by hosts. That matters because a
105+
floats, otherwise strings). YAML parsers can differ in their supported forms and resolution; identical semantics
106+
across other SDKs or libraries are not assumed. That matters because a
95107
host verifies a skill by parsing the fetched `SKILL.md` itself and comparing field by field against the published
96108
entry; a value that one side types as a number and the other as a string is a verification failure. Quote values
97109
such as version numbers that are meant to be strings.
98110

99-
Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are
100-
valid YAML the reader does not support; it rejects them with a <xref:System.FormatException> naming the construct. For such a
101-
file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the frontmatter
102-
directly. That escape hatch covers only valid-but-unsupported YAML: a file with no frontmatter block, malformed
103-
YAML, or invalid UTF-8 is rejected regardless, since no host could parse it either. When the reader can parse the
104-
file, an explicit object must match it exactly, or the skill is rejected at construction rather than by every host.
111+
Automatic authoring requires the bounded reader to succeed. Unsupported constructs and detected malformed input
112+
raise an error; no fallback object is invented. Unsupported forms include anchors, aliases, tags, complex keys,
113+
nested and multi-line flow collections, and multi-line quoted scalars.
114+
115+
The explicit `JsonObject` overloads retain UTF-8 and frontmatter-boundary checks, entry/URI validation, and manifest
116+
digest/size computation from the served bytes. They also reject format errors detected by the reader and reject
117+
metadata that differs from a frontmatter block the reader can parse completely.
118+
119+
**The explicit overloads do not guarantee whole-document YAML validation.** An unsupported construct can stop
120+
parsing before later content is examined. The application must parse and validate the **entire frontmatter**
121+
with a suitable mechanism and supply identical JSON before relying on that path. An unsupported-format error
122+
is not proof that the rest of the document is valid, and acceptance of supplied metadata is not proof of equality.
123+
This boundary does not relax the specification's requirement that published frontmatter match the file.
105124

106125
#### Custom catalogs
107126

@@ -128,7 +147,9 @@ the caller's <xref:System.Security.Claims.ClaimsPrincipal> when the transport su
128147
transport does), and any items that incoming-message filters attached to the request.
129148

130149
A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with
131-
`resources/read`. A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers
150+
`resources/read`. The application/catalog author must supply frontmatter identical to the file. The transport
151+
validates entry structure, not full file contents, and does not require use of the optional authoring reader.
152+
A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers
132153
for every skill the server serves to the caller. Throw <xref:ModelContextProtocol.McpProtocolException> with
133154
<xref:ModelContextProtocol.McpErrorCode.InvalidParams> for a cursor the catalog cannot interpret.
134155
<xref:ModelContextProtocol.Extensions.Skills.InMemoryMcpSkillCatalog> is the built-in implementation over a fixed
@@ -139,14 +160,16 @@ set of entries and can be composed into a custom one.
139160
A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to
140161
<xref:ModelContextProtocol.Extensions.Skills.SkillResources.Dynamic> and serve it through a catalog. Such a skill
141162
offers no content integrity, and hosts may decline to load it.
163+
If a host accepts a dynamic skill, parsing and comparing its fetched frontmatter is still required.
142164

143165
#### Protocol versions and caching hints
144166

145-
`skills/list` results carry the base protocol's `ttlMs` and `cacheScope` list-caching attributes, configured through
167+
`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, configured through
146168
<xref:ModelContextProtocol.Extensions.Skills.McpSkillsOptions>. Those attributes, and `resultType`, are defined
147169
from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later,
148170
where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in
149-
list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted.
171+
list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use
172+
the same configured hints. Only use `public` when both the listing and individual entries are identical for every caller.
150173

151174
### Consuming skills
152175

@@ -155,6 +178,8 @@ host's side of the protocol:
155178

156179
```csharp
157180
using ModelContextProtocol.Extensions.Skills;
181+
using ModelContextProtocol.Protocol;
182+
using System.Text.Json.Nodes;
158183

159184
if (!client.SupportsSkills())
160185
{
@@ -169,6 +194,19 @@ Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md");
169194

170195
// Read a file and verify it against the held entry's digest and size.
171196
ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri);
197+
198+
// This example loads text SKILL.md resources and rejects other content forms.
199+
if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text)
200+
{
201+
throw new SkillVerificationException("A text SKILL.md is required for this example.");
202+
}
203+
204+
// A parse failure, including unsupported YAML, rejects this load. Do not fall back to skill.Frontmatter.
205+
var fetchedFrontmatter = SkillFrontmatter.Parse(text.Text);
206+
if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
207+
{
208+
throw new SkillVerificationException("The fetched frontmatter differs from the held entry.");
209+
}
172210
```
173211

174212
`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural
@@ -206,10 +244,13 @@ specification places most of the burden on hosts. In particular:
206244
identity and URI.
207245

208246
The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against
209-
the fetched `SKILL.md` automatically. A host can do so with
247+
the fetched `SKILL.md` automatically. **The host must perform that independent check before loading**, regardless
248+
of whether the server used a catalog, automatic authoring, or explicit metadata. For the supported subset it can use
210249
<xref:ModelContextProtocol.Extensions.Skills.SkillFrontmatter.Parse(System.String)> on the fetched text and
211250
<xref:System.Text.Json.Nodes.JsonNode.DeepEquals(System.Text.Json.Nodes.JsonNode,System.Text.Json.Nodes.JsonNode)>
212-
against the entry's frontmatter, treating a parse failure or a difference as a verification failure.
251+
against the entry's frontmatter. Unsupported content must be rejected or parsed with a suitable full parser;
252+
malformed input or a difference is a verification failure. Never use the server's JSON as a substitute for parsing
253+
the fetched file. These examples do not implement the host's complete approval, origin-isolation or loading policy.
213254

214255
### Not implemented
215256

@@ -218,7 +259,7 @@ Servers built with this package do not declare `directoryRead`, and hosts must n
218259

219260
### Samples
220261

221-
- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer): a Streamable
262+
- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/ecb11e621b0ae46685314729a74c0cb8fe283d44/samples/SkillsServer): a Streamable
222263
HTTP server serving two skills from directories on disk.
223-
- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient): a client that
264+
- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/ecb11e621b0ae46685314729a74c0cb8fe283d44/samples/SkillsClient): a client that
224265
connects to it and discovers, retrieves, and verifies them.

‎samples/SkillsClient/Program.cs‎

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// 1. Connect over Streamable HTTP and check that the server declares the extension.
44
// 2. Enumerate the skills with skills/list (the client extension follows pagination for you).
55
// 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions.
6-
// 4. Read the skill's files with resources/read and verify each one against the manifest's digest and size.
6+
// 4. Verify fetched bytes against the manifest, then independently parse and compare SKILL.md frontmatter.
77
// 5. Show that a file the manifest does not list is refused before any request is sent.
88
//
99
// Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as
@@ -12,6 +12,7 @@
1212
using ModelContextProtocol.Client;
1313
using ModelContextProtocol.Extensions.Skills;
1414
using ModelContextProtocol.Protocol;
15+
using System.Text.Json.Nodes;
1516

1617
var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001");
1718

@@ -63,12 +64,32 @@
6364

6465
Console.WriteLine();
6566

66-
// 4. Verified reads. ReadSkillResourceAsync issues resources/read and checks size and digest against the held
67-
// entry; a mismatch throws SkillVerificationException and the content must not be used.
67+
// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter.
68+
// The host must independently verify frontmatter before loading, regardless of the server's authoring path.
6869
Console.WriteLine("=== resources/read (verified) ===");
6970
ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri);
70-
Console.WriteLine($" {SkillUri} verified:");
71-
Console.WriteLine(Indent(((TextResourceContents)skillFile.Contents[0]).Text));
71+
if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText)
72+
{
73+
throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification.");
74+
}
75+
76+
try
77+
{
78+
var fetchedFrontmatter = SkillFrontmatter.Parse(skillText.Text);
79+
if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter))
80+
{
81+
throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry.");
82+
}
83+
}
84+
catch (FormatException exception)
85+
{
86+
// This sample rejects unsupported YAML. A full host may instead use a suitable full parser,
87+
// but must never treat the server-provided JSON as verification of the fetched file.
88+
throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception);
89+
}
90+
91+
Console.WriteLine($" {SkillUri} manifest and frontmatter verified:");
92+
Console.WriteLine(Indent(skillText.Text));
7293

7394
string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri;
7495
ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri);

‎samples/SkillsClient/README.md‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ doing what a host's skill-loading path does:
1010
overload exposes the `ttlMs` and `cacheScope` hints).
1111
3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it
1212
found in server instructions.
13-
4. Reads the skill's files with `resources/read` and verifies each against the manifest's digest and size
14-
(`client.ReadSkillResourceAsync(skill, uri)`).
13+
4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size
14+
(`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, independently parses its
15+
frontmatter and compares every field with the held entry.
1516
5. Shows that a file the manifest does not list is refused before any request is sent.
1617

1718
## Run
@@ -52,7 +53,7 @@ Expected output (abridged):
5253
...
5354
5455
=== resources/read (verified) ===
55-
skill://git-workflow/SKILL.md verified:
56+
skill://git-workflow/SKILL.md manifest and frontmatter verified:
5657
| ---
5758
| name: git-workflow
5859
...
@@ -65,5 +66,11 @@ Expected output (abridged):
6566

6667
- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come
6768
from the same server. Treat skill content as untrusted model input and tag it with its originating server.
68-
- The SDK does not verify frontmatter automatically. A host can re-parse the fetched `SKILL.md` with
69-
`SkillFrontmatter.Parse` and compare it to the entry with `JsonNode.DeepEquals` before loading a skill.
69+
- Digest and size verification are not frontmatter verification. Every host must independently parse the
70+
fetched `SKILL.md` and compare all fields with the held entry before loading, regardless of how the server
71+
authored the metadata.
72+
- This sample accepts text `SKILL.md` resources and uses `SkillFrontmatter.Parse` for its supported subset.
73+
It rejects mismatches, malformed input and unsupported YAML. A host needing other YAML forms must use a
74+
suitable full parser, never the server-provided JSON as a verification fallback.
75+
- This is not a complete host implementation: approval binding, origin isolation and permission decisions
76+
remain application responsibilities.

‎samples/SkillsServer/README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ SHA-256 digest and size of every file, and registers the `skills/list` and `skil
1515
resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from
1616
its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`.
1717

18+
This is the optional automatic provider path. Its bounded `SkillFrontmatter` reader rejects unsupported or
19+
malformed input rather than guessing metadata. Applications may instead supply structured metadata through a
20+
catalog. When using explicit-metadata authoring for unsupported YAML, the application must parse and validate
21+
the entire frontmatter and supply identical JSON; acceptance by the helper is not proof of full YAML validity.
22+
1823
## Run
1924

2025
```bash
@@ -39,5 +44,7 @@ Any MCP host that supports Streamable HTTP can also be pointed at `http://localh
3944
URI with `skills/get` and read it with `resources/read` without ever listing the catalog.
4045
- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`.
4146
A catalog that varies by principal must not do that.
47+
- Hosts must independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before
48+
loading, regardless of the server's authoring path. Digest/size checks alone do not perform that comparison.
4249
- See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough,
4350
including how to implement a custom `IMcpSkillCatalog`.

0 commit comments

Comments
 (0)