From 976da8f01920b8cd162c054dfa394cde7d1f31d8 Mon Sep 17 00:00:00 2001 From: Peder Date: Tue, 8 Sep 2026 22:53:10 +0200 Subject: [PATCH 01/14] Widen PaginatedRequestParams and PaginatedResult constructors to protected Both base classes had private protected constructors, so a package outside Core could not implement a paginated method. The Skills extension's skills/list is paginated. Widening to protected is source and binary compatible and passes package validation against the 2.0.0 baseline. Carried over from #1856. Co-authored-by: Girish Konda Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- .../Protocol/PaginatedRequest.cs | 10 ++++++++-- .../Protocol/PaginatedResult.cs | 9 ++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) 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() { } From ff9e4b9d85c96bb5161528281266d8dbdc0003d3 Mon Sep 17 00:00:00 2001 From: Peder Date: Tue, 8 Sep 2026 22:53:15 +0200 Subject: [PATCH 02/14] Add ModelContextProtocol.Extensions.Skills (SEP-2640) Implements the MCP Skills extension (io.modelcontextprotocol/skills) as a separate package alongside Extensions.Apps and Extensions.Tasks. Protocol: Skill, SkillResource, SkillResources (a closed array-or-"dynamic" union whose converter rejects null and every other shape), and the skills/list and skills/get request and result types, source-generated for AOT. ListSkillsResult links Core's internal CacheScopeConverter so unknown cacheScope values do not break deserialization of a listing. Server: McpServerSkill.Create and CreateFromDirectory build a skill's entry and its file resources together, computing each digest and size from the bytes the resource serves. Text is served as TextResourceContents only when the bytes round-trip through UTF-8, since hosts hash the UTF-8 of the text they receive; everything else is a blob. WithSkills(IEnumerable) registers the methods, the extension and resources capabilities, and every file resource; WithSkills(IMcpSkillCatalog) backs the methods with a custom catalog. InMemoryMcpSkillCatalog uses keyset cursors. Every entry is validated at construction against the specification's structural MUSTs. Client: SupportsSkills, ListSkillsAsync (all pages or one), GetSkillAsync, and ReadSkillResourceAsync, which reads through resources/read and verifies size and digest against the held entry, refusing unlisted URIs before any request is sent. SkillVerifier exposes the same checks. resultType, ttlMs, and cacheScope are emitted only on requests negotiated under 2026-07-28 or later, where the latter two default to 0 and private when unset, matching Core's handling of the built-in list methods (#1721). YAML frontmatter parsing and resources/directory/read are deliberately not included; see the docs page and PR description. The SkillResources union and converter and the keyset-cursor catalog design are carried over from #1856. Co-authored-by: Girish Konda Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- ModelContextProtocol.slnx | 3 + README.md | 2 + src/Directory.Build.targets | 1 + .../Client/McpSkillsClientExtensions.cs | 238 +++++++++++++ .../Client/SkillVerificationException.cs | 39 +++ .../Client/SkillVerifier.cs | 197 +++++++++++ .../McpSkillsJsonContext.cs | 20 ++ ...elContextProtocol.Extensions.Skills.csproj | 61 ++++ .../Protocol/GetSkillRequestParams.cs | 24 ++ .../Protocol/GetSkillResult.cs | 26 ++ .../Protocol/ListSkillsRequestParams.cs | 12 + .../Protocol/ListSkillsResult.cs | 40 +++ .../Protocol/Skill.cs | 74 ++++ .../Protocol/SkillResource.cs | 46 +++ .../Protocol/SkillResources.cs | 64 ++++ .../Protocol/SkillResourcesConverter.cs | 79 +++++ .../Server/IMcpSkillCatalog.cs | 53 +++ .../Server/InMemoryMcpSkillCatalog.cs | 132 +++++++ .../Server/McpServerSkill.cs | 322 ++++++++++++++++++ .../Server/McpServerSkillFile.cs | 52 +++ .../Server/McpSkillPage.cs | 22 ++ .../Server/McpSkillsBuilderExtensions.cs | 235 +++++++++++++ .../Server/McpSkillsOptions.cs | 41 +++ .../SkillValidation.cs | 226 ++++++++++++ .../SkillsProtocol.cs | 57 ++++ src/PACKAGE.md | 2 + 26 files changed, 2068 insertions(+) create mode 100644 src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..97e90457c 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -49,6 +49,8 @@ + + @@ -69,6 +71,7 @@ + diff --git a/README.md b/README.md index 71902e4e8..7ef8a27ca 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Skills](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Skills.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills) - MCP Skills extension for serving and consuming Agent Skills with verifiable file manifests. + - **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/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.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs new file mode 100644 index 000000000..9ca1a4a4b --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -0,0 +1,238 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to discover, retrieve, and verify skills served under the +/// MCP Skills extension (SEP-2640). +/// +public static class McpSkillsClientExtensions +{ + /// + /// Gets whether the server declared the MCP Skills extension in its capabilities. + /// + /// The client. + /// if the server serves skills/list and skills/get; otherwise, . + /// is . + /// + /// Clients must issue skills/list and skills/get only after observing the server's declaration. + /// The other methods in this class throw when it is absent. + /// + public static bool SupportsSkills(this McpClient client) + { +#if NET + ArgumentNullException.ThrowIfNull(client); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); +#endif + + return client.ServerCapabilities?.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true; + } + + /// + /// Retrieves the entries of every skill the server lists, following pagination to the end. + /// + /// The client. + /// The to monitor for cancellation requests. The default is . + /// The listed skills. + /// is . + /// The server did not declare the MCP Skills extension. + /// The request failed or the server returned an error response. + /// + /// + /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than + /// it serves. Do not treat an empty listing as proof that a server has no skills; a skill's entry can always be + /// retrieved by URI with . + /// + /// + /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use + /// , which returns one page at a time. + /// + /// + public static async ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) + { + List? skills = null; + ListSkillsRequestParams requestParams = new(); + do + { + var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); + skills ??= new(page.Skills.Count); + skills.AddRange(page.Skills); + requestParams.Cursor = page.NextCursor; + } + while (requestParams.Cursor is not null); + + return skills; + } + + /// + /// Retrieves one page of the skills the server lists. + /// + /// The client. + /// The request parameters, including the cursor of the page to retrieve. + /// The to monitor for cancellation requests. The default is . + /// The page, as returned by the server. + /// or is . + /// The server did not declare the MCP Skills extension. + /// The request failed or the server returned an error response. + public static async ValueTask ListSkillsAsync( + this McpClient client, + ListSkillsRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(ListSkillsAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsList, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + } + + /// + /// Retrieves the entry for a single skill by the URI of its SKILL.md. + /// + /// The client. + /// The URI of the skill's SKILL.md. + /// The to monitor for cancellation requests. The default is . + /// The skill's entry. + /// or is . + /// The server did not declare the MCP Skills extension. + /// + /// The request failed or the server returned an error response, including + /// when the server serves no skill at . + /// + /// + /// A server answers for every skill it serves, whether or not the skill appears in its listing. Use this to + /// confirm a URI referenced from server instructions or another skill, and to refresh one skill's manifest + /// without re-enumerating the catalog. + /// + public static async ValueTask GetSkillAsync(this McpClient client, string uri, CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri }, cancellationToken).ConfigureAwait(false); + return result.Skill; + } + + /// + /// Retrieves the entry for a single skill using explicit request parameters. + /// + /// The client. + /// The request parameters. + /// The to monitor for cancellation requests. The default is . + /// The result, as returned by the server. + /// or is . + /// The server did not declare the MCP Skills extension. + /// The request failed or the server returned an error response. + public static async ValueTask GetSkillAsync( + this McpClient client, + GetSkillRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(GetSkillAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsGet, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.GetSkillRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + return response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); + } + + /// + /// Reads one of a skill's files through resources/read and verifies the content against the skill's manifest. + /// + /// The client. + /// The skill entry being acted on. + /// The URI of the file to read. It must be listed in 's manifest. + /// The to monitor for cancellation requests. The default is . + /// The verified contents. + /// An argument is . + /// + /// 's manifest is . Such a skill offers no content + /// integrity; read its files with + /// only if the host has decided to load unverifiable skills. + /// + /// + /// is not listed in the manifest, or the content's size or digest does not match its entry. + /// + /// The request failed or the server returned an error response. + /// + /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is + /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with + /// and proceed from the new manifest. + /// + public static async ValueTask ReadSkillResourceAsync( + this McpClient client, + Skill skill, + string uri, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + if (skill.Resources.IsDynamic) + { + throw new InvalidOperationException( + $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified. " + + $"Use {nameof(McpClient.ReadResourceAsync)} directly if unverifiable content is acceptable."); + } + + if (SkillVerifier.FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + SkillVerifier.Verify(skill, result); + return result; + } + + private static void ThrowIfSkillsNotSupported(McpClient client, string operationName) + { + if (!SupportsSkills(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs new file mode 100644 index 000000000..7f1c153d7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs @@ -0,0 +1,39 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// The exception thrown when a skill file's content does not match its manifest entry, or when a file is read +/// that the manifest does not list. +/// +/// +/// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale +/// because the skill was updated after the entry was fetched. In all cases the content must not be used. To +/// recover from staleness, fetch a fresh entry with skills/get and proceed from its manifest; because the +/// manifest changed, any approval bound to the previous one is revoked. +/// +public sealed class SkillVerificationException : McpException +{ + /// + /// Initializes a new instance of the class. + /// + public SkillVerificationException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public SkillVerificationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public SkillVerificationException(string message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs new file mode 100644 index 000000000..7696c984f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -0,0 +1,197 @@ +using ModelContextProtocol.Protocol; +using System.Security.Cryptography; +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Verifies skill file content against a skill's manifest, and computes manifest digests. +/// +/// +/// +/// When a host retrieves a file listed in a skill's manifest, it must verify the content against that entry's +/// digest and size, and must treat a read of a file the manifest does not list as a verification failure. These +/// helpers implement those checks. Frontmatter verification (re-parsing the fetched SKILL.md and comparing +/// its YAML frontmatter against the entry) is not implemented here, since this package does not parse YAML. +/// +/// +/// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest +/// and the content are consistent, not that either is trustworthy. +/// +/// +public static class SkillVerifier +{ + /// + /// Computes the manifest digest of content: sha256: followed by the lowercase hexadecimal SHA-256 of the bytes. + /// + /// The raw bytes. + /// The digest. + public static string ComputeDigest(ReadOnlySpan content) + { +#if NET + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(content, hash); +#else + byte[] hash; + using (var sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(content.ToArray()); + } +#endif + + var builder = new StringBuilder(SkillsProtocol.DigestPrefix, SkillsProtocol.DigestPrefix.Length + (hash.Length * 2)); + foreach (byte b in hash) + { + builder.Append(ToHexChar(b >> 4)).Append(ToHexChar(b & 0xF)); + } + + return builder.ToString(); + + static char ToHexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + } + + /// + /// Verifies raw content against a manifest entry. + /// + /// The manifest entry. + /// The bytes that were read. + /// is . + /// The size or digest of does not match . + public static void Verify(SkillResource expected, ReadOnlySpan content) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); +#endif + + if (content.Length != expected.Size) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' is {content.Length} bytes, but its manifest entry declares {expected.Size} bytes."); + } + + string actualDigest = ComputeDigest(content); + if (!string.Equals(actualDigest, expected.Digest, StringComparison.OrdinalIgnoreCase)) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' has digest '{actualDigest}', but its manifest entry declares '{expected.Digest}'."); + } + } + + /// + /// Verifies the contents returned by resources/read against a manifest entry. + /// + /// The manifest entry. + /// The contents that were read. + /// An argument is . + /// + /// The contents are not for 's URI, are neither text nor a blob, or their size or + /// digest does not match . + /// + /// + /// Text contents are hashed as their UTF-8 encoding; blob contents are hashed as their decoded bytes. + /// + public static void Verify(SkillResource expected, ResourceContents contents) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(contents); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); + if (contents is null) throw new ArgumentNullException(nameof(contents)); +#endif + + if (!string.Equals(contents.Uri, expected.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The contents are for '{contents.Uri}', but verification was requested against the manifest entry for '{expected.Uri}'."); + } + + switch (contents) + { + case TextResourceContents text: + Verify(expected, Encoding.UTF8.GetBytes(text.Text)); + break; + + case BlobResourceContents blob: + ReadOnlyMemory decoded; + try + { + decoded = blob.DecodedData; + } + catch (FormatException e) + { + throw new SkillVerificationException($"The blob contents of '{contents.Uri}' are not valid Base64.", e); + } + + Verify(expected, decoded.Span); + break; + + default: + throw new SkillVerificationException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified."); + } + } + + /// + /// Verifies the result of a resources/read for one of a skill's files against the skill's manifest. + /// + /// The skill entry being acted on. + /// The result of reading a file of the skill. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// The result is empty, contains contents for a URI the manifest does not list, or contains contents whose + /// size or digest does not match the manifest. + /// + public static void Verify(Skill skill, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + if (skill.Resources.IsDynamic) + { + throw new InvalidOperationException( + $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified."); + } + + if (result.Contents is not { Count: > 0 }) + { + throw new SkillVerificationException($"The read returned no contents to verify against skill '{skill.Uri}'."); + } + + foreach (var contents in result.Contents) + { + var expected = FindResource(skill, contents.Uri) ?? + throw new SkillVerificationException( + $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + + Verify(expected, contents); + } + } + + internal static SkillResource? FindResource(Skill skill, string uri) + { + var resources = skill.Resources.Resources; + if (resources is null) + { + return null; + } + + foreach (var resource in resources) + { + if (string.Equals(resource.Uri, uri, StringComparison.Ordinal)) + { + return resource; + } + } + + return null; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs new file mode 100644 index 000000000..10e451bc2 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Provides source-generated JSON serialization metadata for the MCP Skills extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(Skill))] +[JsonSerializable(typeof(SkillResource))] +[JsonSerializable(typeof(SkillResources))] +[JsonSerializable(typeof(ListSkillsRequestParams))] +[JsonSerializable(typeof(ListSkillsResult))] +[JsonSerializable(typeof(GetSkillRequestParams))] +[JsonSerializable(typeof(GetSkillResult))] +public sealed partial class McpSkillsJsonContext : JsonSerializerContext +{ +} diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj new file mode 100644 index 000000000..0cbb0696c --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -0,0 +1,61 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Skills + MCP Skills extension (SEP-2640) for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs new file mode 100644 index 000000000..4ba7ff040 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs @@ -0,0 +1,24 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the parameters for a skills/get request retrieving a single skill's entry by URI. +/// +/// +/// See the Skills extension specification +/// for details. +/// +public sealed class GetSkillRequestParams : RequestParams +{ + /// + /// Gets or sets the URI of the skill's SKILL.md. + /// + /// + /// If the URI does not identify a skill the server serves, the server returns error -32602 + /// (Invalid params), the same code resources/read uses for unknown resources. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs new file mode 100644 index 000000000..2c9432fc3 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs @@ -0,0 +1,26 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a server's response to a skills/get request, containing one skill's entry. +/// +/// +/// +/// A server answers for every skill it serves, whether or not that skill appears in its skills/list +/// result. The result carries no pagination cursor. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class GetSkillResult : Result +{ + /// + /// Gets or sets the skill's entry, identical in shape and meaning to an entry of skills/list. + /// + [JsonPropertyName("skill")] + public required Skill Skill { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs new file mode 100644 index 000000000..41bd87a95 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs @@ -0,0 +1,12 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the parameters for a skills/list request enumerating the skills a server serves. +/// +/// +/// See the Skills extension specification +/// for details. +/// +public sealed class ListSkillsRequestParams : PaginatedRequestParams; diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs new file mode 100644 index 000000000..bc18bbcb9 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -0,0 +1,40 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a server's response to a skills/list request, containing the skills it serves. +/// +/// +/// +/// The result may be empty or partial. A server whose skill catalog is large, generated on demand, or +/// otherwise unenumerable may return fewer skills than it serves, and hosts must not treat an empty listing as +/// proof that a server has no skills. Skills absent from a listing remain retrievable through skills/get. +/// +/// +/// An entry is atomic: a skill's manifest is never split across pages. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class ListSkillsResult : PaginatedResult, ICacheableResult +{ + /// + /// Gets or sets the skill entries. + /// + [JsonPropertyName("skills")] + public IList Skills { get; set; } = []; + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs new file mode 100644 index 000000000..c604df30d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the entry for a single skill, as returned by skills/list and skills/get. +/// +/// +/// +/// An entry is a complete, point-in-time snapshot of a skill: the URI of its SKILL.md, the verbatim +/// frontmatter of that file, and a manifest of the skill's files with their digests and sizes. A host that +/// pages through a listing has everything it needs to build its registry, present a skill for approval, and +/// verify every file it later reads, without a second round-trip per skill. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class Skill +{ + /// + /// Gets or sets the resource URI of the skill's SKILL.md, readable via resources/read. + /// + /// + /// The path segment preceding /SKILL.md must equal the name field of , + /// so that a skill's name is recoverable from its URI alone. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + /// + /// Gets or sets the skill's SKILL.md YAML frontmatter, rendered verbatim as a JSON object. + /// + /// + /// Every field the author wrote is passed through, not a curated subset. name and description + /// are always present. Hosts re-parse the fetched SKILL.md and compare its frontmatter against this + /// object field by field, treating any discrepancy as a verification failure, so this must reproduce the + /// authored frontmatter exactly. + /// + [JsonPropertyName("frontmatter")] + public required JsonObject Frontmatter { get; set; } + + /// + /// Gets or sets the skill's file manifest: an enumeration of every file with its digest and size, or + /// when the skill's content is generated and cannot be digested. + /// + [JsonPropertyName("resources")] + public required SkillResources Resources { get; set; } + + /// + /// Gets the skill's name from , or if it is absent or not a string. + /// + /// + /// A skill's name is a label, not an identifier. Skills are identified by within a server, + /// and by the pair of server identity and across servers. + /// + [JsonIgnore] + public string? Name => GetFrontmatterString("name"); + + /// + /// Gets the skill's description from , or if it is absent or not a string. + /// + [JsonIgnore] + public string? Description => GetFrontmatterString("description"); + + private string? GetFrontmatterString(string key) => + Frontmatter.TryGetPropertyValue(key, out var node) && + node is JsonValue value && + value.TryGetValue(out string? text) + ? text + : null; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs new file mode 100644 index 000000000..d58590a1b --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs @@ -0,0 +1,46 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a single file belonging to a skill, as listed in a manifest. +/// +/// +/// +/// This is distinct from , the base protocol's resource +/// metadata type. A carries only the integrity information a host needs to verify +/// a skill's file: its URI, digest, and size. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +public sealed class SkillResource +{ + /// + /// Gets or sets the resource URI of the file, readable via resources/read. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + /// + /// Gets or sets the SHA-256 digest of the file's raw bytes, formatted as sha256:{hex} where + /// {hex} is 64 lowercase hexadecimal characters. + /// + /// + /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the + /// manifest and the content are consistent; it is not a security boundary and must not be treated as one. + /// + [JsonPropertyName("digest")] + public required string Digest { get; set; } + + /// + /// Gets or sets the length in bytes of the file's raw content, being the same bytes covers. + /// + /// + /// A read whose byte length differs from this value is a verification failure equivalent to a digest mismatch. + /// + [JsonPropertyName("size")] + public required long Size { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs new file mode 100644 index 000000000..68aa5b2dd --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs @@ -0,0 +1,64 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a skill's file manifest: either a complete enumeration of the skill's files, or the +/// "dynamic" marker indicating that the skill's content is generated and cannot be digested. +/// +/// +/// +/// The specification requires this value on every skill entry and admits exactly two forms. An entry with no +/// manifest at all, or with any value other than an array or the string "dynamic", is invalid and hosts +/// must not load it. Use or to construct one; there is +/// deliberately no public constructor, so an invalid manifest cannot be produced by accident. +/// +/// +/// See the Skills extension specification +/// for details. +/// +/// +[JsonConverter(typeof(SkillResourcesConverter))] +public sealed class SkillResources +{ + private SkillResources(IReadOnlyList? resources) => Resources = resources; + + /// + /// Gets a manifest representing a skill whose content is generated dynamically. + /// + /// + /// A skill declared this way offers no content integrity and cannot be content-bound. Hosts may decline + /// to load it, and server authors should expect that some will. + /// + public static SkillResources Dynamic { get; } = new(null); + + /// + /// Creates a manifest enumerating every file of a skill. + /// + /// + /// The skill's complete file list. It must include an entry whose URI equals the skill's own + /// , carrying the digest and size of the SKILL.md itself. + /// + /// A manifest wrapping a copy of . + /// is . + public static SkillResources FromResources(IEnumerable resources) + { +#if NET + ArgumentNullException.ThrowIfNull(resources); +#else + if (resources is null) throw new ArgumentNullException(nameof(resources)); +#endif + + return new SkillResources(resources.ToArray()); + } + + /// + /// Gets a value indicating whether this manifest is the "dynamic" marker. + /// + public bool IsDynamic => Resources is null; + + /// + /// Gets the enumerated files, or when is . + /// + public IReadOnlyList? Resources { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs new file mode 100644 index 000000000..8c8aea26e --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs @@ -0,0 +1,79 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Serializes as either a JSON array of or the +/// literal string "dynamic", and rejects every other shape. +/// +internal sealed class SkillResourcesConverter : JsonConverter +{ + /// + /// Gets a value indicating that this converter is invoked for null tokens. + /// + /// + /// Without this, System.Text.Json assigns directly for a reference type and never + /// calls , so "resources": null would be silently accepted. The specification + /// requires a manifest on every entry and admits only an array or the "dynamic" string, so a null + /// must be rejected like any other invalid value. + /// + public override bool HandleNull => true; + + public override SkillResources Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + switch (reader.TokenType) + { + case JsonTokenType.Null: + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found null."); + + case JsonTokenType.String: + string? sentinel = reader.GetString(); + if (!string.Equals(sentinel, SkillsProtocol.DynamicResourcesSentinel, StringComparison.Ordinal)) + { + throw new JsonException( + $"Invalid skill manifest: expected the string '{SkillsProtocol.DynamicResourcesSentinel}' but found '{sentinel}'."); + } + + return SkillResources.Dynamic; + + case JsonTokenType.StartArray: + var resources = new List(); + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + return SkillResources.FromResources(resources); + } + + var resource = JsonSerializer.Deserialize(ref reader, McpSkillsJsonContext.Default.SkillResource) ?? + throw new JsonException("Invalid skill manifest: a resource entry was null."); + resources.Add(resource); + } + + throw new JsonException("Invalid skill manifest: unterminated array."); + + default: + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found {reader.TokenType}."); + } + } + + public override void Write(Utf8JsonWriter writer, SkillResources value, JsonSerializerOptions options) + { + if (value.IsDynamic) + { + writer.WriteStringValue(SkillsProtocol.DynamicResourcesSentinel); + return; + } + + writer.WriteStartArray(); + foreach (var resource in value.Resources!) + { + JsonSerializer.Serialize(writer, resource, McpSkillsJsonContext.Default.SkillResource); + } + + writer.WriteEndArray(); + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs new file mode 100644 index 000000000..9407384a7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -0,0 +1,53 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Supplies the skills a server serves. +/// +/// +/// +/// A catalog is the source of truth behind a server's skills/list and skills/get methods. +/// serves a fixed set of entries; implement this interface directly when +/// skills come from a database, a file share, or another source that should not be loaded up front. +/// +/// +/// The two methods are deliberately independent. A server may enumerate only part of its catalog, or none of +/// it, while still answering for every skill it serves by URI. +/// +/// +/// A catalog is responsible only for the entries. The skills' files must additionally be served as ordinary +/// resources through resources/read, since that is how hosts fetch skill content. +/// +/// +public interface IMcpSkillCatalog +{ + /// + /// Lists a page of the skills this catalog publishes. + /// + /// + /// An opaque cursor returned by a previous call, or to start at the first page. + /// + /// The to monitor for cancellation requests. + /// + /// A page of entries, and the cursor for the following page when more entries remain. A skill's manifest is + /// never split across pages. + /// + /// + /// Returning an empty page is valid. Hosts must not treat an empty listing as proof that a server has no skills. + /// Throw with for a cursor that + /// this catalog did not issue. + /// + ValueTask ListAsync(string? cursor, CancellationToken cancellationToken); + + /// + /// Gets the entry for a single skill by the URI of its SKILL.md. + /// + /// The URI of the skill's SKILL.md. + /// The to monitor for cancellation requests. + /// + /// The skill's entry, or if this catalog does not serve a skill at . + /// + /// + /// This must answer for every skill the server serves, including skills omitted from . + /// + ValueTask GetAsync(string uri, CancellationToken cancellationToken); +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs new file mode 100644 index 000000000..5277376d4 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -0,0 +1,132 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// An over a fixed set of skill entries held in memory. +/// +/// +/// +/// Every entry is validated against the specification's structural requirements when the catalog is constructed, +/// so a server cannot publish an entry a conforming host would refuse to load. +/// +/// +/// Entries are ordered by URI so that pagination is stable across calls. Cursors are keyset cursors over that +/// order rather than offsets. +/// +/// +public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog +{ + private readonly Skill[] _ordered; + private readonly string[] _orderedUris; + private readonly Dictionary _byUri; + private readonly int _pageSize; + + /// + /// Initializes a new instance of the class. + /// + /// The skills this catalog serves. + /// The maximum number of entries returned per call. Defaults to 50. + /// is . + /// is less than 1. + /// + /// Two skills share the same URI, or an entry violates the specification's structural requirements (for example, + /// its name does not match its URI, its manifest omits its own SKILL.md, or a digest is malformed). + /// + public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) + { +#if NET + ArgumentNullException.ThrowIfNull(skills); + ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1); +#else + if (skills is null) throw new ArgumentNullException(nameof(skills)); + if (pageSize < 1) throw new ArgumentOutOfRangeException(nameof(pageSize)); +#endif + + _pageSize = pageSize; + _byUri = new Dictionary(StringComparer.Ordinal); + foreach (var skill in skills) + { + SkillValidation.Validate(skill, nameof(skills)); + + if (_byUri.ContainsKey(skill.Uri)) + { + throw new ArgumentException($"Duplicate skill URI '{skill.Uri}'.", nameof(skills)); + } + + _byUri.Add(skill.Uri, skill); + } + + _ordered = [.. _byUri.Values]; + Array.Sort(_ordered, static (left, right) => string.CompareOrdinal(left.Uri, right.Uri)); + _orderedUris = Array.ConvertAll(_ordered, static skill => skill.Uri); + } + + /// + /// Gets the number of skills in this catalog. + /// + public int Count => _ordered.Length; + + /// + public ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + int start = 0; + if (DecodeCursor(cursor) is { } afterUri) + { + int index = Array.BinarySearch(_orderedUris, afterUri, StringComparer.Ordinal); + start = index >= 0 ? index + 1 : ~index; + } + + int count = Math.Min(_pageSize, _ordered.Length - start); + if (count <= 0) + { + return new ValueTask(McpSkillPage.Empty); + } + + var page = new Skill[count]; + Array.Copy(_ordered, start, page, 0, count); + + bool hasMore = start + count < _ordered.Length; + return new ValueTask(new McpSkillPage + { + Skills = page, + NextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null, + }); + } + + /// + public ValueTask GetAsync(string uri, CancellationToken cancellationToken) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + cancellationToken.ThrowIfCancellationRequested(); + + _byUri.TryGetValue(uri, out var skill); + return new ValueTask(skill); + } + + private static string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri)); + + private static string? DecodeCursor(string? cursor) + { + if (string.IsNullOrEmpty(cursor)) + { + return null; + } + + try + { + return Encoding.UTF8.GetString(Convert.FromBase64String(cursor!)); + } + catch (FormatException) + { + throw new McpProtocolException($"Invalid cursor '{cursor}'.", McpErrorCode.InvalidParams); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs new file mode 100644 index 000000000..4756cf6c1 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -0,0 +1,322 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a skill served by an MCP server: its entry together with the +/// instances that serve the skill's files. +/// +/// +/// +/// The specification requires a skill's manifest to carry the SHA-256 digest and size of every file, and a host +/// refuses content whose bytes do not match. Building a skill through or +/// computes the manifest from the same bytes the resources serve, so the two +/// cannot disagree. +/// +/// +/// The frontmatter is supplied separately from the SKILL.md content and must reproduce that file's YAML +/// frontmatter exactly, field by field. Hosts re-parse the fetched SKILL.md and compare, treating any +/// discrepancy as a verification failure. This package does not parse YAML. +/// +/// +/// Register skills with , +/// which registers both the catalog entries and the file resources. +/// +/// +public sealed class McpServerSkill +{ + private McpServerSkill(Skill protocolSkill, IReadOnlyList resources) + { + ProtocolSkill = protocolSkill; + Resources = resources; + } + + /// + /// Gets the skill's entry, as returned by skills/list and skills/get. + /// + public Skill ProtocolSkill { get; } + + /// + /// Gets the resources serving the skill's files, one per file, each addressable at the URI its manifest entry names. + /// + public IReadOnlyList Resources { get; } + + /// + /// Creates a skill from its files. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md, is missing a required + /// field or its name does not match , omits SKILL.md, + /// contains a duplicate or unsafe path, or exceeds the specification's per-skill limits. + /// + public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); + + // Normalize and order the files: SKILL.md first, then the rest by path, so the manifest is deterministic. + var normalized = new List<(string Path, McpServerSkillFile File)>(); + var seenPaths = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + if (file is null) + { + throw new ArgumentException("The skill's files must not contain null entries.", nameof(files)); + } + + string path = NormalizePath(file.Path); + if (!seenPaths.Add(path)) + { + throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", nameof(files)); + } + + normalized.Add((path, file)); + } + + if (!seenPaths.Contains(SkillsProtocol.SkillFileName)) + { + throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", nameof(files)); + } + + normalized.Sort(static (left, right) => + { + bool leftIsSkillFile = left.Path == SkillsProtocol.SkillFileName; + bool rightIsSkillFile = right.Path == SkillsProtocol.SkillFileName; + if (leftIsSkillFile != rightIsSkillFile) + { + return leftIsSkillFile ? -1 : 1; + } + + return string.CompareOrdinal(left.Path, right.Path); + }); + + // Build and validate the entry before creating any resources, so an invalid skill fails fast with a + // message about the entry rather than about a resource. + var manifest = new List(normalized.Count); + foreach (var (path, file) in normalized) + { + manifest.Add(new SkillResource + { + Uri = root + "/" + path, + Digest = SkillVerifier.ComputeDigest(file.Content.Span), + Size = file.Content.Length, + }); + } + + var skill = new Skill + { + Uri = uri, + Frontmatter = frontmatter, + Resources = SkillResources.FromResources(manifest), + }; + + SkillValidation.Validate(skill, nameof(frontmatter)); + + var resources = new McpServerResource[normalized.Count]; + for (int i = 0; i < normalized.Count; i++) + { + var (path, file) = normalized[i]; + bool isSkillFile = path == SkillsProtocol.SkillFileName; + resources[i] = CreateResource( + manifest[i].Uri, + name: isSkillFile ? skill.Name! : path, + description: isSkillFile ? skill.Description : null, + mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, file.Content.Span)), + file.Content); + } + + return new McpServerSkill(skill, resources); + } + + /// + /// Creates a skill from every file in a directory, recursively. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// The directory's contents do not form a valid skill; see . + /// + /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the + /// manifest or the served content. + /// + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skill directory '{fullDirectory}' does not exist."); + } + + if (!fullDirectory.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + fullDirectory += Path.DirectorySeparatorChar; + } + + var files = new List(); + foreach (string filePath in Directory.EnumerateFiles(fullDirectory, "*", SearchOption.AllDirectories)) + { + string relativePath = filePath.Substring(fullDirectory.Length).Replace(Path.DirectorySeparatorChar, '/'); + if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) + { + relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, '/'); + } + + files.Add(new McpServerSkillFile + { + Path = relativePath, + Content = File.ReadAllBytes(filePath), + }); + } + + return Create(uri, frontmatter, files); + } + + private static string NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("A skill file must have a non-empty path.", nameof(McpServerSkillFile.Path)); + } + + string normalized = path!.Replace('\\', '/'); + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized.Substring(2); + } + + if (normalized.Length == 0 || normalized[0] == '/' || normalized[normalized.Length - 1] == '/') + { + throw new ArgumentException($"The skill file path '{path}' must be relative to the skill's root and must not end in a separator.", nameof(McpServerSkillFile.Path)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == "..") + { + throw new ArgumentException($"The skill file path '{path}' must not contain empty, '.', or '..' segments.", nameof(McpServerSkillFile.Path)); + } + } + + return normalized; + } + + private static McpServerResource CreateResource(string uri, string name, string? description, string mimeType, ReadOnlyMemory content) + { + // Text is served as TextResourceContents only when the bytes round-trip through UTF-8 exactly, because the + // host hashes the UTF-8 encoding of the text it receives and compares it against the manifest digest, which + // was computed over the raw bytes. Anything else is served as a blob, which round-trips by construction. + ResourceContents Read() => + IsTextualMimeType(mimeType) && TryDecodeUtf8(content.Span, out string? text) + ? new TextResourceContents { Uri = uri, MimeType = mimeType, Text = text! } + : BlobResourceContents.FromBytes(content, uri, mimeType); + + return McpServerResource.Create(Read, new McpServerResourceCreateOptions + { + UriTemplate = uri, + Name = name, + Description = description, + MimeType = mimeType, + }); + } + + private static bool TryDecodeUtf8(ReadOnlySpan bytes, out string? text) + { + try + { + text = s_strictUtf8.GetString(bytes.ToArray()); + return true; + } + catch (DecoderFallbackException) + { + text = null; + return false; + } + } + + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private static bool IsTextualMimeType(string mimeType) => + mimeType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/yaml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/toml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/javascript", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/x-sh", StringComparison.OrdinalIgnoreCase); + + private static string GuessMimeType(string path, ReadOnlySpan content) + { + int dot = path.LastIndexOf('.'); + string extension = dot < 0 || dot < path.LastIndexOf('/') ? string.Empty : path.Substring(dot + 1).ToLowerInvariant(); + + return extension switch + { + "md" or "markdown" => "text/markdown", + "txt" => "text/plain", + "csv" => "text/csv", + "html" or "htm" => "text/html", + "css" => "text/css", + "js" or "mjs" => "text/javascript", + "ts" => "text/typescript", + "py" => "text/x-python", + "cs" => "text/x-csharp", + "sh" or "bash" => "application/x-sh", + "json" => "application/json", + "yaml" or "yml" => "application/yaml", + "toml" => "application/toml", + "xml" => "application/xml", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" or "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + _ => TryDecodeUtf8(content, out _) ? "text/plain" : "application/octet-stream", + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs new file mode 100644 index 000000000..a4f9f0040 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs @@ -0,0 +1,52 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one file of a skill authored through . +/// +public sealed class McpServerSkillFile +{ + /// + /// Gets the file's path relative to the skill's root directory, using / as the separator + /// (for example, SKILL.md or references/GUIDE.md). + /// + public required string Path { get; init; } + + /// + /// Gets the file's raw content. Its digest and size in the skill's manifest are computed from exactly these bytes. + /// + public required ReadOnlyMemory Content { get; init; } + + /// + /// Gets the MIME type to advertise for the file's resource, or to infer one from the + /// file's extension. + /// + public string? MimeType { get; init; } + + /// + /// Creates a file from UTF-8 encoded text. + /// + /// The file's path relative to the skill's root directory. + /// The file's content. + /// The MIME type to advertise, or to infer one from . + /// The file. + /// or is . + public static McpServerSkillFile FromText(string path, string text, string? mimeType = null) + { +#if NET + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(text); +#else + if (path is null) throw new ArgumentNullException(nameof(path)); + if (text is null) throw new ArgumentNullException(nameof(text)); +#endif + + return new McpServerSkillFile + { + Path = path, + Content = Encoding.UTF8.GetBytes(text), + MimeType = mimeType, + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs new file mode 100644 index 000000000..ed1f9a55a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs @@ -0,0 +1,22 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one page of skill entries returned by . +/// +public sealed class McpSkillPage +{ + /// + /// Gets an empty page with no following page. + /// + public static McpSkillPage Empty { get; } = new() { Skills = [] }; + + /// + /// Gets the entries in this page. + /// + public required IReadOnlyList Skills { get; init; } + + /// + /// Gets the cursor to pass back for the following page, or when no entries remain. + /// + public string? NextCursor { get; init; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs new file mode 100644 index 000000000..7c4e5fd22 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -0,0 +1,235 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to enable MCP Skills (SEP-2640) support. +/// +public static class McpSkillsBuilderExtensions +{ + /// + /// Enables MCP Skills support for a fixed set of skills, serving both their entries and their files. + /// + /// The server builder. + /// The skills to serve. + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// or is . + /// + /// Two skills share a URI, or two skills list the same file URI with different content. + /// + /// + /// + /// This registers skills/list and skills/get backed by an + /// over the skills' entries, and registers each skill's so the files + /// are served through resources/list and resources/read. + /// + /// + /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, + /// provided every skill lists it with the same digest. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IEnumerable skills, + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(skills); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (skills is null) throw new ArgumentNullException(nameof(skills)); +#endif + + var entries = new List(); + var registeredFiles = new Dictionary(StringComparer.Ordinal); + foreach (var skill in skills) + { + if (skill is null) + { + throw new ArgumentException("The skills must not contain null entries.", nameof(skills)); + } + + entries.Add(skill.ProtocolSkill); + + var manifest = skill.ProtocolSkill.Resources.Resources!; + for (int i = 0; i < manifest.Count; i++) + { + var entry = manifest[i]; + if (registeredFiles.TryGetValue(entry.Uri, out string? existingDigest)) + { + if (!string.Equals(existingDigest, entry.Digest, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The file '{entry.Uri}' is listed by more than one skill with different content.", + nameof(skills)); + } + + continue; + } + + registeredFiles.Add(entry.Uri, entry.Digest); + builder.Services.AddSingleton(skill.Resources[i]); + } + } + + return WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); + } + + /// + /// Enables MCP Skills support backed by the specified catalog. + /// + /// The server builder. + /// The catalog supplying the skills this server serves. + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// or is . + /// + /// + /// This registers skills/list and skills/get and declares the extension in the server's + /// capabilities. Declaring the extension commits the server to both methods. + /// + /// + /// A catalog supplies entries only. The skills' files must be served as ordinary resources through + /// resources/read, so register them as well (for example with WithResources). To have this done + /// automatically, build the skills with and use the + /// overload. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IMcpSkillCatalog catalog, + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(catalog); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (catalog is null) throw new ArgumentNullException(nameof(catalog)); +#endif + + var options = new McpSkillsOptions(); + configure?.Invoke(options); + + builder.Services.AddSingleton>( + _ => new McpSkillsConfigureOptions(catalog, options)); + + return builder; + } + + private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkillsOptions skillsOptions) + : IConfigureOptions + { + public void Configure(McpServerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(options); +#else + if (options is null) throw new ArgumentNullException(nameof(options)); +#endif + + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(SkillsProtocol.ExtensionId)) + { + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + } + + // A server declaring the skills extension must also declare the resources capability, since skill + // files are read through resources/read. + options.Capabilities.Resources ??= new ResourcesCapability(); + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = HandleListSkillsAsync, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + // RoutingNameParameter is deliberately left unset. The specification defines no Mcp-Name header + // mapping for skills/get, so requiring one on Streamable HTTP would reject conforming clients. + Method = SkillsProtocol.MethodSkillsGet, + Handler = HandleGetSkillAsync, + }); + } + + private async ValueTask HandleListSkillsAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams); + var page = await catalog.ListAsync(requestParams?.Cursor, cancellationToken).ConfigureAwait(false); + + var result = new ListSkillsResult + { + Skills = [.. page.Skills], + NextCursor = page.NextCursor, + }; + + // resultType, ttlMs, and cacheScope are 2026-07-28 result fields. Earlier revisions reject them as + // unrecognized keys (issue #1721), so they are only emitted when the request was negotiated under + // 2026-07-28 or later, where ttlMs and cacheScope are required and default to the conservative + // "immediately stale, not shareable" values the SDK uses for the built-in list methods. + if (IsJuly2026OrLaterProtocolRequest(request)) + { + result.ResultType = "complete"; + result.TimeToLive = skillsOptions.TimeToLive ?? TimeSpan.Zero; + result.CacheScope = skillsOptions.CacheScope ?? CacheScope.Private; + } + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.ListSkillsResult); + } + + private async ValueTask HandleGetSkillAsync(JsonRpcRequest request, CancellationToken cancellationToken) + { + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.GetSkillRequestParams); + if (string.IsNullOrEmpty(requestParams?.Uri)) + { + throw new McpProtocolException("The 'uri' parameter is required.", McpErrorCode.InvalidParams); + } + + var skill = await catalog.GetAsync(requestParams!.Uri, cancellationToken).ConfigureAwait(false) ?? + throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams); + + var result = new GetSkillResult { Skill = skill }; + if (IsJuly2026OrLaterProtocolRequest(request)) + { + result.ResultType = "complete"; + } + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); + } + + private static T? DeserializeParams(JsonRpcRequest request, JsonTypeInfo typeInfo) where T : class + { + if (request.Params is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(request.Params, typeInfo); + } + catch (JsonException e) + { + throw new McpProtocolException($"Invalid parameters for '{request.Method}': {e.Message}", McpErrorCode.InvalidParams); + } + } + + /// + /// Returns whether the request was negotiated under the 2026-07-28 protocol revision or later. Under that + /// revision every request carries its protocol version; requests from an initialize-handshake + /// session (2025-11-25 and earlier) carry none, so a missing version means an earlier revision. + /// + private static bool IsJuly2026OrLaterProtocolRequest(JsonRpcRequest request) => + McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request.Context?.ProtocolVersion); + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs new file mode 100644 index 000000000..c0f2ca926 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs @@ -0,0 +1,41 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Configures the behavior of the MCP Skills extension on a server. +/// +public sealed class McpSkillsOptions +{ + /// + /// Gets or sets the freshness hint advertised on skills/list results. + /// + /// + /// + /// This is the base protocol's ttlMs list-caching attribute, with the same semantics as on + /// tools/list. It is a hint about the listing, not an integrity property of the skills' content. + /// + /// + /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that + /// revision or later, a value is sent as (immediately stale), + /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent. + /// + /// + public TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the cache scope advertised on skills/list results. + /// + /// + /// + /// Set this to only when the catalog is identical for every caller. + /// A listing that varies by principal must not be advertised as publicly cacheable. + /// + /// + /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that + /// revision or later, a value is sent as , + /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent. + /// + /// + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs new file mode 100644 index 000000000..52603e7ae --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -0,0 +1,226 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Validates skill entries against the structural requirements of the Skills extension specification, so that +/// a server never publishes an entry a conforming host would refuse to load. +/// +internal static class SkillValidation +{ + private const string SkillFileSuffix = "/" + SkillsProtocol.SkillFileName; + + /// + /// Returns the skill root (the SKILL.md URI with its /SKILL.md suffix removed), or throws if + /// does not end in /SKILL.md. + /// + public static string GetSkillRoot(string skillUri, string paramName) + { + if (string.IsNullOrEmpty(skillUri)) + { + throw new ArgumentException("A skill URI must not be null or empty.", paramName); + } + + if (!skillUri.EndsWith(SkillFileSuffix, StringComparison.Ordinal) || skillUri.Length == SkillFileSuffix.Length) + { + throw new ArgumentException( + $"The skill URI '{skillUri}' must be the URI of the skill's {SkillsProtocol.SkillFileName}, ending in '{SkillFileSuffix}'.", + paramName); + } + + return skillUri.Substring(0, skillUri.Length - SkillFileSuffix.Length); + } + + /// + /// Returns the final path segment of a skill root, which the specification requires to equal the skill's name. + /// + public static string GetNameSegment(string skillRoot) + { + int slash = skillRoot.LastIndexOf('/'); + return slash < 0 ? skillRoot : skillRoot.Substring(slash + 1); + } + + /// + /// Returns whether satisfies the Agent Skills naming rules: 1 to 64 characters, + /// lowercase letters, digits, and hyphens, with no leading, trailing, or consecutive hyphens. + /// + public static bool IsValidSkillName(string name) + { + if (name.Length is 0 or > 64) + { + return false; + } + + char previous = '-'; + foreach (char c in name) + { + bool isAlphanumeric = c is (>= 'a' and <= 'z') or (>= '0' and <= '9'); + if (!isAlphanumeric && c != '-') + { + return false; + } + + if (c == '-' && previous == '-') + { + return false; + } + + previous = c; + } + + return previous != '-'; + } + + /// + /// Returns whether has the form sha256:{hex} with 64 lowercase hexadecimal digits. + /// + public static bool IsValidDigest(string? digest) + { + const int HexLength = 64; + if (digest is null || + digest.Length != SkillsProtocol.DigestPrefix.Length + HexLength || + !digest.StartsWith(SkillsProtocol.DigestPrefix, StringComparison.Ordinal)) + { + return false; + } + + for (int i = SkillsProtocol.DigestPrefix.Length; i < digest.Length; i++) + { + if (digest[i] is not ((>= '0' and <= '9') or (>= 'a' and <= 'f'))) + { + return false; + } + } + + return true; + } + + /// + /// Validates a complete skill entry, throwing describing the first violation found. + /// + public static void Validate(Skill skill, string paramName) + { + if (skill is null) + { + throw new ArgumentNullException(paramName); + } + + string root = GetSkillRoot(skill.Uri, paramName); + string nameSegment = GetNameSegment(root); + + if (skill.Frontmatter is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no frontmatter.", paramName); + } + + string? name = skill.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'name' in its frontmatter.", paramName); + } + + if (!IsValidSkillName(name!)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', which does not satisfy the Agent Skills naming rules " + + "(1 to 64 lowercase letters, digits, and single hyphens, not starting or ending with a hyphen).", + paramName); + } + + if (!string.Equals(name, nameSegment, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', but the path segment preceding /{SkillsProtocol.SkillFileName} is '{nameSegment}'. " + + "The specification requires them to be equal.", + paramName); + } + + if (string.IsNullOrEmpty(skill.Description)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName); + } + + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest. Use SkillResources.Dynamic for generated content.", paramName); + } + + if (skill.Resources.IsDynamic) + { + return; + } + + var resources = skill.Resources.Resources!; + if (resources.Count == 0) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has an empty resources manifest. A manifest must list every file of the skill, {SkillsProtocol.SkillFileName} included.", + paramName); + } + + if (resources.Count > SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists {resources.Count} files, exceeding the limit of {SkillsProtocol.MaxResourcesPerSkill} per skill.", + paramName); + } + + string rootPrefix = root + "/"; + var seen = new HashSet(StringComparer.Ordinal); + bool hasSkillFile = false; + long totalSize = 0; + + foreach (var resource in resources) + { + if (resource is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a null entry in its resources manifest.", paramName); + } + + if (string.IsNullOrEmpty(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a resource with a null or empty URI.", paramName); + } + + if (!resource.Uri.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}', which is not within the skill's directory '{root}'.", + paramName); + } + + if (!seen.Add(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' more than once.", paramName); + } + + if (!IsValidDigest(resource.Digest)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with the digest '{resource.Digest}', " + + "which is not of the form 'sha256:' followed by 64 lowercase hexadecimal digits.", + paramName); + } + + if (resource.Size < 0) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with a negative size.", paramName); + } + + totalSize += resource.Size; + hasSkillFile |= string.Equals(resource.Uri, skill.Uri, StringComparison.Ordinal); + } + + if (!hasSkillFile) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' does not list its own {SkillsProtocol.SkillFileName} in its resources manifest.", + paramName); + } + + if (totalSize > SkillsProtocol.MaxTotalSizeBytes) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' totals {totalSize} bytes, exceeding the limit of {SkillsProtocol.MaxTotalSizeBytes} bytes per skill.", + paramName); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs new file mode 100644 index 000000000..8940fffd2 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs @@ -0,0 +1,57 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Provides constants for the MCP Skills extension (SEP-2640). +/// +/// +/// See the Skills extension specification +/// for details. +/// +public static class SkillsProtocol +{ + /// + /// The extension identifier for the MCP Skills extension, as it appears in the extensions + /// field of a server's capabilities. + /// + public const string ExtensionId = "io.modelcontextprotocol/skills"; + + /// + /// The name of the request method sent from the client to enumerate the skills a server serves. + /// + public const string MethodSkillsList = "skills/list"; + + /// + /// The name of the request method sent from the client to retrieve a single skill's entry by URI. + /// + public const string MethodSkillsGet = "skills/get"; + + /// + /// The file name of the manifest every skill directory must contain at its root. + /// + public const string SkillFileName = "SKILL.md"; + + /// + /// The maximum number of files a single skill may declare in its manifest, included. + /// + /// + /// Hosts must support skills up to and including this limit. Servers should not serve a skill that exceeds it. + /// + public const int MaxResourcesPerSkill = 512; + + /// + /// The maximum total size in bytes of a single skill's files, summed over its manifest. + /// + /// + /// Hosts must support skills up to and including this limit. Servers should not serve a skill that exceeds it. + /// + public const long MaxTotalSizeBytes = 16 * 1024 * 1024; + + /// The value of a skill's resources field when its content is generated dynamically. + internal const string DynamicResourcesSentinel = "dynamic"; + + /// The prefix of a manifest digest. + internal const string DigestPrefix = "sha256:"; + + /// The MIME type recommended for a skill's SKILL.md resource. + internal const string SkillFileMimeType = "text/markdown"; +} diff --git a/src/PACKAGE.md b/src/PACKAGE.md index b3c1d9143..3926c3adb 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -18,6 +18,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Skills](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Skills.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills) - MCP Skills extension for serving and consuming Agent Skills with verifiable file manifests. + - **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started From 0ee2f3f14c3aa1905cf06441c5e2aa05e327fe2d Mon Sep 17 00:00:00 2001 From: Peder Date: Tue, 8 Sep 2026 22:53:20 +0200 Subject: [PATCH 03/14] Add tests and a conformance fixture for the Skills extension Unit tests cover serialization (including every invalid manifest shape and frontmatter pass-through), the in-memory catalog (ordering, keyset pagination, cursor validation, and each structural validation rule), McpServerSkill (manifest computation, path normalization, resource metadata, directory loading), and SkillVerifier (known digest vectors, size and digest mismatches, text versus blob, unlisted files). End-to-end tests drive the client extensions against an in-process server for both WithSkills overloads: listing and pagination, skills served but not listed, dynamic skills, verified reads including a tampered resource, the -32602 error contract, and the wire shape of resultType, ttlMs, and cacheScope on 2025-11-25 and 2026-07-28 sessions, including the defaults applied when no options are set. The conformance server gains the SEP-2640 fixture: three static skills built with McpServerSkill plus a dynamic one, paged two at a time. The SEP-2640 scenarios from modelcontextprotocol/conformance#330 pass 30/30, 6/6, 1/1 stateless and 29/29, 6/6, 1/1 on a 2025-11-25 session. The AOT compatibility app now also lists, gets, and verifies a skill. The serialization tests and the fixture's skill set are carried over from #1856. Co-authored-by: Girish Konda Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- ...xtProtocol.AotCompatibility.TestApp.csproj | 1 + .../Program.cs | 24 +- ...elContextProtocol.ConformanceServer.csproj | 1 + .../Program.cs | 8 + .../Skills/ConformanceSkills.cs | 100 +++++++ .../Client/SkillVerifierTests.cs | 142 ++++++++++ .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/SkillSerializationTests.cs | 265 ++++++++++++++++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 243 ++++++++++++++++ .../Server/McpServerSkillTests.cs | 201 +++++++++++++ .../Server/McpServerSkillsCatalogTests.cs | 234 ++++++++++++++++ .../Server/McpServerSkillsDefaultsTests.cs | 63 +++++ .../Server/McpServerSkillsTests.cs | 164 +++++++++++ 13 files changed, 1446 insertions(+), 1 deletion(-) create mode 100644 tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66edf..db48715d2 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -16,6 +16,7 @@ + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd9361..d4057e4c0 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; @@ -11,7 +12,14 @@ services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithMcpApps(); + .WithMcpApps() + .WithSkills( + [ + McpServerSkill.Create( + "skill://aot/SKILL.md", + new System.Text.Json.Nodes.JsonObject { ["name"] = "aot", ["description"] = "An AOT-published skill." }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription: An AOT-published skill.\n---\n")]), + ]); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -41,6 +49,20 @@ throw new Exception($"Unexpected result: {result}"); } +// List, get, and verify a skill. +var skills = await client.ListSkillsAsync(); +if (skills.Count != 1 || skills[0].Name != "aot") +{ + throw new Exception($"Unexpected skills listing: {skills.Count} entries."); +} + +var skill = await client.GetSkillAsync("skill://aot/SKILL.md"); +var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri); +if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot")) +{ + throw new Exception("Unexpected skill content."); +} + Console.WriteLine("Success!"); [McpServerToolType] diff --git a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj index dffffa9d3..7f2ca8212 100644 --- a/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj +++ b/tests/ModelContextProtocol.ConformanceServer/ModelContextProtocol.ConformanceServer.csproj @@ -15,6 +15,7 @@ + diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 73f63821e..328b523de 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -1,8 +1,10 @@ using ConformanceServer.Prompts; using ConformanceServer.Resources; using ConformanceServer.Tools; +using ModelContextProtocol.ConformanceServer.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; +using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Extensions.Tasks; using System.Collections.Concurrent; using System.Diagnostics; @@ -53,6 +55,12 @@ private static void ConfigureConformanceMcpServer( .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() + .WithSkills(ConformanceSkills.CreateCatalog(), options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }) + .WithResources(ConformanceSkills.CreateResources()) .WithTasks( new InMemoryMcpTaskStore { diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs new file mode 100644 index 000000000..6c9a05ea7 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -0,0 +1,100 @@ +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 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", + Frontmatter("git-workflow", "Follow this team's Git conventions for branching and commits"), + [ + SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"), + ]), + + McpServerSkill.Create( + "skill://pdf-processing/SKILL.md", + Frontmatter("pdf-processing", "Extract, fill, and assemble PDF documents"), + [ + 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", + Frontmatter("refunds", "Process customer refund requests per company policy"), + [ + 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/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs new file mode 100644 index 000000000..0493fc855 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -0,0 +1,142 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for : digest formatting and content verification against a manifest. +/// +public class SkillVerifierTests +{ + private const string Uri = "skill://alpha/SKILL.md"; + + private static SkillResource Entry(byte[] content) => new() + { + Uri = Uri, + Digest = SkillVerifier.ComputeDigest(content), + Size = content.Length, + }; + + [Theory] + [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] + public void ComputeDigest_MatchesKnownVectors(string input, string expected) + { + Assert.Equal(expected, SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(input))); + } + + [Fact] + public void Verify_AcceptsMatchingBytes() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + + SkillVerifier.Verify(Entry(content), content); + } + + [Fact] + public void Verify_RejectsSizeMismatch() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + var entry = Entry(content); + entry.Size += 1; + + var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content)); + Assert.Contains("bytes", exception.Message); + } + + [Fact] + public void Verify_RejectsDigestMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n")); + + var exception = Assert.Throws(() => + SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n"))); + Assert.Contains("digest", exception.Message); + } + + [Fact] + public void Verify_AcceptsUppercaseDigestFromServer() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var entry = Entry(content); + entry.Digest = entry.Digest.ToUpperInvariant().Replace("SHA256", "sha256"); + + SkillVerifier.Verify(entry, content); + } + + [Fact] + public void Verify_TextContents_HashesUtf8Encoding() + { + const string Text = "# HÊllo\n"; + var entry = Entry(Encoding.UTF8.GetBytes(Text)); + + SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = Text }); + } + + [Fact] + public void Verify_BlobContents_HashesDecodedBytes() + { + byte[] content = [0x00, 0xFF, 0x10, 0x80]; + var entry = Entry(content); + + SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri)); + } + + [Fact] + public void Verify_Contents_RejectsUriMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => + SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" })); + } + + [Fact] + public void Verify_Skill_ChecksEveryContentAgainstManifest() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var skill = CreateSkill(content); + + SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] })); + } + + [Fact] + public void Verify_Skill_RejectsUnlistedFile() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] })); + Assert.Contains("not listed", exception.Message); + } + + [Fact] + public void Verify_Skill_RejectsEmptyResult() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); + } + + [Fact] + public void Verify_Skill_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" }] })); + } + + 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/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 677d77357..0ae1d0775 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -84,6 +84,7 @@ + diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs new file mode 100644 index 000000000..d0826dc71 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -0,0 +1,265 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Protocol; + +/// +/// Serialization and deserialization tests for the SEP-2640 skills protocol types, with particular attention to +/// the array-or-"dynamic" union used for a skill's manifest. +/// +public class SkillSerializationTests +{ + private static Skill CreateEntry(SkillResources resources) => new() + { + Uri = "skill://git-workflow/SKILL.md", + Frontmatter = new JsonObject + { + ["name"] = "git-workflow", + ["description"] = "Follow this team's Git conventions", + }, + Resources = resources, + }; + + private static SkillResource CreateResource(string uri, string digest, long size) => new() + { + Uri = uri, + Digest = digest, + Size = size, + }; + + [Fact] + public void Skill_WithEnumeratedResources_RoundTrips() + { + var original = CreateEntry(SkillResources.FromResources( + [ + CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('a', 64), 2314), + CreateResource("skill://git-workflow/examples/email.md", "sha256:" + new string('b', 64), 962), + ])); + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Uri); + Assert.Equal("git-workflow", deserialized.Name); + Assert.Equal("Follow this team's Git conventions", deserialized.Description); + Assert.False(deserialized.Resources.IsDynamic); + + var resources = deserialized.Resources.Resources; + Assert.NotNull(resources); + Assert.Equal(2, resources.Count); + Assert.Equal("skill://git-workflow/SKILL.md", resources[0].Uri); + Assert.Equal(2314, resources[0].Size); + Assert.Equal("sha256:" + new string('b', 64), resources[1].Digest); + } + + [Fact] + public void Skill_WithDynamicResources_SerializesAsTheDynamicString() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.Equal("dynamic", node["resources"]?.GetValue()); + } + + [Fact] + public void Skill_WithDynamicResources_RoundTrips() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.Dynamic), McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Resources.IsDynamic); + Assert.Null(deserialized.Resources.Resources); + } + + [Fact] + public void Skill_WithEmptyResourceArray_RoundTripsAsNonDynamic() + { + string json = JsonSerializer.Serialize(CreateEntry(SkillResources.FromResources([])), McpSkillsJsonContext.Default.Skill); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(deserialized); + Assert.False(deserialized.Resources.IsDynamic); + Assert.Empty(deserialized.Resources.Resources!); + } + + [Fact] + public void Skill_PreservesArbitraryFrontmatterFields() + { + const string Json = """ + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { + "name": "refunds", + "description": "Process refunds", + "license": "Apache-2.0", + "metadata": { "version": "2.1.0", "owner": "billing" }, + "allowed-tools": "Bash(git:*)" + }, + "resources": "dynamic" + } + """; + + var skill = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(skill); + Assert.Equal("Apache-2.0", skill.Frontmatter["license"]?.GetValue()); + Assert.Equal("2.1.0", skill.Frontmatter["metadata"]?["version"]?.GetValue()); + Assert.Equal("Bash(git:*)", skill.Frontmatter["allowed-tools"]?.GetValue()); + + string reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized))); + } + + [Fact] + public void Skill_NameAndDescription_AreNullWhenAbsentOrNotStrings() + { + var skill = new Skill + { + Uri = "skill://x/SKILL.md", + Frontmatter = new JsonObject { ["name"] = 42 }, + Resources = SkillResources.Dynamic, + }; + + Assert.Null(skill.Name); + Assert.Null(skill.Description); + } + + [Theory] + [InlineData("\"static\"")] + [InlineData("\"Dynamic\"")] + [InlineData("\"\"")] + [InlineData("null")] + [InlineData("123")] + [InlineData("true")] + [InlineData("{}")] + [InlineData("[null]")] + public void Skill_WithInvalidResourcesValue_Throws(string resourcesJson) + { + string json = $$""" + { + "uri": "skill://git-workflow/SKILL.md", + "frontmatter": { "name": "git-workflow", "description": "d" }, + "resources": {{resourcesJson}} + } + """; + + Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill)); + } + + [Fact] + public void Skill_WithoutResources_Throws() + { + const string Json = """ + { + "uri": "skill://git-workflow/SKILL.md", + "frontmatter": { "name": "git-workflow", "description": "d" } + } + """; + + Assert.ThrowsAny(() => JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill)); + } + + [Fact] + public void SkillResources_FromResources_CopiesTheSource() + { + var mutable = new List + { + CreateResource("skill://a/SKILL.md", "sha256:" + new string('c', 64), 1), + }; + + var resources = SkillResources.FromResources(mutable); + mutable.Add(CreateResource("skill://a/extra.md", "sha256:" + new string('d', 64), 2)); + + Assert.Single(resources.Resources!); + } + + [Fact] + public void SkillResources_FromResources_WithNull_Throws() => + Assert.Throws(() => SkillResources.FromResources(null!)); + + [Fact] + public void ListSkillsResult_RoundTripsCursorAndCacheAttributes() + { + var original = new ListSkillsResult + { + Skills = [CreateEntry(SkillResources.Dynamic)], + NextCursor = "cursor-1", + ResultType = "complete", + TimeToLive = TimeSpan.FromMinutes(5), + CacheScope = CacheScope.Public, + }; + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsResult); + var node = JsonNode.Parse(json)!.AsObject(); + Assert.Equal(300_000, node["ttlMs"]?.GetValue()); + Assert.Equal("public", node["cacheScope"]?.GetValue()); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsResult); + Assert.NotNull(deserialized); + Assert.Equal("cursor-1", deserialized.NextCursor); + Assert.Equal("complete", deserialized.ResultType); + Assert.Equal(TimeSpan.FromMinutes(5), deserialized.TimeToLive); + Assert.Equal(CacheScope.Public, deserialized.CacheScope); + Assert.Single(deserialized.Skills); + } + + [Fact] + public void ListSkillsResult_ToleratesUnknownCacheScopeOnRead() + { + const string Json = """{ "skills": [], "ttlMs": 0, "cacheScope": "regional" }"""; + + var deserialized = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.ListSkillsResult); + + Assert.NotNull(deserialized); + Assert.Null(deserialized.CacheScope); + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); + } + + [Fact] + public void ListSkillsResult_OmitsOptionalFieldsWhenUnset() + { + string json = JsonSerializer.Serialize(new ListSkillsResult(), McpSkillsJsonContext.Default.ListSkillsResult); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + Assert.False(node.ContainsKey("nextCursor")); + Assert.False(node.ContainsKey("resultType")); + Assert.Empty(node["skills"]!.AsArray()); + } + + [Fact] + public void GetSkillResult_RoundTrips() + { + var original = new GetSkillResult + { + Skill = CreateEntry(SkillResources.FromResources( + [ + CreateResource("skill://git-workflow/SKILL.md", "sha256:" + new string('e', 64), 10), + ])), + ResultType = "complete", + }; + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.GetSkillResult); + Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor")); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + Assert.NotNull(deserialized); + Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Skill.Uri); + Assert.Equal("complete", deserialized.ResultType); + } + + [Fact] + public void RequestParams_RoundTrip() + { + string listJson = JsonSerializer.Serialize(new ListSkillsRequestParams { Cursor = "abc" }, McpSkillsJsonContext.Default.ListSkillsRequestParams); + Assert.Equal("abc", JsonSerializer.Deserialize(listJson, McpSkillsJsonContext.Default.ListSkillsRequestParams)?.Cursor); + + string getJson = JsonSerializer.Serialize(new GetSkillRequestParams { Uri = "skill://a/SKILL.md" }, McpSkillsJsonContext.Default.GetSkillRequestParams); + Assert.Equal("skill://a/SKILL.md", JsonSerializer.Deserialize(getJson, McpSkillsJsonContext.Default.GetSkillRequestParams)?.Uri); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs new file mode 100644 index 000000000..fc9565e29 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -0,0 +1,243 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : ordering, keyset pagination, lookup, cursor validation, and +/// the structural validation of entries against the specification. +/// +public class InMemoryMcpSkillCatalogTests +{ + private static readonly string s_validDigest = "sha256:" + new string('a', 64); + + private static 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, 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, 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, TestContext.Current.CancellationToken); + var second = await catalog.ListAsync(first.NextCursor, 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, 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, TestContext.Current.CancellationToken); + + Assert.Empty(page.Skills); + Assert.Null(page.NextCursor); + } + + [Fact] + public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams() + { + var catalog = CreateCatalog(10, "a"); + + var exception = await Assert.ThrowsAsync( + async () => await catalog.ListAsync("not-base64!!", 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", 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, TestContext.Current.CancellationToken)); + } + + [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_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("name missing", s => s.Frontmatter.Remove("name")); + yield return Case("name not a string", s => s.Frontmatter["name"] = 1); + yield return Case("name does not match uri segment", s => s.Frontmatter["name"] = "beta"); + yield return Case("name violates naming rules", s => + { + s.Uri = "skill://Alpha/SKILL.md"; + s.Frontmatter["name"] = "Alpha"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("description missing", s => s.Frontmatter.Remove("description")); + yield return Case("description empty", s => s.Frontmatter["description"] = ""); + yield return Case("empty manifest", s => s.Resources = SkillResources.FromResources([])); + yield return Case("manifest omits SKILL.md", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = "skill://alpha/other.md", Digest = s_validDigest, Size = 1 }])); + yield return Case("manifest lists file outside the skill", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alphabet/x.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("duplicate manifest entry", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("malformed digest (uppercase)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:" + new string('A', 64), Size = 1 }])); + yield return Case("malformed digest (wrong length)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:abc", Size = 1 }])); + yield return Case("malformed digest (wrong algorithm)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha512:" + new string('a', 64), Size = 1 }])); + yield return Case("negative size", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = -1 }])); + yield return Case("too many resources", s => s.Resources = SkillResources.FromResources( + Enumerable.Range(0, SkillsProtocol.MaxResourcesPerSkill + 1).Select(i => new SkillResource + { + Uri = i == 0 ? s.Uri : $"skill://alpha/f{i}.md", + Digest = s_validDigest, + Size = 1, + }))); + yield return Case("total size over limit", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/big.bin", Digest = s_validDigest, Size = SkillsProtocol.MaxTotalSizeBytes }, + ])); + } + + [Theory] + [MemberData(nameof(InvalidSkills))] + public void Constructor_RejectsInvalidSkill(string reason, Skill skill) + { + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("skill://", exception.Message); + Assert.NotEmpty(reason); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs new file mode 100644 index 000000000..d56336ce1 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -0,0 +1,201 @@ +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 skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + ]); + + var entry = skill.ProtocolSkill; + Assert.Equal(SkillUri, entry.Uri); + Assert.Same(entry.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, "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_RejectsNullArguments() + { + Assert.Throws(() => McpServerSkill.Create(null!, Frontmatter(), [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, null!, [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), 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]); + + var skill = McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory); + + 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 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..64bdbfba6 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -0,0 +1,234 @@ +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"; + + 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, + }; + + var catalog = new PartialCatalog( + listed: new InMemoryMcpSkillCatalog([.. listed, tampered, dynamic], pageSize: 2), + unlisted: unlisted); + + 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 DynamicSkill_IsListedWithTheDynamicMarker() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var dynamic = Assert.Single(skills, s => s.Uri == DynamicUri); + + Assert.True(dynamic.Resources.IsDynamic); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(dynamic, DynamicUri, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsContentThatDoesNotMatchTheManifest() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(TamperedUri, TestContext.Current.CancellationToken); + + var exception = await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, TamperedUri, TestContext.Current.CancellationToken)); + + Assert.Contains(TamperedUri, exception.Message); + + // The unverified read itself still works through the base API; only the verified path refuses it. + var raw = await client.ReadResourceAsync(TamperedUri, cancellationToken: TestContext.Current.CancellationToken); + Assert.Single(raw.Contents); + } + + [Theory] + [InlineData("""{ "uri": 42 }""")] + [InlineData("""{ }""")] + [InlineData("""{ "uri": "" }""")] + public async Task SkillsGet_WithInvalidParams_ReturnsInvalidParams(string paramsJson) + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = JsonNode.Parse(paramsJson) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Theory] + [InlineData("""{ "cursor": "not-base64!!" }""")] + [InlineData("""{ "cursor": 42 }""")] + public async Task SkillsList_WithInvalidCursor_ReturnsInvalidParams(string paramsJson) + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync(async () => await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList, Params = JsonNode.Parse(paramsJson) }, + TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SkillsList_WithoutParams_Succeeds() + { + await using McpClient client = await CreateMcpClientForServer(); + + var response = await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken); + + Assert.Equal(2, response.Result!["skills"]!.AsArray().Count); + } + + [Fact] + public async Task SkillsMethods_On2026_07_28Session_IncludeResultTypeAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + Assert.Equal(McpProtocolVersions.July2026ProtocolVersion, client.NegotiatedProtocolVersion); + + var list = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("complete", list["resultType"]?.GetValue()); + Assert.Equal(300_000, list["ttlMs"]?.GetValue()); + Assert.Equal("public", list["cacheScope"]?.GetValue()); + + var get = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.Equal("complete", get["resultType"]?.GetValue()); + Assert.False(get.ContainsKey("nextCursor")); + Assert.False(get.ContainsKey("ttlMs")); + } + + [Fact] + public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }); + Assert.Equal(McpProtocolVersions.November2025ProtocolVersion, client.NegotiatedProtocolVersion); + + var list = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.False(list.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/list result."); + Assert.False(list.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/list result."); + Assert.False(list.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/list result."); + Assert.Equal(2, list["skills"]!.AsArray().Count); + + var get = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + Assert.False(get.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/get result."); + Assert.Equal(UnlistedUri, get["skill"]!["uri"]!.GetValue()); + } + + /// + /// A catalog that lists some skills and serves one more by URI only. + /// + private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted) : IMcpSkillCatalog + { + public ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) => + listed.ListAsync(cursor, cancellationToken); + + public async ValueTask GetAsync(string uri, CancellationToken cancellationToken) => + string.Equals(uri, unlisted.Uri, StringComparison.Ordinal) + ? unlisted + : await listed.GetAsync(uri, cancellationToken); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs new file mode 100644 index 000000000..a5956df96 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that a server configured with WithSkills and no options still satisfies the 2026-07-28 +/// requirement that skills/list carry ttlMs and cacheScope, using the same conservative +/// defaults the SDK applies to the built-in list methods. +/// +public class McpServerSkillsDefaultsTests : ClientServerTestBase +{ + public McpServerSkillsDefaultsTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithSkills( + [ + McpServerSkill.Create( + "skill://alpha/SKILL.md", + new JsonObject { ["name"] = "alpha", ["description"] = "d" }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: alpha\ndescription: d\n---\n")]), + ]); + } + + [Fact] + public async Task SkillsList_WithDefaultOptions_On2026_07_28_CarriesConservativeCacheHints() + { + await using McpClient client = await CreateMcpClientForServer( + new McpClientOptions { ProtocolVersion = McpProtocolVersions.July2026ProtocolVersion }); + + var result = (await client.SendRequestAsync( + new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + + Assert.Equal("complete", result["resultType"]?.GetValue()); + Assert.Equal(0, result["ttlMs"]?.GetValue()); + Assert.Equal("private", result["cacheScope"]?.GetValue()); + + var typed = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(TimeSpan.Zero, typed.TimeToLive); + Assert.Equal(CacheScope.Private, typed.CacheScope); + } + + [Fact] + public async Task ClientExtensions_ThrowWhenServerDoesNotDeclareSkills() + { + // A client connected to this server sees the extension; fake its absence to exercise the guard. + await using McpClient client = await CreateMcpClientForServer(); + client.ServerCapabilities.Extensions!.Remove(SkillsProtocol.ExtensionId); + + Assert.False(client.SupportsSkills()); + await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetSkillAsync("skill://alpha/SKILL.md", TestContext.Current.CancellationToken)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs new file mode 100644 index 000000000..053015a50 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs @@ -0,0 +1,164 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for skills registered through : capability declaration, listing, +/// retrieval, automatic resource registration, and verified reads through the client extensions. +/// +public class McpServerSkillsTests : ClientServerTestBase +{ + private const string GitWorkflowUri = "skill://git-workflow/SKILL.md"; + private const string GitWorkflowMarkdown = "---\nname: git-workflow\ndescription: Git conventions\nlicense: MIT\n---\n\n# Git workflow\n"; + private const string RefundsUri = "skill://acme/billing/refunds/SKILL.md"; + private static readonly byte[] s_binaryFile = [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE, 0x00]; + + public McpServerSkillsTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var gitWorkflow = McpServerSkill.Create( + GitWorkflowUri, + new JsonObject { ["name"] = "git-workflow", ["description"] = "Git conventions", ["license"] = "MIT" }, + [ + McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown), + McpServerSkillFile.FromText("references/GUIDE.md", "# Guide\n"), + new McpServerSkillFile { Path = "assets/logo.png", Content = s_binaryFile }, + ]); + + var refunds = McpServerSkill.Create( + RefundsUri, + new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds" }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: Process refunds\n---\n")]); + + mcpServerBuilder.WithSkills([gitWorkflow, refunds]); + } + + [Fact] + public async Task Server_DeclaresSkillsExtensionAndResourcesCapability() + { + await using McpClient client = await CreateMcpClientForServer(); + + Assert.True(client.SupportsSkills()); + Assert.NotNull(client.ServerCapabilities.Resources); + + var settings = System.Text.Json.JsonSerializer.SerializeToNode(client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId])?.AsObject(); + Assert.NotNull(settings); + Assert.Empty(settings); + } + + [Fact] + public async Task ListSkillsAsync_ReturnsEveryRegisteredSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, skills.Count); + var gitWorkflow = Assert.Single(skills, s => s.Uri == GitWorkflowUri); + Assert.Equal("git-workflow", gitWorkflow.Name); + Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); + Assert.Equal(3, gitWorkflow.Resources.Resources!.Count); + Assert.Contains(skills, s => s.Uri == RefundsUri && s.Name == "refunds"); + } + + [Fact] + public async Task GetSkillAsync_ReturnsTheRequestedEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skill = await client.GetSkillAsync(RefundsUri, TestContext.Current.CancellationToken); + + Assert.Equal(RefundsUri, skill.Uri); + Assert.Equal("refunds", skill.Name); + Assert.Single(skill.Resources.Resources!); + } + + [Fact] + public async Task GetSkillAsync_WithUnknownUri_ReturnsInvalidParams() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://missing/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SkillFiles_AreListedAsResources() + { + await using McpClient client = await CreateMcpClientForServer(); + + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + var skillFile = Assert.Single(resources, r => r.Uri == GitWorkflowUri); + Assert.Equal("git-workflow", skillFile.Name); + Assert.Equal("Git conventions", skillFile.Description); + Assert.Equal("text/markdown", skillFile.MimeType); + Assert.Contains(resources, r => r.Uri == "skill://git-workflow/references/GUIDE.md"); + Assert.Contains(resources, r => r.Uri == "skill://git-workflow/assets/logo.png" && r.MimeType == "image/png"); + Assert.Contains(resources, r => r.Uri == RefundsUri); + } + + [Fact] + public async Task ReadSkillResourceAsync_ReturnsVerifiedText() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(GitWorkflowMarkdown, text.Text); + Assert.Equal("text/markdown", text.MimeType); + } + + [Fact] + public async Task ReadSkillResourceAsync_ReturnsVerifiedBlobForBinaryFile() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, "skill://git-workflow/assets/logo.png", TestContext.Current.CancellationToken); + + var blob = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(s_binaryFile, blob.DecodedData.ToArray()); + Assert.Equal("image/png", blob.MimeType); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsUnlistedFileWithoutContactingServer() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/NEW.md", TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadSkillResourceAsync_DetectsContentThatNoLongerMatchesTheHeldEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); + + // Simulate a stale held entry: the host approved the skill against a different manifest. + var stale = skill.Resources.Resources!.Select(r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size }).ToList(); + stale[0].Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes("previous content")); + stale[0].Size = Encoding.UTF8.GetByteCount("previous content"); + skill.Resources = SkillResources.FromResources(stale); + + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken)); + } +} From 188d502041269d31d9540ab96dfcbbd2b55ecf9e Mon Sep 17 00:00:00 2001 From: Peder Date: Tue, 8 Sep 2026 22:53:27 +0200 Subject: [PATCH 04/14] Add Skills samples and docs SkillsServer is a Streamable HTTP server serving two skills from directories on disk through McpServerSkill.CreateFromDirectory, one with a nested skill path, and pointing at one of them from its server instructions. SkillsClient connects to it and walks through the host side: capability check, listing with caching hints, retrieval by URI, verified reads of SKILL.md and a supporting file, and the refusal of an unlisted file. The docs page covers serving skills, custom catalogs, dynamic skills, protocol-version handling of the caching hints, the client API, verification, security considerations, and what is deliberately not implemented. README and PACKAGE.md list the new package. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/index.md | 1 + docs/concepts/skills/skills.md | 192 ++++++++++++++++++ docs/concepts/toc.yml | 2 + samples/SkillsClient/Program.cs | 90 ++++++++ samples/SkillsClient/README.md | 69 +++++++ samples/SkillsClient/SkillsClient.csproj | 16 ++ samples/SkillsServer/Program.cs | 63 ++++++ .../Properties/launchSettings.json | 14 ++ samples/SkillsServer/README.md | 45 ++++ .../SkillsServer/Skills/git-workflow/SKILL.md | 25 +++ .../git-workflow/references/COMMIT_STYLE.md | 24 +++ .../git-workflow/templates/PULL_REQUEST.md | 16 ++ samples/SkillsServer/Skills/refunds/SKILL.md | 18 ++ .../Skills/refunds/examples/approved.md | 9 + .../Skills/refunds/examples/declined.md | 9 + .../Skills/refunds/policy/REFUND_POLICY.md | 8 + samples/SkillsServer/SkillsServer.csproj | 19 ++ 17 files changed, 620 insertions(+) create mode 100644 docs/concepts/skills/skills.md create mode 100644 samples/SkillsClient/Program.cs create mode 100644 samples/SkillsClient/README.md create mode 100644 samples/SkillsClient/SkillsClient.csproj create mode 100644 samples/SkillsServer/Program.cs create mode 100644 samples/SkillsServer/Properties/launchSettings.json create mode 100644 samples/SkillsServer/README.md create mode 100644 samples/SkillsServer/Skills/git-workflow/SKILL.md create mode 100644 samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md create mode 100644 samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md create mode 100644 samples/SkillsServer/Skills/refunds/SKILL.md create mode 100644 samples/SkillsServer/Skills/refunds/examples/approved.md create mode 100644 samples/SkillsServer/Skills/refunds/examples/declined.md create mode 100644 samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md create mode 100644 samples/SkillsServer/SkillsServer.csproj 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..93eafca44 --- /dev/null +++ b/docs/concepts/skills/skills.md @@ -0,0 +1,192 @@ +--- +title: Skills +description: Serve and consume Agent Skills over MCP with the Skills extension. +uid: skills +--- + +## Skills + +The Skills extension lets an MCP server publish [Agent Skills](https://agentskills.io/) alongside its tools, +resources, and prompts. A skill is a directory of files, minimally a `SKILL.md` with YAML frontmatter, that gives +an agent structured workflow instructions. Over MCP, each file is an ordinary resource, and the extension adds two +methods for discovering skills and obtaining a verifiable manifest of their files: + +- `skills/list` enumerates the skills a server serves, with pagination. +- `skills/get` returns the entry for a single skill by the URI of its `SKILL.md`. + +Skills are provided by the `ModelContextProtocol.Extensions.Skills` package. The implementation follows the +[Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx) +(SEP-2640, extension id `io.modelcontextprotocol/skills`). + +### Overview + +A skill entry () is a complete, point-in-time snapshot of a skill: + +- `uri`: the resource URI of its `SKILL.md`, conventionally `skill:///SKILL.md`. The final segment + of `` is the skill's name. +- `frontmatter`: the `SKILL.md` YAML frontmatter, rendered verbatim as a JSON object. `name` and `description` + are always present; every other authored field passes through. +- `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size + (), or the string `"dynamic"` for generated content + that cannot be digested. + +A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each +one against the manifest. That verification, and the rule that an unlisted file is a change to the skill, is what +lets a user's approval bind to specific content. + +### Serving skills + +The simplest way to serve skills is to describe each one with + and register them with `WithSkills`. The SDK computes +every digest and size from the same bytes the resources serve, so the manifest and the content cannot disagree, +and registers the file resources for you. + +```csharp +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +var gitWorkflow = McpServerSkill.CreateFromDirectory( + uri: "skill://git-workflow/SKILL.md", + frontmatter: new JsonObject + { + ["name"] = "git-workflow", + ["description"] = "Follow this team's Git conventions for branching and commits.", + }, + directoryPath: Path.Combine(AppContext.BaseDirectory, "Skills", "git-workflow")); + +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithSkills([gitWorkflow], options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); +``` + +`McpServerSkill.Create` takes the files explicitly when they are not on disk: + +```csharp +var skill = McpServerSkill.Create( + "skill://refunds/SKILL.md", + new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds." }, + [ + McpServerSkillFile.FromText("SKILL.md", skillMarkdown), + McpServerSkillFile.FromText("examples/approved.md", approvedTemplate), + new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes }, + ]); +``` + +Both methods validate the skill against the specification and throw with a +specific message when, for example, the frontmatter `name` does not match the URI, `SKILL.md` is missing, or the +skill exceeds the per-skill limits of 512 files or 16 MiB. + +#### Frontmatter + +The frontmatter is supplied as a and must reproduce the YAML frontmatter +at the top of `SKILL.md` exactly, field by field. Hosts re-parse the fetched `SKILL.md` and compare it against the +entry, treating any discrepancy as a verification failure. The SDK does not include a YAML parser and does not +derive the frontmatter from the file, so keep the two in sync. + +#### 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, CancellationToken cancellationToken) + { + var (skills, nextCursor) = await repository.GetPageAsync(cursor, pageSize: 50, cancellationToken); + return new McpSkillPage { Skills = skills, NextCursor = nextCursor }; + } + + public ValueTask GetAsync(string uri, CancellationToken cancellationToken) => + repository.FindAsync(uri, cancellationToken); +} +``` + +A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with +`resources/read`. A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers +for every skill the server serves. Throw with + for a cursor the catalog did not issue. + is the built-in implementation over a fixed +set of entries and can be composed into a custom one. + +#### Dynamic skills + +A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to + and serve it through a catalog. Such a skill +offers no content integrity, and hosts may decline to load it. + +#### Protocol versions and caching hints + +`skills/list` results carry the base protocol's `ttlMs` and `cacheScope` list-caching attributes, configured through +. Those attributes, and `resultType`, are defined +from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, +where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in +list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. + +### Consuming skills + +The client extension methods in cover the +host's side of the protocol: + +```csharp +using ModelContextProtocol.Extensions.Skills; + +if (!client.SupportsSkills()) +{ + return; // Clients issue skills/list and skills/get only after observing the declaration. +} + +// Enumerate, following pagination. The listing may be empty or partial. +IList skills = await client.ListSkillsAsync(); + +// Retrieve one skill by URI, for example one referenced from server instructions. +Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); + +// Read a file and verify it against the held entry's digest and size. +ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); +``` + +`ReadSkillResourceAsync` throws when the +content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the +content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest; +because the manifest changed, any approval bound to the previous one is revoked and must be obtained again. + +The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`, +return one page or the raw result and expose the caching hints. + exposes the verification and digest helpers for hosts +that read resources through other means. + +### Security considerations + +Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The +specification places most of the burden on hosts. In particular: + +- 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`, since it does not parse YAML; a host must do that itself before loading a skill. + +### 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/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs new file mode 100644 index 000000000..4f80b1829 --- /dev/null +++ b/samples/SkillsClient/Program.cs @@ -0,0 +1,90 @@ +// Demonstrates consuming Agent Skills over MCP with the Skills extension (SEP-2640), from the host's side: +// +// 1. Connect over Streamable HTTP and check that the server declares the extension. +// 2. Enumerate the skills with skills/list (the client extension follows pagination for you). +// 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions. +// 4. Read the skill's files with resources/read and verify each one against the manifest's digest and size. +// 5. Show that a file the manifest does not list is refused before any request is sent. +// +// Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as +// the first argument to run against another server. + +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001"); + +await using McpClient client = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Name = "Skills Server", + Endpoint = endpoint, +})); + +Console.WriteLine($"Connected to {client.ServerInfo.Name} {client.ServerInfo.Version} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); +Console.WriteLine(); + +// 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration. +if (!client.SupportsSkills()) +{ + Console.WriteLine("The server does not declare the io.modelcontextprotocol/skills extension."); + return; +} + +// 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI. +Console.WriteLine("=== skills/list ==="); +ListSkillsResult firstPage = await client.ListSkillsAsync(new ListSkillsRequestParams()); +Console.WriteLine($" caching hints: ttlMs={(firstPage.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={firstPage.CacheScope?.ToString() ?? "(none)"}"); + +IList skills = await client.ListSkillsAsync(); +foreach (Skill listed in skills) +{ + string manifest = listed.Resources.IsDynamic + ? "dynamic (no digests)" + : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes"; + Console.WriteLine($" {listed.Uri}"); + Console.WriteLine($" name: {listed.Name}"); + Console.WriteLine($" description: {listed.Description}"); + Console.WriteLine($" manifest: {manifest}"); +} + +Console.WriteLine(); + +// 3. Retrieve one skill by URI. The server's instructions reference this one, so a host confirms it directly. +Console.WriteLine("=== skills/get ==="); +Console.WriteLine($" server instructions: {client.ServerInstructions}"); +const string SkillUri = "skill://git-workflow/SKILL.md"; +Skill skill = await client.GetSkillAsync(SkillUri); +Console.WriteLine($" {skill.Uri} ({skill.Name})"); +foreach (SkillResource file in skill.Resources.Resources!) +{ + Console.WriteLine($" {file.Uri} {file.Size,6} bytes {file.Digest}"); +} + +Console.WriteLine(); + +// 4. Verified reads. ReadSkillResourceAsync issues resources/read and checks size and digest against the held +// entry; a mismatch throws SkillVerificationException and the content must not be used. +Console.WriteLine("=== resources/read (verified) ==="); +ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); +Console.WriteLine($" {SkillUri} verified:"); +Console.WriteLine(Indent(((TextResourceContents)skillFile.Contents[0]).Text)); + +string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri; +ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri); +Console.WriteLine($" {supportingFileUri} verified ({((TextResourceContents)supportingFile.Contents[0]).Text.Length} chars)"); +Console.WriteLine(); + +// 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it. +Console.WriteLine("=== unlisted file ==="); +try +{ + await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/UNLISTED.md"); +} +catch (SkillVerificationException e) +{ + Console.WriteLine($" refused: {e.Message}"); +} + +static string Indent(string text) => + string.Join(Environment.NewLine, text.TrimEnd().Split('\n').Select(line => " | " + line.TrimEnd('\r'))); diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md new file mode 100644 index 000000000..a44640775 --- /dev/null +++ b/samples/SkillsClient/README.md @@ -0,0 +1,69 @@ +# Skills Client Sample + +A console client that consumes [Agent Skills](https://agentskills.io/) over MCP through the Skills extension +([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)), +doing what a host's skill-loading path does: + +1. Connects over Streamable HTTP and checks that the server declares `io.modelcontextprotocol/skills` + (`client.SupportsSkills()`). +2. Enumerates the catalog with `skills/list` (`client.ListSkillsAsync()` follows pagination; the per-page + overload exposes the `ttlMs` and `cacheScope` hints). +3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it + found in server instructions. +4. Reads the skill's files with `resources/read` and verifies each against the manifest's digest and size + (`client.ReadSkillResourceAsync(skill, uri)`). +5. Shows that a file the manifest does not list is refused before any request is sent. + +## Run + +Start the [SkillsServer](../SkillsServer) sample in one terminal: + +```bash +dotnet run --project samples/SkillsServer/SkillsServer.csproj +``` + +Then run the client in another: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj +``` + +To run against another Streamable HTTP server, pass its endpoint: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp +``` + +Expected output (abridged): + +``` +=== skills/list === + caching hints: ttlMs=300000 cacheScope=Public + skill://acme/billing/refunds/SKILL.md + name: refunds + ... + skill://git-workflow/SKILL.md + name: git-workflow + manifest: 3 file(s), 1523 bytes + +=== skills/get === + skill://git-workflow/SKILL.md (git-workflow) + skill://git-workflow/SKILL.md 724 bytes sha256:â€Ļ + ... + +=== resources/read (verified) === + skill://git-workflow/SKILL.md verified: + | --- + | name: git-workflow + ... + +=== unlisted file === + refused: 'skill://git-workflow/references/UNLISTED.md' is not listed in the manifest of skill ... +``` + +## Notes + +- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come + from the same server. Treat skill content as untrusted model input and tag it with its originating server. +- The SDK does not verify frontmatter (re-parsing the fetched `SKILL.md`'s YAML and comparing it to the entry), + because it does not include a YAML parser. A host must do that itself before loading a skill. diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj new file mode 100644 index 000000000..01f44dcb0 --- /dev/null +++ b/samples/SkillsClient/SkillsClient.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + + diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs new file mode 100644 index 000000000..77405e31e --- /dev/null +++ b/samples/SkillsServer/Program.cs @@ -0,0 +1,63 @@ +// 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. +// McpServerSkill.CreateFromDirectory reads the files, computes the SHA-256 digest and size of each one, and +// produces both the skill's entry (what skills/list and skills/get return) and the resources that serve the files +// (what resources/read returns). WithSkills registers all of it. +// +// The frontmatter is supplied in code and must mirror the YAML frontmatter at the top of SKILL.md exactly. Hosts +// re-parse the fetched SKILL.md and compare it field by field against the entry, and refuse the skill on any +// discrepancy. The extension deliberately does not parse YAML. + +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; + +var builder = WebApplication.CreateBuilder(args); + +string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills"); + +var gitWorkflow = McpServerSkill.CreateFromDirectory( + uri: "skill://git-workflow/SKILL.md", + frontmatter: new JsonObject + { + ["name"] = "git-workflow", + ["description"] = "Follow this team's Git conventions for branching, commit messages, and pull requests.", + ["license"] = "MIT", + }, + directoryPath: Path.Combine(skillsRoot, "git-workflow")); + +// A nested skill path: the organizational prefix is "acme/billing" and the skill's name is "refunds". +var refunds = McpServerSkill.CreateFromDirectory( + uri: "skill://acme/billing/refunds/SKILL.md", + frontmatter: new JsonObject + { + ["name"] = "refunds", + ["description"] = "Process customer refund requests per company policy.", + ["metadata"] = new JsonObject { ["owner"] = "billing-team", ["version"] = "2.1.0" }, + }, + directoryPath: Path.Combine(skillsRoot, "refunds")); + +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() + .WithSkills([gitWorkflow, refunds], 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..c318e53f6 --- /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://acme/billing/refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | + +`McpServerSkill.CreateFromDirectory` reads each directory, computes the SHA-256 digest and size of every file, and +produces both the skill's entry and the resources that serve its files. `WithSkills` registers the `skills/list` +and `skills/get` methods and the resources in one call. + +The frontmatter is passed in code and must mirror the YAML at the top of each `SKILL.md` exactly. Hosts verify +this and refuse a skill whose entry disagrees with its file. The extension does not parse YAML. + +## 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. The nested path `acme/billing/refunds` shows an + organizational prefix; 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 + + + + + + + + + + + + From c23603ca7caa0f7d75316141b7632210016c8111 Mon Sep 17 00:00:00 2001 From: Peder Date: Tue, 8 Sep 2026 23:31:46 +0200 Subject: [PATCH 05/14] Harden skill authoring and verified reads Addresses four review findings against the previous commits. McpServerSkill.Create now copies each file's bytes once and uses that copy for both the manifest digest and the served resource, so a caller that mutates its buffer after construction cannot make the served content diverge from the published digest. File path segments are percent-encoded when building resource URIs. A file named "{name}.md" previously registered as a resource template instead of a concrete resource and was missing from resources/list. CreateFromDirectory no longer follows symbolic links or other reparse points, and throws when it meets one. A link inside a skill directory could publish a file from outside it under a URI that appears to be inside. SkillVerifier gains Verify(skill, uri, result), which additionally requires the read's result to contain the requested URI. ReadSkillResourceAsync uses it, so a server cannot satisfy a read of one file by returning another, correctly digested, file of the same skill. Regression tests cover each case, including an end-to-end substitution via a read-resource filter and a caller-owned buffer mutated after creation. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 5 +- .../Client/McpSkillsClientExtensions.cs | 5 +- .../Client/SkillVerifier.cs | 52 ++++++++++ .../Server/McpServerSkill.cs | 80 ++++++++++++--- .../Client/SkillVerifierTests.cs | 24 +++++ .../Server/McpServerSkillTests.cs | 99 +++++++++++++++++++ .../Server/McpServerSkillsIntegrityTests.cs | 99 +++++++++++++++++++ 7 files changed, 350 insertions(+), 14 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 93eafca44..370c029e3 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -79,7 +79,10 @@ var skill = McpServerSkill.Create( Both methods validate the skill against the specification and throw with a specific message when, for example, the frontmatter `name` does not match the URI, `SKILL.md` is missing, or the -skill exceeds the per-skill limits of 512 files or 16 MiB. +skill exceeds the per-skill limits of 512 files or 16 MiB. 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 diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index 9ca1a4a4b..ae5afdb25 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -184,7 +184,8 @@ public static async ValueTask GetSkillAsync( /// only if the host has decided to load unverifiable skills. /// /// - /// is not listed in the manifest, or the content's size or digest does not match its entry. + /// is not listed in the manifest, the server's response does not contain contents for + /// , or any returned content's size or digest does not match its manifest entry. /// /// The request failed or the server returned an error response. /// @@ -223,7 +224,7 @@ public static async ValueTask ReadSkillResourceAsync( } var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); - SkillVerifier.Verify(skill, result); + SkillVerifier.Verify(skill, uri, result); return result; } diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 7696c984f..524ebd0b4 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -176,6 +176,58 @@ public static void Verify(Skill skill, ReadResourceResult result) } } + /// + /// Verifies the result of reading a specific file of a skill through resources/read against the skill's + /// manifest, additionally requiring that the result actually contains the requested file. + /// + /// The skill entry being acted on. + /// The URI that was requested. It must be listed in 's manifest. + /// The result of the read. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// is not listed in the manifest, the result does not contain contents for + /// , or any contents in the result fail verification per . + /// + /// + /// Checking every returned content against the manifest is not enough on its own: a server could answer a read + /// of one file with another, correctly digested, file of the same skill. Binding the result to the requested + /// URI closes that gap. + /// + public static void Verify(Skill skill, string uri, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + if (!skill.Resources.IsDynamic && FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + Verify(skill, result); + + bool found = false; + foreach (var contents in result.Contents) + { + found |= string.Equals(contents.Uri, uri, StringComparison.Ordinal); + } + + if (!found) + { + throw new SkillVerificationException( + $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'."); + } + } + internal static SkillResource? FindResource(Skill skill, string uri) { var resources = skill.Resources.Resources; diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 4756cf6c1..16b4237fe 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -114,16 +114,22 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera return string.CompareOrdinal(left.Path, right.Path); }); + // Snapshot every file's bytes once. The caller's ReadOnlyMemory may alias an array the caller goes + // on to mutate, and the digest published in the manifest must describe exactly the bytes served. + var contents = new byte[normalized.Count][]; + // Build and validate the entry before creating any resources, so an invalid skill fails fast with a // message about the entry rather than about a resource. var manifest = new List(normalized.Count); - foreach (var (path, file) in normalized) + for (int i = 0; i < normalized.Count; i++) { + byte[] content = normalized[i].File.Content.ToArray(); + contents[i] = content; manifest.Add(new SkillResource { - Uri = root + "/" + path, - Digest = SkillVerifier.ComputeDigest(file.Content.Span), - Size = file.Content.Length, + Uri = root + "/" + EscapePath(normalized[i].Path), + Digest = SkillVerifier.ComputeDigest(content), + Size = content.Length, }); } @@ -145,8 +151,8 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera manifest[i].Uri, name: isSkillFile ? skill.Name! : path, description: isSkillFile ? skill.Description : null, - mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, file.Content.Span)), - file.Content); + mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, contents[i])), + contents[i]); } return new McpServerSkill(skill, resources); @@ -167,10 +173,20 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera /// The skill. /// An argument is . /// does not exist. - /// The directory's contents do not form a valid skill; see . + /// + /// The directory's contents do not form a valid skill (see ), or the directory contains a + /// symbolic link or other reparse point. + /// /// + /// /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the /// manifest or the served content. + /// + /// + /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file + /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a + /// regular file or directory, or build the skill with and explicit files. + /// /// public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) { @@ -196,9 +212,37 @@ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmat } var files = new List(); - foreach (string filePath in Directory.EnumerateFiles(fullDirectory, "*", SearchOption.AllDirectories)) + CollectFiles(fullDirectory, fullDirectory, files); + + return Create(uri, frontmatter, files); + } + + /// + /// Walks a skill directory without following links. A symbolic link (or any other reparse point) can point + /// outside the skill directory, and a file reached through one would be published under a URI that looks like + /// it lives inside the skill. Rather than try to decide which link targets are acceptable, links are rejected. + /// + private static void CollectFiles(string root, string directory, List files) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) { - string relativePath = filePath.Substring(fullDirectory.Length).Replace(Path.DirectorySeparatorChar, '/'); + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{entry}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + $"because a link can point outside the skill. Replace it with a regular file or directory, or build the skill " + + $"with {nameof(McpServerSkill)}.{nameof(Create)} and explicit files.", + "directoryPath"); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + CollectFiles(root, entry, files); + continue; + } + + string relativePath = entry.Substring(root.Length).Replace(Path.DirectorySeparatorChar, '/'); if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) { relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, '/'); @@ -207,11 +251,25 @@ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmat files.Add(new McpServerSkillFile { Path = relativePath, - Content = File.ReadAllBytes(filePath), + Content = File.ReadAllBytes(entry), }); } + } - return Create(uri, frontmatter, files); + /// + /// Percent-encodes each segment of a normalized relative path so that characters with URI syntax (such as + /// {, ?, #, or a space) in a file name stay literal. Without this, a file named + /// {name}.md would register as a resource template rather than a concrete resource. + /// + private static string EscapePath(string normalizedPath) + { + string[] segments = normalizedPath.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + segments[i] = Uri.EscapeDataString(segments[i]); + } + + return string.Join("/", segments); } private static string NormalizePath(string? path) diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs index 0493fc855..9bac1e82a 100644 --- a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -133,6 +133,30 @@ public void Verify_Skill_ThrowsForDynamicSkill() 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, diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index d56336ce1..44b471c1f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -191,6 +191,105 @@ public void CreateFromDirectory_LoadsFilesRecursively() } } + [Fact] + public void Create_EscapesUriSyntaxCharactersInFilePaths() + { + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("templates/{name} v2.md", "template"), + McpServerSkillFile.FromText("notes/a#b?c.md", "note"), + ]); + + var manifest = skill.ProtocolSkill.Resources.Resources!; + Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri); + Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri); + + // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI. + for (int i = 0; i < manifest.Count; i++) + { + Assert.False(skill.Resources[i].IsTemplated); + Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri); + } + + Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name); + } + + [Fact] + public void Create_SnapshotsCallerOwnedContent() + { + byte[] bytes = Encoding.UTF8.GetBytes(SkillMarkdown); + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), [new McpServerSkillFile { Path = "SKILL.md", Content = bytes }]); + string digestBefore = skill.ProtocolSkill.Resources.Resources![0].Digest; + + bytes[0] = (byte)'X'; + + // The manifest was computed from the original bytes; the served bytes must be those same bytes. The + // end-to-end check that the served content still verifies lives in McpServerSkillsSnapshotTests. + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), digestBefore); + } + +#if NET + [Fact] + public void CreateFromDirectory_RejectsSymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside.txt"), "private data"); + + try + { + File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); + Assert.Contains("linked.txt", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_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_WithMissingDirectory_Throws() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs new file mode 100644 index 000000000..05902deb0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs @@ -0,0 +1,99 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end regression tests for two integrity properties of the Skills extension: the bytes a +/// serves are the bytes its manifest describes, even if the caller mutates its +/// buffer afterwards; and a verified read is bound to the file that was requested, not merely to files of the +/// same skill. +/// +public class McpServerSkillsIntegrityTests : ClientServerTestBase +{ + private const string SnapshotUri = "skill://snapshot/SKILL.md"; + private const string SwapUri = "skill://swap/SKILL.md"; + private const string SwapOtherUri = "skill://swap/other.md"; + private const string OriginalMarkdown = "---\nname: snapshot\ndescription: d\n---\noriginal\n"; + + public McpServerSkillsIntegrityTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + // A fresh buffer per fixture instance: ConfigureServices runs once per test, and the mutation below must + // not leak into the next test's skill. + byte[] callerOwnedBuffer = Encoding.UTF8.GetBytes(OriginalMarkdown); + var snapshot = McpServerSkill.Create( + SnapshotUri, + new JsonObject { ["name"] = "snapshot", ["description"] = "d" }, + [new McpServerSkillFile { Path = "SKILL.md", Content = callerOwnedBuffer }]); + + // Mutate the caller's buffer after the skill was created. The served content must not change. + callerOwnedBuffer[callerOwnedBuffer.Length - 2] = (byte)'X'; + + // A misbehaving server: the entry for "swap" is correct, but a read of its SKILL.md is answered with the + // skill's other file, correctly digested and correctly labelled with that other file's URI. + const string SwapMarkdown = "---\nname: swap\ndescription: d\n---\n"; + const string SwapOther = "other content"; + var swapEntry = new Skill + { + Uri = SwapUri, + Frontmatter = new JsonObject { ["name"] = "swap", ["description"] = "d" }, + Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = SwapUri, Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SwapMarkdown)), Size = SwapMarkdown.Length }, + new SkillResource { Uri = SwapOtherUri, Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SwapOther)), Size = SwapOther.Length }, + ]), + }; + + mcpServerBuilder + .WithSkills(new InMemoryMcpSkillCatalog([snapshot.ProtocolSkill, swapEntry])) + .WithResources(snapshot.Resources) + .WithResources( + [ + McpServerResource.Create( + () => new TextResourceContents { Uri = SwapOtherUri, MimeType = "text/markdown", Text = SwapOther }, + new McpServerResourceCreateOptions { UriTemplate = SwapUri }), + McpServerResource.Create( + () => new TextResourceContents { Uri = SwapOtherUri, MimeType = "text/markdown", Text = SwapOther }, + new McpServerResourceCreateOptions { UriTemplate = SwapOtherUri }), + ]); + } + + [Fact] + public async Task ServedContent_IsTheContentTheManifestDescribes_EvenAfterCallerMutatesItsBuffer() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(SnapshotUri, TestContext.Current.CancellationToken); + + var result = await client.ReadSkillResourceAsync(skill, SnapshotUri, TestContext.Current.CancellationToken); + + Assert.Equal(OriginalMarkdown, Assert.IsType(Assert.Single(result.Contents)).Text); + } + + [Fact] + public async Task ReadSkillResourceAsync_RejectsAResponseContainingOnlyADifferentFileOfTheSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(SwapUri, TestContext.Current.CancellationToken); + + // Sanity check: the substituted response is for a listed, correctly digested file. + var raw = await client.ReadResourceAsync(SwapUri, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(SwapOtherUri, Assert.Single(raw.Contents).Uri); + + var exception = await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, SwapUri, TestContext.Current.CancellationToken)); + Assert.Contains(SwapUri, exception.Message); + + // Reading the other file directly still verifies. + await client.ReadSkillResourceAsync(skill, SwapOtherUri, TestContext.Current.CancellationToken); + } +} From 7dff5ce0d6cfe23ef1be72c20d39480350d53d07 Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 00:28:31 +0200 Subject: [PATCH 06/14] Read skill frontmatter from SKILL.md Requiring authors to restate a SKILL.md's frontmatter as a JsonObject was awkward and error-prone. SkillFrontmatter.Parse now reads it from the file without a YAML dependency, accepting the subset Agent Skills frontmatter uses (nested block mappings, block and flow sequences, plain, quoted, and block scalars, comments) and resolving unquoted scalars per the YAML 1.2 core schema, which matches the YAML libraries other SDKs and hosts use. Anchors, aliases, tags, multi-document streams, complex keys, and tab indentation are rejected with a FormatException naming the construct, since a guessed rendering would fail host-side verification. McpServerSkill gains Create(files), Create(uri, files), CreateFromDirectory(path), and CreateFromDirectory(uri, path), which read the frontmatter from SKILL.md and, where no URI is given, derive it from the frontmatter name as skill://{name}/SKILL.md. The overloads that take an explicit JsonObject remain for frontmatter the reader cannot handle; when the reader can parse the file, a supplied object that differs from it is rejected at construction, since hosts compare the two field by field. WithSkillsFromDirectory(path, uriPrefix) serves every immediate subdirectory containing a SKILL.md in one call. The server sample shrinks to a single WithSkillsFromDirectory call, the conformance fixture reads its frontmatter from the files, and the docs describe the reader, its limits, and the escape hatch. ClientServerTestBase gains a virtual DisposeAsync so fixtures can clean up temporary directories. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 59 +- samples/SkillsClient/README.md | 10 +- samples/SkillsServer/Program.cs | 37 +- samples/SkillsServer/README.md | 16 +- .../Server/McpServerSkill.cs | 317 ++++-- .../Server/McpSkillsBuilderExtensions.cs | 83 ++ .../SkillFrontmatter.cs | 967 ++++++++++++++++++ .../Skills/ConformanceSkills.cs | 11 +- .../ClientServerTestBase.cs | 2 +- .../Server/McpServerSkillTests.cs | 80 +- .../McpServerSkillsFromDirectoryTests.cs | 96 ++ .../Server/SkillFrontmatterTests.cs | 289 ++++++ 12 files changed, 1827 insertions(+), 140 deletions(-) create mode 100644 src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 370c029e3..4d13b0054 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -36,48 +36,45 @@ lets a user's approval bind to specific content. ### Serving skills -The simplest way to serve skills is to describe each one with - and register them with `WithSkills`. The SDK computes -every digest and size from the same bytes the resources serve, so the manifest and the content cannot disagree, -and registers the file resources for you. +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 System.Text.Json.Nodes; - -var gitWorkflow = McpServerSkill.CreateFromDirectory( - uri: "skill://git-workflow/SKILL.md", - frontmatter: new JsonObject - { - ["name"] = "git-workflow", - ["description"] = "Follow this team's Git conventions for branching and commits.", - }, - directoryPath: Path.Combine(AppContext.BaseDirectory, "Skills", "git-workflow")); builder.Services .AddMcpServer() .WithHttpTransport() - .WithSkills([gitWorkflow], options => + .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options => { options.TimeToLive = TimeSpan.FromMinutes(5); options.CacheScope = CacheScope.Public; }); ``` -`McpServerSkill.Create` takes the files explicitly when they are not on disk: +Every immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each +`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, +and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix` +such as `skill://acme/billing/` to place the skills under an organizational path. + +For finer control, build skills individually with and +register them with `WithSkills`: ```csharp -var skill = McpServerSkill.Create( - "skill://refunds/SKILL.md", - new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds." }, +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]); ``` -Both methods validate the skill against the specification and throw with a +All of these validate the skill against the specification and throw with a specific message when, for example, the frontmatter `name` does not match the URI, `SKILL.md` is missing, or the skill exceeds the per-skill limits of 512 files or 16 MiB. 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 @@ -86,10 +83,19 @@ names containing characters with URI syntax (such as `{`, `?`, or a space) are p #### Frontmatter -The frontmatter is supplied as a and must reproduce the YAML frontmatter -at the top of `SKILL.md` exactly, field by field. Hosts re-parse the fetched `SKILL.md` and compare it against the -entry, treating any discrepancy as a verification failure. The SDK does not include a YAML parser and does not -derive the frontmatter from the file, so keep the two in sync. + reads the YAML frontmatter of a `SKILL.md` into a + without a YAML library. It accepts the subset of YAML that Agent Skills +frontmatter uses: block mappings nested to any depth, block and flow sequences, plain, quoted, and block scalars, +and comments. Unquoted scalars are resolved per the YAML 1.2 core schema (`null`, booleans, integers, finite +floats, otherwise strings), matching the YAML libraries used by other SDKs and by hosts. That matters because a +host verifies a skill by parsing the fetched `SKILL.md` itself and comparing field by field against the published +entry; a value that one side types as a number and the other as a string is a verification failure. Quote values +such as version numbers that are meant to be strings. + +Anchors, aliases, tags, multi-document streams, complex keys, and tab indentation are rejected with a + naming the construct. For such a file, the `Create` and `CreateFromDirectory` +overloads that take an explicit `JsonObject` supply the frontmatter directly. When the reader can parse the file, +an explicit object must match it exactly, or the skill is rejected at construction rather than by every host. #### Custom catalogs @@ -180,7 +186,10 @@ specification places most of the burden on hosts. In particular: 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`, since it does not parse YAML; a host must do that itself before loading a skill. +the fetched `SKILL.md` automatically. A host can do so with + on the fetched text and + +against the entry's frontmatter, treating a parse failure or a difference as a verification failure. ### Not implemented diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md index a44640775..a92591154 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -39,12 +39,12 @@ Expected output (abridged): ``` === skills/list === caching hints: ttlMs=300000 cacheScope=Public - skill://acme/billing/refunds/SKILL.md - name: refunds - ... 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) @@ -65,5 +65,5 @@ Expected output (abridged): - Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come from the same server. Treat skill content as untrusted model input and tag it with its originating server. -- The SDK does not verify frontmatter (re-parsing the fetched `SKILL.md`'s YAML and comparing it to the entry), - because it does not include a YAML parser. A host must do that itself before loading a skill. +- The SDK does not verify frontmatter automatically. A host can re-parse the fetched `SKILL.md` with + `SkillFrontmatter.Parse` and compare it to the entry with `JsonNode.DeepEquals` before loading a skill. diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs index 77405e31e..f16c7c0c1 100644 --- a/samples/SkillsServer/Program.cs +++ b/samples/SkillsServer/Program.cs @@ -1,43 +1,20 @@ // 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. -// McpServerSkill.CreateFromDirectory reads the files, computes the SHA-256 digest and size of each one, and -// produces both the skill's entry (what skills/list and skills/get return) and the resources that serve the files -// (what resources/read returns). WithSkills registers all of it. -// -// The frontmatter is supplied in code and must mirror the YAML frontmatter at the top of SKILL.md exactly. Hosts -// re-parse the fetched SKILL.md and compare it field by field against the entry, and refuse the skill on any -// discrepancy. The extension deliberately does not parse YAML. +// 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; -using System.Text.Json.Nodes; 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"); -var gitWorkflow = McpServerSkill.CreateFromDirectory( - uri: "skill://git-workflow/SKILL.md", - frontmatter: new JsonObject - { - ["name"] = "git-workflow", - ["description"] = "Follow this team's Git conventions for branching, commit messages, and pull requests.", - ["license"] = "MIT", - }, - directoryPath: Path.Combine(skillsRoot, "git-workflow")); - -// A nested skill path: the organizational prefix is "acme/billing" and the skill's name is "refunds". -var refunds = McpServerSkill.CreateFromDirectory( - uri: "skill://acme/billing/refunds/SKILL.md", - frontmatter: new JsonObject - { - ["name"] = "refunds", - ["description"] = "Process customer refund requests per company policy.", - ["metadata"] = new JsonObject { ["owner"] = "billing-team", ["version"] = "2.1.0" }, - }, - directoryPath: Path.Combine(skillsRoot, "refunds")); - builder.Services .AddMcpServer(options => { @@ -49,7 +26,7 @@ "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md."; }) .WithHttpTransport() - .WithSkills([gitWorkflow, refunds], options => + .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); diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md index c318e53f6..6ec5bb535 100644 --- a/samples/SkillsServer/README.md +++ b/samples/SkillsServer/README.md @@ -8,14 +8,12 @@ Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and suppor | Skill URI | Files | | ---------------------------------------- | -------------------------------------------------------------- | | `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` | -| `skill://acme/billing/refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | +| `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | -`McpServerSkill.CreateFromDirectory` reads each directory, computes the SHA-256 digest and size of every file, and -produces both the skill's entry and the resources that serve its files. `WithSkills` registers the `skills/list` -and `skills/get` methods and the resources in one call. - -The frontmatter is passed in code and must mirror the YAML at the top of each `SKILL.md` exactly. Hosts verify -this and refuse a skill whose entry disagrees with its file. The extension does not parse YAML. +`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the +SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the +resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from +its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`. ## Run @@ -35,8 +33,8 @@ Any MCP host that supports Streamable HTTP can also be pointed at `http://localh ## Notes -- Skill URIs are scoped to the server that serves them. The nested path `acme/billing/refunds` shows an - organizational prefix; only the final segment must equal the skill's `name`. +- 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`. diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 16b4237fe..0b60cab08 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -13,18 +13,22 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// The specification requires a skill's manifest to carry the SHA-256 digest and size of every file, and a host -/// refuses content whose bytes do not match. Building a skill through or -/// computes the manifest from the same bytes the resources serve, so the two -/// cannot disagree. +/// refuses content whose bytes do not match. Building a skill through +/// or (or their overloads) computes the manifest from the same bytes the +/// resources serve, so the two cannot disagree. /// /// -/// The frontmatter is supplied separately from the SKILL.md content and must reproduce that file's YAML -/// frontmatter exactly, field by field. Hosts re-parse the fetched SKILL.md and compare, treating any -/// discrepancy as a verification failure. This package does not parse YAML. +/// The skill's frontmatter is read from its SKILL.md by . Hosts re-parse the +/// fetched SKILL.md and compare it field by field against the published entry, treating any discrepancy as +/// a verification failure, so the two must agree. The overloads that accept an explicit +/// exist for frontmatter the reader cannot handle; when it can read the file, an explicit object that differs from +/// it is rejected. /// /// /// Register skills with , -/// which registers both the catalog entries and the file resources. +/// which registers both the catalog entries and the file resources, or point +/// +/// at a directory of skills. /// /// public sealed class McpServerSkill @@ -46,7 +50,59 @@ private McpServerSkill(Skill protocolSkill, IReadOnlyList res public IReadOnlyList Resources { get; } /// - /// Creates a skill from its files. + /// Creates a skill from its files, reading the frontmatter from SKILL.md and deriving the skill's URI + /// from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// is . + /// + /// omits SKILL.md, contains a duplicate or unsafe path, or exceeds the + /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (see + /// ) or is missing a required field. + /// + public static McpServerSkill Create(IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(files); +#else + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files, reading the frontmatter from SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md; omits SKILL.md, + /// contains a duplicate or unsafe path, or exceeds the specification's per-skill limits; or the SKILL.md + /// frontmatter cannot be read (see ), is missing a required field, or has a + /// name that does not match . + /// + public static McpServerSkill Create(string uri, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files with explicitly supplied frontmatter. /// /// /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path @@ -60,10 +116,17 @@ private McpServerSkill(Skill protocolSkill, IReadOnlyList res /// The skill. /// An argument is . /// - /// does not end in /SKILL.md, is missing a required - /// field or its name does not match , omits SKILL.md, - /// contains a duplicate or unsafe path, or exceeds the specification's per-skill limits. + /// does not end in /SKILL.md; is missing a required + /// field, has a name that does not match , or differs from the frontmatter in + /// SKILL.md; or omits SKILL.md, contains a duplicate or unsafe path, or + /// exceeds the specification's per-skill limits. /// + /// + /// Prefer , which reads the frontmatter from the + /// file. This overload exists for frontmatter that cannot read. When it can read + /// the file, the supplied object must match it exactly, since hosts compare the two field by field and refuse + /// the skill on any difference. + /// public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) { #if NET @@ -76,8 +139,126 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera if (files is null) throw new ArgumentNullException(nameof(files)); #endif - string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); + return CreateCore(uri, uriPrefix: null, frontmatter, files, nameof(files)); + } + /// + /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md + /// and deriving the skill's URI from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// + /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the + /// manifest or the served content. + /// + /// + /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file + /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a + /// regular file or directory, or build the skill with and + /// explicit files. + /// + /// + public static McpServerSkill CreateFromDirectory(string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// See for how files and links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file in a directory, recursively, with explicitly supplied frontmatter. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// Prefer , which reads the frontmatter from the file. This + /// overload exists for frontmatter that cannot read. When it can read the file, + /// the supplied object must match it exactly. See for how files and + /// links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + private const string DefaultUriPrefix = "skill://"; + + /// + /// Creates a skill from a directory, deriving its URI as {uriPrefix}{name}/SKILL.md. Used by + /// WithSkillsFromDirectory. + /// + internal static McpServerSkill CreateFromDirectory(string directoryPath, string uriPrefix, string paramName) => + CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath), paramName); + + private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName) + { // Normalize and order the files: SKILL.md first, then the rest by path, so the manifest is deterministic. var normalized = new List<(string Path, McpServerSkillFile File)>(); var seenPaths = new HashSet(StringComparer.Ordinal); @@ -85,13 +266,13 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera { if (file is null) { - throw new ArgumentException("The skill's files must not contain null entries.", nameof(files)); + throw new ArgumentException("The skill's files must not contain null entries.", filesParamName); } string path = NormalizePath(file.Path); if (!seenPaths.Add(path)) { - throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", nameof(files)); + throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", filesParamName); } normalized.Add((path, file)); @@ -99,7 +280,7 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera if (!seenPaths.Contains(SkillsProtocol.SkillFileName)) { - throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", nameof(files)); + throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", filesParamName); } normalized.Sort(static (left, right) => @@ -117,19 +298,72 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera // Snapshot every file's bytes once. The caller's ReadOnlyMemory may alias an array the caller goes // on to mutate, and the digest published in the manifest must describe exactly the bytes served. var contents = new byte[normalized.Count][]; + for (int i = 0; i < normalized.Count; i++) + { + contents[i] = normalized[i].File.Content.ToArray(); + } + + // Read the frontmatter from SKILL.md (always first after sorting). When the caller supplied frontmatter, + // the file is still read so that the two can be checked against each other; if the file uses YAML the + // reader does not support, the caller's frontmatter stands on its own. + JsonObject? fileFrontmatter = null; + FormatException? frontmatterError = null; + try + { + if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) + { + throw new FormatException($"{SkillsProtocol.SkillFileName} is not valid UTF-8."); + } + + fileFrontmatter = SkillFrontmatter.Parse(skillMarkdown!); + } + catch (FormatException e) + { + frontmatterError = e; + } + + if (frontmatter is null) + { + frontmatter = fileFrontmatter ?? throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {frontmatterError!.Message} " + + $"If the file uses YAML that {nameof(SkillFrontmatter)} does not support, supply the frontmatter explicitly " + + "with the overload that takes a JsonObject.", + filesParamName); + } + else if (fileFrontmatter is not null && !JsonNode.DeepEquals(fileFrontmatter, frontmatter)) + { + throw new ArgumentException( + $"The supplied frontmatter does not match the frontmatter in {SkillsProtocol.SkillFileName}. Hosts compare the two field by field " + + "and refuse the skill on any difference. Fix the mismatch, or omit the frontmatter argument to have it read from the file. " + + $"From the file: {fileFrontmatter.ToJsonString()} Supplied: {frontmatter.ToJsonString()}", + nameof(frontmatter)); + } + + if (uri is null) + { + string? name = new Skill { Uri = string.Empty, Frontmatter = frontmatter, Resources = SkillResources.Dynamic }.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} must declare a string 'name' for the skill's URI to be derived from it.", + filesParamName); + } + + uri = uriPrefix + name + "/" + SkillsProtocol.SkillFileName; + } + + string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); // Build and validate the entry before creating any resources, so an invalid skill fails fast with a // message about the entry rather than about a resource. var manifest = new List(normalized.Count); for (int i = 0; i < normalized.Count; i++) { - byte[] content = normalized[i].File.Content.ToArray(); - contents[i] = content; manifest.Add(new SkillResource { Uri = root + "/" + EscapePath(normalized[i].Path), - Digest = SkillVerifier.ComputeDigest(content), - Size = content.Length, + Digest = SkillVerifier.ComputeDigest(contents[i]), + Size = contents[i].Length, }); } @@ -158,48 +392,8 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera return new McpServerSkill(skill, resources); } - /// - /// Creates a skill from every file in a directory, recursively. - /// - /// - /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path - /// segment preceding /SKILL.md must equal the skill's name. - /// - /// - /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and - /// description fields and reproduce the authored frontmatter exactly. - /// - /// The skill's root directory. It must contain a SKILL.md. - /// The skill. - /// An argument is . - /// does not exist. - /// - /// The directory's contents do not form a valid skill (see ), or the directory contains a - /// symbolic link or other reparse point. - /// - /// - /// - /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the - /// manifest or the served content. - /// - /// - /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file - /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a - /// regular file or directory, or build the skill with and explicit files. - /// - /// - public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + private static List ReadDirectory(string directoryPath) { -#if NET - ArgumentNullException.ThrowIfNull(uri); - ArgumentNullException.ThrowIfNull(frontmatter); - ArgumentNullException.ThrowIfNull(directoryPath); -#else - if (uri is null) throw new ArgumentNullException(nameof(uri)); - if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); - if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); -#endif - string fullDirectory = Path.GetFullPath(directoryPath); if (!Directory.Exists(fullDirectory)) { @@ -213,8 +407,7 @@ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmat var files = new List(); CollectFiles(fullDirectory, fullDirectory, files); - - return Create(uri, frontmatter, files); + return files; } /// diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index 7c4e5fd22..ab539362c 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -83,6 +83,89 @@ public static IMcpServerBuilder WithSkills( return WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); } + /// + /// Enables MCP Skills support for every skill directory found directly under , + /// serving both their entries and their files. + /// + /// The server builder. + /// + /// A directory whose immediate subdirectories are skills. Each subdirectory containing a SKILL.md becomes + /// one skill; subdirectories without one are ignored. + /// + /// + /// The prefix of each skill's URI. A skill's SKILL.md is published at {uriPrefix}{name}/SKILL.md, + /// where name is the skill's frontmatter name. Defaults to skill://; use a longer prefix such as + /// skill://acme/billing/ to place the skills under an organizational path. + /// + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// An argument is . + /// does not exist. + /// + /// No subdirectory contains a SKILL.md, a skill directory is invalid (see + /// ), or two skills declare the same name. + /// + /// + /// Each skill is built with : files are read once, + /// digests are computed from the bytes served, and symbolic links are rejected. Only immediate subdirectories + /// are considered skills; a SKILL.md nested deeper inside a skill is one of that skill's files. + /// + public static IMcpServerBuilder WithSkillsFromDirectory( + this IMcpServerBuilder builder, + string directoryPath, + string uriPrefix = "skill://", + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(directoryPath); + ArgumentNullException.ThrowIfNull(uriPrefix); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); + if (uriPrefix is null) throw new ArgumentNullException(nameof(uriPrefix)); +#endif + + if (uriPrefix.Length == 0) + { + throw new ArgumentException("The URI prefix must not be empty.", nameof(uriPrefix)); + } + + if (!uriPrefix.EndsWith("/", StringComparison.Ordinal)) + { + uriPrefix += "/"; + } + + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skills directory '{fullDirectory}' does not exist."); + } + + var skillDirectories = new List(Directory.EnumerateDirectories(fullDirectory)); + skillDirectories.Sort(StringComparer.Ordinal); + + var skills = new List(); + foreach (string skillDirectory in skillDirectories) + { + if (!File.Exists(Path.Combine(skillDirectory, SkillsProtocol.SkillFileName))) + { + continue; + } + + skills.Add(McpServerSkill.CreateFromDirectory(skillDirectory, uriPrefix, nameof(directoryPath))); + } + + if (skills.Count == 0) + { + throw new ArgumentException( + $"No skill directories were found under '{fullDirectory}'. Each skill must be an immediate subdirectory containing a {SkillsProtocol.SkillFileName}.", + nameof(directoryPath)); + } + + return WithSkills(builder, skills, configure); + } + /// /// Enables MCP Skills support backed by the specified catalog. /// diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs new file mode 100644 index 000000000..176d3071d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -0,0 +1,967 @@ +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Reads the YAML frontmatter of a SKILL.md into a , without a YAML library. +/// Servers use it to publish a skill's entry from its file; hosts can use it to compare a fetched SKILL.md against the entry. +/// +/// +/// +/// Agent Skills frontmatter is a small, regular subset of YAML: a block mapping of scalars, with at most a nested +/// mapping (metadata) and the occasional sequence. This reader accepts that subset deliberately and +/// rejects everything else with a naming the construct, so that a skill whose +/// frontmatter it cannot represent faithfully is never published with a guessed rendering. Anchors, aliases, +/// tags, multi-document streams, complex keys, and tab indentation are rejected. +/// +/// +/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, +/// otherwise strings), which is what the YAML libraries used by other MCP SDKs and hosts do. A host verifies a +/// skill by parsing the fetched SKILL.md with its own YAML library and comparing field by field against the +/// entry, so matching that resolution is what makes the published frontmatter verifiable. +/// +/// +public static class SkillFrontmatter +{ + private const string Delimiter = "---"; + + /// + /// Parses the frontmatter at the start of . + /// + /// The full text of a SKILL.md. + /// The frontmatter as a JSON object. + /// + /// The text does not begin with a ----delimited frontmatter block, the block is not a mapping, or it + /// uses YAML this reader does not support. + /// + public static JsonObject Parse(string skillMarkdown) + { + var lines = SplitLines(skillMarkdown); + if (lines.Count == 0 || lines[0].TrimEnd() != Delimiter) + { + throw new FormatException($"{SkillsProtocol.SkillFileName} must begin with a line containing only '{Delimiter}' that opens the YAML frontmatter."); + } + + int end = -1; + for (int i = 1; i < lines.Count; i++) + { + string trimmed = lines[i].TrimEnd(); + if (trimmed == Delimiter || trimmed == "...") + { + end = i; + break; + } + } + + if (end < 0) + { + throw new FormatException($"The YAML frontmatter of {SkillsProtocol.SkillFileName} is not closed by a line containing only '{Delimiter}'."); + } + + var body = new List(end - 1); + for (int i = 1; i < end; i++) + { + body.Add(new Line(lines[i], i + 1)); + } + + var parser = new Parser(body); + var root = parser.ParseDocument(); + return root as JsonObject ?? throw new FormatException("The frontmatter must be a YAML mapping of keys to values."); + } + + private static List SplitLines(string text) + { + if (text.Length > 0 && text[0] == '\uFEFF') + { + text = text.Substring(1); + } + + var lines = new List(); + int start = 0; + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '\n') + { + int lineEnd = i > start && text[i - 1] == '\r' ? i - 1 : i; + lines.Add(text.Substring(start, lineEnd - start)); + start = i + 1; + } + } + + if (start < text.Length) + { + lines.Add(text.Substring(start)); + } + + return lines; + } + + private readonly struct Line + { + public Line(string raw, int number) + { + Raw = raw; + Number = number; + + int indent = 0; + while (indent < raw.Length && raw[indent] == ' ') + { + indent++; + } + + if (indent < raw.Length && raw[indent] == '\t') + { + throw new FormatException($"Line {number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + Indent = indent; + Content = raw.Substring(indent); + IsBlank = Content.Length == 0 || Content[0] == '#'; + } + + public string Raw { get; } + public int Number { get; } + public int Indent { get; } + public string Content { get; } + + /// Whether the line is empty or a comment, and therefore structurally insignificant. + public bool IsBlank { get; } + } + + private sealed class Parser(List lines) + { + private readonly List _lines = lines; + private int _pos; + + public JsonNode? ParseDocument() + { + SkipBlank(); + if (_pos >= _lines.Count) + { + return new JsonObject(); + } + + var first = _lines[_pos]; + if (first.Indent != 0) + { + throw new FormatException($"Line {first.Number}: the frontmatter must start at column 1."); + } + + var node = ParseBlock(0); + SkipBlank(); + if (_pos < _lines.Count) + { + throw new FormatException($"Line {_lines[_pos].Number}: unexpected content after the frontmatter mapping."); + } + + return node; + } + + private void SkipBlank() + { + while (_pos < _lines.Count && _lines[_pos].IsBlank) + { + _pos++; + } + } + + private Line? PeekSignificant() + { + for (int i = _pos; i < _lines.Count; i++) + { + if (!_lines[i].IsBlank) + { + return _lines[i]; + } + } + + return null; + } + + /// Parses the block node formed by the lines at . + private JsonNode? ParseBlock(int indent) + { + SkipBlank(); + var line = _lines[_pos]; + return IsSequenceEntry(line.Content) ? ParseSequence(indent) : ParseMapping(indent); + } + + private static bool IsSequenceEntry(string content) => + content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] == ' '); + + private JsonObject ParseMapping(int indent) + { + var result = new JsonObject(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + if (IsSequenceEntry(line.Content)) + { + throw new FormatException($"Line {line.Number}: a sequence entry cannot appear directly inside a mapping. Put it under a key."); + } + + string content = line.Content; + if (content[0] == '?') + { + throw new FormatException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter."); + } + + int consumed; + string key; + if (content[0] is '"' or '\'') + { + key = ParseQuotedScalar(content, 0, line.Number, out consumed); + int colon = SkipSpaces(content, consumed); + if (colon >= content.Length || content[colon] != ':' || (colon + 1 < content.Length && content[colon + 1] != ' ')) + { + throw new FormatException($"Line {line.Number}: expected ':' after the quoted key."); + } + + consumed = colon + 1; + } + else + { + int separator = FindKeySeparator(content); + if (separator < 0) + { + throw new FormatException($"Line {line.Number}: expected a 'key: value' pair."); + } + + key = content.Substring(0, separator).TrimEnd(); + if (key.Length == 0) + { + throw new FormatException($"Line {line.Number}: empty mapping key."); + } + + consumed = separator + 1; + } + + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {line.Number}: duplicate key '{key}'."); + } + + string rest = StripComment(content.Substring(consumed)).Trim(); + _pos++; + result[key] = ParseValue(rest, indent, line.Number, allowSameIndentSequence: true); + } + + return result; + } + + private JsonArray ParseSequence(int indent) + { + var result = new JsonArray(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent || (line.Indent == indent && !IsSequenceEntry(line.Content))) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + string item = line.Content.Length > 1 ? line.Content.Substring(2) : string.Empty; + int itemIndent = indent + 2; + _pos++; + + string trimmedItem = item.TrimStart(); + if (trimmedItem.Length == 0 || trimmedItem[0] == '#') + { + // "- " followed by nothing: the value is the more-indented block that follows, or null. + var next = PeekSignificant(); + result.Add(next is { } n && n.Indent > indent ? ParseBlock(n.Indent) : null); + continue; + } + + itemIndent = indent + 2 + (item.Length - trimmedItem.Length); + if (IsSequenceEntry(trimmedItem) || (trimmedItem[0] is not ('"' or '\'' or '[' or '{' or '|' or '>' or '&' or '*' or '!') && FindKeySeparator(trimmedItem) >= 0)) + { + // A compact nested node ("- key: value" or "- - x"): re-read this line as if the item started on + // its own line at the item's column, then continue with the block at that indentation. + _pos--; + _lines[_pos] = new Line(new string(' ', itemIndent) + trimmedItem, line.Number); + result.Add(ParseBlock(itemIndent)); + continue; + } + + result.Add(ParseValue(StripComment(trimmedItem).Trim(), indent, line.Number, allowSameIndentSequence: false)); + } + + return result; + } + + /// + /// Parses the value that follows a key or sequence dash. is the remainder of the + /// line, with comments removed; the line itself has already been consumed. + /// + private JsonNode? ParseValue(string rest, int parentIndent, int lineNumber, bool allowSameIndentSequence) + { + if (rest.Length == 0) + { + var next = PeekSignificant(); + if (next is { } n) + { + if (n.Indent > parentIndent) + { + return ParseBlock(n.Indent); + } + + if (allowSameIndentSequence && n.Indent == parentIndent && IsSequenceEntry(n.Content)) + { + return ParseSequence(parentIndent); + } + } + + return null; + } + + switch (rest[0]) + { + case '|' or '>': + return ParseBlockScalar(rest, parentIndent, lineNumber); + + case '&' or '*' or '!': + throw new FormatException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + + case '[': + return ParseFlowSequence(rest, lineNumber); + + case '{': + return ParseFlowMapping(rest, lineNumber); + + case '"' or '\'': + string quoted = ParseQuotedScalar(rest, 0, lineNumber, out int consumed); + if (StripComment(rest.Substring(consumed)).Trim().Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value."); + } + + return JsonValue.Create(quoted); + + default: + if (FindKeySeparator(rest) >= 0) + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally."); + } + + return ResolvePlainScalar(CollectPlainContinuation(rest, parentIndent, lineNumber), lineNumber); + } + } + + /// Folds the more-indented continuation lines of a plain multi-line scalar into it. + private string CollectPlainContinuation(string first, int parentIndent, int lineNumber) + { + var builder = new StringBuilder(first); + int pendingNewlines = 0; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + pendingNewlines++; + _pos++; + continue; + } + + if (line.Indent <= parentIndent || line.Content[0] == '#') + { + break; + } + + string text = StripComment(line.Content).Trim(); + if (text.Length == 0) + { + break; + } + + if (FindKeySeparator(text) >= 0) + { + throw new FormatException( + $"Line {line.Number}: a plain scalar cannot contain ': '. If this line is meant to be a key, check its indentation; " + + $"the value that started on line {lineNumber} continues onto any more-indented line."); + } + + builder.Append(pendingNewlines > 0 ? new string('\n', pendingNewlines) : " "); + pendingNewlines = 0; + builder.Append(text); + _pos++; + } + + // Trailing blank lines belong to whatever follows, so give them back. + _pos -= pendingNewlines; + return builder.ToString(); + } + + private JsonNode? ParseBlockScalar(string header, int parentIndent, int lineNumber) + { + bool literal = header[0] == '|'; + char chomping = 'c'; + int explicitIndent = 0; + for (int i = 1; i < header.Length; i++) + { + char c = header[i]; + if (c is '-' or '+') + { + chomping = c == '-' ? 's' : 'k'; + } + else if (c is >= '1' and <= '9') + { + explicitIndent = c - '0'; + } + else if (c == ' ' || c == '#') + { + break; + } + else + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}'."); + } + } + + // Gather the raw lines of the block: everything blank, plus everything indented more than the parent. + var raw = new List(); + int contentIndent = explicitIndent > 0 ? parentIndent + explicitIndent : -1; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + raw.Add(string.Empty); + _pos++; + continue; + } + + if (line.Indent <= parentIndent) + { + break; + } + + if (contentIndent < 0) + { + contentIndent = line.Indent; + } + else if (line.Indent < contentIndent) + { + break; + } + + raw.Add(line.Raw.Substring(contentIndent)); + _pos++; + } + + // Trailing blank lines are subject to chomping; count and remove them. + int trailing = 0; + while (raw.Count > 0 && raw[raw.Count - 1].Length == 0) + { + raw.RemoveAt(raw.Count - 1); + trailing++; + } + + var builder = new StringBuilder(); + if (literal) + { + for (int i = 0; i < raw.Count; i++) + { + if (i > 0) + { + builder.Append('\n'); + } + + builder.Append(raw[i]); + } + } + else + { + // Folded: adjacent non-empty, non-indented lines join with a space; empty lines become newlines; + // more-indented lines keep their line breaks. + bool previousMoreIndented = false; + int emptyRun = 0; + for (int i = 0; i < raw.Count; i++) + { + string text = raw[i]; + if (text.Length == 0) + { + emptyRun++; + continue; + } + + bool moreIndented = text[0] == ' '; + if (builder.Length > 0) + { + if (emptyRun > 0) + { + builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0)); + } + else + { + builder.Append(moreIndented || previousMoreIndented ? '\n' : ' '); + } + } + + emptyRun = 0; + builder.Append(text); + previousMoreIndented = moreIndented; + } + } + + if (builder.Length > 0 || trailing > 0) + { + switch (chomping) + { + case 'c' when builder.Length > 0: + builder.Append('\n'); + break; + case 'k': + builder.Append('\n', builder.Length > 0 ? trailing + 1 : trailing); + break; + } + } + + return JsonValue.Create(builder.ToString()); + } + + private static JsonArray ParseFlowSequence(string text, int lineNumber) + { + var result = new JsonArray(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new FormatException($"Line {lineNumber}: unterminated flow sequence; multi-line flow collections are not supported."); + } + + if (text[pos] == ']') + { + pos++; + break; + } + + result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]")); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ',') + { + pos++; + } + else if (pos >= text.Length || text[pos] != ']') + { + throw new FormatException($"Line {lineNumber}: expected ',' or ']' in flow sequence."); + } + } + + if (StripComment(text.Substring(pos)).Trim().Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow sequence."); + } + + return result; + } + + private static JsonObject ParseFlowMapping(string text, int lineNumber) + { + var result = new JsonObject(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new FormatException($"Line {lineNumber}: unterminated flow mapping; multi-line flow collections are not supported."); + } + + if (text[pos] == '}') + { + pos++; + break; + } + + var keyNode = ParseFlowScalar(text, ref pos, lineNumber, ":"); + string key = keyNode is JsonValue v && v.TryGetValue(out string? s) ? s : keyNode?.ToJsonString() ?? "null"; + pos = SkipSpaces(text, pos); + if (pos >= text.Length || text[pos] != ':') + { + throw new FormatException($"Line {lineNumber}: expected ':' in flow mapping."); + } + + pos++; + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {lineNumber}: duplicate key '{key}'."); + } + + result[key] = ParseFlowScalar(text, ref pos, lineNumber, ",}"); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ',') + { + pos++; + } + else if (pos >= text.Length || text[pos] != '}') + { + throw new FormatException($"Line {lineNumber}: expected ',' or '}}' in flow mapping."); + } + } + + if (StripComment(text.Substring(pos)).Trim().Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow mapping."); + } + + return result; + } + + private static JsonNode? ParseFlowScalar(string text, ref int pos, int lineNumber, string terminators) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new FormatException($"Line {lineNumber}: unexpected end of flow collection."); + } + + char c = text[pos]; + if (c is '[' or '{') + { + throw new FormatException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + } + + if (c is '"' or '\'') + { + string quoted = ParseQuotedScalar(text, pos, lineNumber, out int consumed); + pos = consumed; + return JsonValue.Create(quoted); + } + + int start = pos; + while (pos < text.Length && terminators.IndexOf(text[pos]) < 0) + { + pos++; + } + + string plain = text.Substring(start, pos - start).Trim(); + if (plain.Length > 0 && plain[0] is '&' or '*' or '!') + { + throw new FormatException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + } + + return ResolvePlainScalar(plain, lineNumber); + } + + /// Parses a quoted scalar starting at ; returns the index after the closing quote. + private static string ParseQuotedScalar(string text, int start, int lineNumber, out int end) + { + char quote = text[start]; + var builder = new StringBuilder(); + int i = start + 1; + while (i < text.Length) + { + char c = text[i]; + if (c == quote) + { + if (quote == '\'' && i + 1 < text.Length && text[i + 1] == '\'') + { + builder.Append('\''); + i += 2; + continue; + } + + end = i + 1; + return builder.ToString(); + } + + if (quote == '"' && c == '\\') + { + if (++i >= text.Length) + { + break; + } + + char e = text[i]; + switch (e) + { + case '0': builder.Append('\0'); break; + case 'a': builder.Append('\a'); break; + case 'b': builder.Append('\b'); break; + case 't' or '\t': builder.Append('\t'); break; + case 'n': builder.Append('\n'); break; + case 'v': builder.Append('\v'); break; + case 'f': builder.Append('\f'); break; + case 'r': builder.Append('\r'); break; + case 'e': builder.Append('\u001B'); break; + case ' ': builder.Append(' '); break; + case '"': builder.Append('"'); break; + case '/': builder.Append('/'); break; + case '\\': builder.Append('\\'); break; + case 'N': builder.Append('\u0085'); break; + case '_': builder.Append('\u00A0'); break; + case 'L': builder.Append('\u2028'); break; + case 'P': builder.Append('\u2029'); break; + case 'x': builder.Append((char)ParseHex(text, i + 1, 2, lineNumber)); i += 2; break; + case 'u': builder.Append((char)ParseHex(text, i + 1, 4, lineNumber)); i += 4; break; + case 'U': builder.Append(char.ConvertFromUtf32(ParseHex(text, i + 1, 8, lineNumber))); i += 8; break; + default: + throw new FormatException($"Line {lineNumber}: unsupported escape sequence '\\{e}' in double-quoted scalar."); + } + + i++; + continue; + } + + builder.Append(c); + i++; + } + + throw new FormatException($"Line {lineNumber}: unterminated quoted scalar; multi-line quoted scalars are not supported."); + } + + private static int ParseHex(string text, int start, int length, int lineNumber) + { + if (start + length > text.Length || + !int.TryParse(text.Substring(start, length), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int value)) + { + throw new FormatException($"Line {lineNumber}: invalid hexadecimal escape in double-quoted scalar."); + } + + return value; + } + + /// Resolves an unquoted scalar per the YAML 1.2 core schema. + private static JsonNode? ResolvePlainScalar(string text, int lineNumber) + { + switch (text) + { + case "" or "~" or "null" or "Null" or "NULL": + return null; + case "true" or "True" or "TRUE": + return JsonValue.Create(true); + case "false" or "False" or "FALSE": + return JsonValue.Create(false); + } + + if (TryResolveNumber(text, lineNumber) is { } number) + { + return number; + } + + return JsonValue.Create(text); + } + + private static JsonNode? TryResolveNumber(string text, int lineNumber) + { + int i = 0; + bool negative = false; + if (text[0] is '+' or '-') + { + negative = text[0] == '-'; + i = 1; + } + + if (i >= text.Length) + { + return null; + } + + string body = text.Substring(i); + + if (body.StartsWith("0x", StringComparison.Ordinal) && body.Length > 2 && IsAll(body, 2, IsHexDigit)) + { + long value = Convert.ToInt64(body.Substring(2), 16); + return JsonValue.Create(negative ? -value : value); + } + + if (body.StartsWith("0o", StringComparison.Ordinal) && body.Length > 2 && IsAll(body, 2, static c => c is >= '0' and <= '7')) + { + long value = Convert.ToInt64(body.Substring(2), 8); + return JsonValue.Create(negative ? -value : value); + } + + if (body is ".inf" or ".Inf" or ".INF" or ".nan" or ".NaN" or ".NAN") + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented in JSON."); + } + + // Integer: digits only. + if (IsAll(body, 0, IsDigit)) + { + return JsonNode.Parse((negative ? "-" : string.Empty) + body.TrimStart('0').PadLeft(1, '0')); + } + + // Float: [digits][.digits][e[+-]digits], with at least one digit somewhere in the mantissa. + int mantissaEnd = 0; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + } + + int integerDigits = mantissaEnd; + int fractionDigits = 0; + if (mantissaEnd < body.Length && body[mantissaEnd] == '.') + { + mantissaEnd++; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + fractionDigits++; + } + } + + if (integerDigits + fractionDigits == 0) + { + return null; + } + + int exponentEnd = mantissaEnd; + if (exponentEnd < body.Length && body[exponentEnd] is 'e' or 'E') + { + exponentEnd++; + if (exponentEnd < body.Length && body[exponentEnd] is '+' or '-') + { + exponentEnd++; + } + + int exponentDigits = 0; + while (exponentEnd < body.Length && IsDigit(body[exponentEnd])) + { + exponentEnd++; + exponentDigits++; + } + + if (exponentDigits == 0) + { + return null; + } + } + + if (exponentEnd != body.Length) + { + return null; + } + + if (!double.TryParse(body, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) || double.IsInfinity(parsed)) + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented as a JSON number."); + } + + return JsonValue.Create(negative ? -parsed : parsed); + } + + private static bool IsDigit(char c) => c is >= '0' and <= '9'; + + private static bool IsHexDigit(char c) => c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + + private static bool IsAll(string text, int start, Func predicate) + { + if (start >= text.Length) + { + return false; + } + + for (int i = start; i < text.Length; i++) + { + if (!predicate(text[i])) + { + return false; + } + } + + return true; + } + + private static int SkipSpaces(string text, int pos) + { + while (pos < text.Length && text[pos] == ' ') + { + pos++; + } + + return pos; + } + + /// Finds the ':' that separates a plain key from its value: the first ':' followed by a space or the end of the line. + private static int FindKeySeparator(string content) + { + for (int i = 0; i < content.Length; i++) + { + if (content[i] == ':' && (i + 1 == content.Length || content[i + 1] == ' ')) + { + return i; + } + + if (content[i] == ' ' && i + 1 < content.Length && content[i + 1] == '#') + { + // Anything after " #" is a comment; a key cannot be separated inside one. + return -1; + } + } + + return -1; + } + + /// Removes a trailing comment (" #...") from a line fragment that does not start with a quote. + private static string StripComment(string text) + { + if (text.Length > 0 && text[0] == '#') + { + return string.Empty; + } + + char quote = '\0'; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + if (quote != '\0') + { + if (c == '\\' && quote == '"') + { + i++; + } + else if (c == quote) + { + quote = '\0'; + } + + continue; + } + + if (c is '"' or '\'') + { + quote = c; + } + else if (c == '#' && i > 0 && text[i - 1] == ' ') + { + return text.Substring(0, i); + } + } + + return text; + } + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs index 6c9a05ea7..81fe15827 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -9,10 +9,10 @@ namespace ModelContextProtocol.ConformanceServer.Skills; /// /// /// -/// The three static skills are authored through , so 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 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. @@ -26,14 +26,12 @@ public static class ConformanceSkills [ McpServerSkill.Create( "skill://git-workflow/SKILL.md", - Frontmatter("git-workflow", "Follow this team's Git conventions for branching and commits"), [ SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"), ]), McpServerSkill.Create( "skill://pdf-processing/SKILL.md", - Frontmatter("pdf-processing", "Extract, fill, and assemble PDF documents"), [ 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"), @@ -44,7 +42,6 @@ public static class ConformanceSkills McpServerSkill.Create( "skill://acme/billing/refunds/SKILL.md", - Frontmatter("refunds", "Process customer refund requests per company policy"), [ SkillFile("refunds", "Process customer refund requests per company policy", "# Refunds\n"), McpServerSkillFile.FromText("examples/email.md", "Subject: Your refund\n"), 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/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 44b471c1f..4600952c3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -153,12 +153,86 @@ public void Create_RejectsMissingDescription() 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)]); + + // Typed value in the file must match a typed value in the object. + McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = 2 } }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: 2\n---\n")]); + + // File uses YAML the reader rejects: the explicit object stands on its own. + var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), + [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]); + Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name); + } + [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] @@ -172,7 +246,11 @@ public void CreateFromDirectory_LoadsFilesRecursively() 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]); - var skill = McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory); + // 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( diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs new file mode 100644 index 000000000..bc0e7a70c --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -0,0 +1,96 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for WithSkillsFromDirectory: a directory of skill folders becomes a served catalog with +/// frontmatter read from each SKILL.md, URIs derived from the frontmatter names under the given prefix, and +/// non-skill subdirectories ignored. +/// +public class McpServerSkillsFromDirectoryTests : ClientServerTestBase +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-skills-root-" + Guid.NewGuid().ToString("N")); + + public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper, startServer: false) + { + Directory.CreateDirectory(Path.Combine(_root, "git-workflow", "references")); + File.WriteAllText(Path.Combine(_root, "git-workflow", "SKILL.md"), "---\nname: git-workflow\ndescription: Git conventions\nlicense: MIT\n---\n\n# Git\n"); + File.WriteAllText(Path.Combine(_root, "git-workflow", "references", "STYLE.md"), "# Style\n"); + + Directory.CreateDirectory(Path.Combine(_root, "refunds")); + File.WriteAllText(Path.Combine(_root, "refunds", "SKILL.md"), "---\nname: refunds\ndescription: >\n Process refunds\n per policy.\nmetadata:\n version: \"2.1.0\"\n---\n"); + + // Not a skill: no SKILL.md. Must be ignored. + Directory.CreateDirectory(Path.Combine(_root, "shared")); + File.WriteAllText(Path.Combine(_root, "shared", "README.md"), "not a skill"); + + // A file at the root is ignored too. + File.WriteAllText(Path.Combine(_root, "README.md"), "about these skills"); + + McpServerBuilder.WithSkillsFromDirectory(_root, uriPrefix: "skill://acme/billing"); + StartServer(); + } + + [Fact] + public async Task ServesEverySkillDirectory_WithFrontmatterFromTheFile() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(2, skills.Count); + + var gitWorkflow = Assert.Single(skills, s => s.Uri == "skill://acme/billing/git-workflow/SKILL.md"); + Assert.Equal("MIT", gitWorkflow.Frontmatter["license"]?.GetValue()); + Assert.Equal(2, gitWorkflow.Resources.Resources!.Count); + Assert.Contains(gitWorkflow.Resources.Resources, r => r.Uri == "skill://acme/billing/git-workflow/references/STYLE.md"); + + var refunds = Assert.Single(skills, s => s.Uri == "skill://acme/billing/refunds/SKILL.md"); + Assert.Equal("Process refunds per policy.\n", refunds.Description); + Assert.Equal("2.1.0", refunds.Frontmatter["metadata"]?["version"]?.GetValue()); + } + + [Fact] + public async Task ServedFiles_VerifyAgainstTheirManifest() + { + await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync("skill://acme/billing/git-workflow/SKILL.md", TestContext.Current.CancellationToken); + + foreach (var file in skill.Resources.Resources!) + { + var result = await client.ReadSkillResourceAsync(skill, file.Uri, TestContext.Current.CancellationToken); + Assert.Single(result.Contents); + } + } + + [Fact] + public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() + { + string empty = Path.Combine(Path.GetTempPath(), "mcp-skills-empty-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(empty, "not-a-skill")); + try + { + var services = new ServiceCollection(); + var exception = Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty)); + Assert.Contains("No skill directories", exception.Message); + + Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(Path.Combine(empty, "missing"))); + Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty, uriPrefix: "")); + } + finally + { + Directory.Delete(empty, recursive: true); + } + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + Directory.Delete(_root, recursive: true); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs new file mode 100644 index 000000000..46d1cc939 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -0,0 +1,289 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : the YAML subset it accepts, YAML 1.2 core-schema scalar resolution, +/// and the constructs it rejects. +/// +public class SkillFrontmatterTests +{ + private static JsonObject Parse(string yamlBody, string suffix = "\n# Body\n") => + SkillFrontmatter.Parse("---\n" + yamlBody + "\n---" + suffix); + + private static void AssertJson(string expectedJson, JsonNode? actual) => + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedJson), actual), $"Expected {expectedJson} but got {actual?.ToJsonString() ?? "null"}."); + + [Fact] + public void Parses_TheAgentSkillsReferenceShape() + { + var frontmatter = Parse(""" + name: pdf-processing + description: Extract, fill, and assemble PDF documents + license: Apache-2.0 + compatibility: Designed for Claude Code + metadata: + author: example-org + version: "1.0" + allowed-tools: Bash(git:*) Read + """); + + AssertJson(""" + { + "name": "pdf-processing", + "description": "Extract, fill, and assemble PDF documents", + "license": "Apache-2.0", + "compatibility": "Designed for Claude Code", + "metadata": { "author": "example-org", "version": "1.0" }, + "allowed-tools": "Bash(git:*) Read" + } + """, frontmatter); + } + + [Fact] + public void PreservesKeyOrder() + { + var frontmatter = Parse("zeta: 1\nalpha: 2\nmid: 3"); + + Assert.Equal(["zeta", "alpha", "mid"], frontmatter.Select(p => p.Key)); + } + + [Theory] + [InlineData("null", "null")] + [InlineData("~", "null")] + [InlineData("", "null")] + [InlineData("Null", "null")] + [InlineData("true", "true")] + [InlineData("False", "false")] + [InlineData("42", "42")] + [InlineData("-7", "-7")] + [InlineData("007", "7")] + [InlineData("0x1F", "31")] + [InlineData("0o17", "15")] + [InlineData("1.5", "1.5")] + [InlineData(".5", "0.5")] + [InlineData("1e3", "1000")] + [InlineData("-2.5E-1", "-0.25")] + [InlineData("123456789012345678901234567890", "123456789012345678901234567890")] + [InlineData("yes", "\"yes\"")] + [InlineData("on", "\"on\"")] + [InlineData("1.0.0", "\"1.0.0\"")] + [InlineData("2026-09-09", "\"2026-09-09\"")] + [InlineData("1_000", "\"1_000\"")] + [InlineData("+", "\"+\"")] + [InlineData("Bash(git:*)", "\"Bash(git:*)\"")] + [InlineData("https://example.com/a:b", "\"https://example.com/a:b\"")] + public void ResolvesPlainScalarsPerCoreSchema(string yaml, string expectedJson) + { + var frontmatter = Parse("value: " + yaml); + + AssertJson("{ \"value\": " + expectedJson + " }", frontmatter); + } + + [Theory] + [InlineData("'single quoted'", "single quoted")] + [InlineData("'it''s'", "it's")] + [InlineData("\"double quoted\"", "double quoted")] + [InlineData("\"tab\\there\"", "tab\there")] + [InlineData("\"new\\nline\"", "new\nline")] + [InlineData("\"quote \\\" inside\"", "quote \" inside")] + [InlineData("\"\\u00e9\\x41\"", "ÊA")] + [InlineData("\"1.0\"", "1.0")] + [InlineData("'true'", "true")] + [InlineData("\"a # not a comment\"", "a # not a comment")] + [InlineData("'key: value'", "key: value")] + public void ParsesQuotedScalars(string yaml, string expected) + { + var frontmatter = Parse("value: " + yaml); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + } + + [Fact] + public void StripsCommentsOutsideQuotes() + { + var frontmatter = Parse(""" + # leading comment + name: demo # trailing comment + description: has#hash inside # but this is a comment + # an indented comment line + license: 'MIT' # after quotes + """); + + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + Assert.Equal("has#hash inside", frontmatter["description"]?.GetValue()); + Assert.Equal("MIT", frontmatter["license"]?.GetValue()); + } + + [Fact] + public void FoldsPlainMultiLineScalars() + { + var frontmatter = Parse(""" + description: This description + continues on the next line + and the one after. + name: demo + """); + + Assert.Equal("This description continues on the next line and the one after.", frontmatter["description"]?.GetValue()); + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void ParsesLiteralBlockScalar() + { + var frontmatter = Parse(""" + description: | + Line one. + Line two. + + Indented line. + name: demo + """); + + Assert.Equal("Line one.\nLine two.\n\n Indented line.\n", frontmatter["description"]?.GetValue()); + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void ParsesFoldedBlockScalar() + { + var frontmatter = Parse(""" + description: > + Folded text + on two lines. + + New paragraph. + """); + + Assert.Equal("Folded text on two lines.\nNew paragraph.\n", frontmatter["description"]?.GetValue()); + } + + [Theory] + [InlineData("|-", "a\nb")] + [InlineData("|", "a\nb\n")] + [InlineData("|+", "a\nb\n\n")] + [InlineData(">-", "a b")] + public void HonorsChompingIndicators(string header, string expected) + { + var frontmatter = Parse($"value: {header}\n a\n b\n\nnext: 1"); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + Assert.Equal(1, frontmatter["next"]?.GetValue()); + } + + [Fact] + public void ParsesBlockSequences() + { + var frontmatter = Parse(""" + tags: + - one + - "two" + - 3 + same-indent: + - a + - b + objects: + - name: x + value: 1 + - name: y + """); + + AssertJson(""" + { + "tags": ["one", "two", 3], + "same-indent": ["a", "b"], + "objects": [{ "name": "x", "value": 1 }, { "name": "y" }] + } + """, frontmatter); + } + + [Fact] + public void ParsesFlowCollections() + { + var frontmatter = Parse(""" + tags: [a, "b c", 'd', 4, true] + empty: [] + map: { k: v, n: 2 } + emptymap: {} + """); + + AssertJson("""{ "tags": ["a", "b c", "d", 4, true], "empty": [], "map": { "k": "v", "n": 2 }, "emptymap": {} }""", frontmatter); + } + + [Fact] + public void ParsesNestedMappingsToAnyDepth() + { + var frontmatter = Parse(""" + a: + b: + c: + d: deep + e: 1 + f: 2 + """); + + AssertJson("""{ "a": { "b": { "c": { "d": "deep" } }, "e": 1 }, "f": 2 }""", frontmatter); + } + + [Fact] + public void HandlesCrlfAndBom() + { + var frontmatter = SkillFrontmatter.Parse("\uFEFF---\r\nname: demo\r\ndescription: d\r\n---\r\n\r\n# Body\r\n"); + + AssertJson("""{ "name": "demo", "description": "d" }""", frontmatter); + } + + [Fact] + public void AcceptsDocumentEndMarkerAsCloser() + { + var frontmatter = SkillFrontmatter.Parse("---\nname: demo\n...\nbody"); + + Assert.Equal("demo", frontmatter["name"]?.GetValue()); + } + + [Fact] + public void EmptyFrontmatterIsAnEmptyObject() + { + Assert.Empty(SkillFrontmatter.Parse("---\n---\n# Body")); + } + + [Theory] + [InlineData("# no frontmatter\nname: x", "must begin")] + [InlineData("---\nname: x\n", "not closed")] + [InlineData("---\nname: x\nname: y\n---", "duplicate key")] + [InlineData("---\n\tname: x\n---", "tabs")] + [InlineData("---\nname: &anchor x\n---", "anchors")] + [InlineData("---\nname: *alias\n---", "anchors")] + [InlineData("---\nname: !!str x\n---", "anchors")] + [InlineData("---\n? complex\n: key\n---", "complex")] + [InlineData("---\nname: \"unterminated\n---", "unterminated")] + [InlineData("---\nname: [a, [b]]\n---", "nested flow")] + [InlineData("---\nname: [a,\n b]\n---", "unterminated flow")] + [InlineData("---\nvalue: .inf\n---", "cannot be represented")] + [InlineData("---\nvalue: \"\\q\"\n---", "unsupported escape")] + [InlineData("---\njust a scalar\n---", "key: value")] + [InlineData("---\n- item\n---", "must be a YAML mapping")] + [InlineData("---\nname: a: b\n---", "cannot contain ': '")] + [InlineData("---\ndescription: text\n other: mis-indented key\n---", "cannot contain ': '")] + [InlineData("---\nname: x\n extra: indented\n---", "cannot contain ': '")] + public void RejectsUnsupportedOrMalformedInput(string markdown, string messageFragment) + { + var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + + Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void RoundTripsThroughSkillEntryValidation() + { + const string Markdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; + + var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", Markdown)]); + + Assert.Equal("skill://git-workflow/SKILL.md", skill.ProtocolSkill.Uri); + Assert.Equal("git-workflow", skill.ProtocolSkill.Name); + Assert.Equal("Git conventions", skill.ProtocolSkill.Description); + } +} From fb030819c20ef5c84659542ae5c494fbcb0b22ae Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 06:58:45 +0200 Subject: [PATCH 07/14] Align the frontmatter reader with reference YAML parsers Addresses four review findings and adds a differential corpus. WithSkillsFromDirectory now rejects a skill directory that is itself a symbolic link, closing the gap left by the per-skill loader, which only checked links inside a skill. Comment removal no longer treats an apostrophe inside a plain scalar as the start of a quoted string, so "the team's workflow # note" yields the text without the comment. Quoted values and flow collections are handed to their own parsers unstripped. A tab before '#' starts a comment, as in YAML. Folded block scalars keep leading empty lines as line breaks, matching the literal style and reference parsers. Hexadecimal and octal integers take no sign under the YAML 1.2 core schema, so "+0x10" is a string, and values beyond 64 bits keep their full unsigned magnitude instead of wrapping. Two further divergences surfaced by comparing against the yaml npm package are fixed as well: a plain scalar starting with '@', '`', or '%' is an error rather than text, and a '- ' entry on the same line as its key is an error rather than a string. SkillFrontmatterCorpusTests holds 66 cases whose expected values were produced by the yaml package's core schema, so the agreement is checked in CI without a Node dependency. The remaining differences are deliberate rejections of constructs the reader does not support. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- .../Server/McpSkillsBuilderExtensions.cs | 10 ++ .../SkillFrontmatter.cs | 116 ++++++++++-------- .../McpServerSkillsFromDirectoryTests.cs | 33 +++++ .../Server/SkillFrontmatterCorpusTests.cs | 91 ++++++++++++++ .../Server/SkillFrontmatterTests.cs | 39 ++++++ 5 files changed, 240 insertions(+), 49 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index ab539362c..c57601699 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -148,6 +148,16 @@ public static IMcpServerBuilder WithSkillsFromDirectory( var skills = new List(); foreach (string skillDirectory in skillDirectories) { + // The per-skill loader rejects links inside a skill; the same rule applies to the skill directory + // itself, which could otherwise be a link to a directory outside the skills root. + if ((File.GetAttributes(skillDirectory) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{skillDirectory}' is a symbolic link or other reparse point. Links are not followed when loading skills, " + + "because a link can point outside the skills directory. Replace it with a regular directory.", + nameof(directoryPath)); + } + if (!File.Exists(Path.Combine(skillDirectory, SkillsProtocol.SkillFileName))) { continue; diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index 176d3071d..65ec71aff 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Numerics; using System.Text; using System.Text.Json.Nodes; @@ -259,7 +260,7 @@ private JsonObject ParseMapping(int indent) throw new FormatException($"Line {line.Number}: duplicate key '{key}'."); } - string rest = StripComment(content.Substring(consumed)).Trim(); + string rest = PrepareValue(content.Substring(consumed)); _pos++; result[key] = ParseValue(rest, indent, line.Number, allowSameIndentSequence: true); } @@ -313,7 +314,7 @@ private JsonArray ParseSequence(int indent) continue; } - result.Add(ParseValue(StripComment(trimmedItem).Trim(), indent, line.Number, allowSameIndentSequence: false)); + result.Add(ParseValue(PrepareValue(trimmedItem), indent, line.Number, allowSameIndentSequence: false)); } return result; @@ -368,6 +369,16 @@ private JsonArray ParseSequence(int indent) return JsonValue.Create(quoted); default: + if (rest[0] is '@' or '`' or '%') + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{rest[0]}', which YAML reserves. Quote the value."); + } + + if (IsSequenceEntry(rest)) + { + throw new FormatException($"Line {lineNumber}: a sequence cannot start on the same line as its key. Put the '- ' entries on the following lines, or quote the value if '-' is meant literally."); + } + if (FindKeySeparator(rest) >= 0) { throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally."); @@ -515,16 +526,18 @@ private string CollectPlainContinuation(string first, int parentIndent, int line } bool moreIndented = text[0] == ' '; - if (builder.Length > 0) + if (builder.Length == 0) { - if (emptyRun > 0) - { - builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0)); - } - else - { - builder.Append(moreIndented || previousMoreIndented ? '\n' : ' '); - } + // Leading empty lines are content: each becomes a line break. + builder.Append('\n', emptyRun); + } + else if (emptyRun > 0) + { + builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0)); + } + else + { + builder.Append(moreIndented || previousMoreIndented ? '\n' : ' '); } emptyRun = 0; @@ -672,6 +685,11 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) throw new FormatException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); } + if (plain.Length > 0 && plain[0] is '@' or '`' or '%') + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{plain[0]}', which YAML reserves. Quote the value."); + } + return ResolvePlainScalar(plain, lineNumber); } @@ -776,6 +794,24 @@ private static int ParseHex(string text, int start, int length, int lineNumber) private static JsonNode? TryResolveNumber(string text, int lineNumber) { + // Hexadecimal and octal integers take no sign in the core schema, and may exceed 64 bits. + if (text.StartsWith("0x", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, IsHexDigit)) + { + var value = BigInteger.Parse("0" + text.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + + if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7')) + { + BigInteger value = BigInteger.Zero; + for (int j = 2; j < text.Length; j++) + { + value = (value * 8) + (text[j] - '0'); + } + + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + int i = 0; bool negative = false; if (text[0] is '+' or '-') @@ -791,18 +827,6 @@ private static int ParseHex(string text, int start, int length, int lineNumber) string body = text.Substring(i); - if (body.StartsWith("0x", StringComparison.Ordinal) && body.Length > 2 && IsAll(body, 2, IsHexDigit)) - { - long value = Convert.ToInt64(body.Substring(2), 16); - return JsonValue.Create(negative ? -value : value); - } - - if (body.StartsWith("0o", StringComparison.Ordinal) && body.Length > 2 && IsAll(body, 2, static c => c is >= '0' and <= '7')) - { - long value = Convert.ToInt64(body.Substring(2), 8); - return JsonValue.Create(negative ? -value : value); - } - if (body is ".inf" or ".Inf" or ".INF" or ".nan" or ".NaN" or ".NAN") { throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented in JSON."); @@ -915,7 +939,7 @@ private static int FindKeySeparator(string content) return i; } - if (content[i] == ' ' && i + 1 < content.Length && content[i + 1] == '#') + if (content[i] is ' ' or '\t' && i + 1 < content.Length && content[i + 1] == '#') { // Anything after " #" is a comment; a key cannot be separated inside one. return -1; @@ -925,37 +949,31 @@ private static int FindKeySeparator(string content) return -1; } - /// Removes a trailing comment (" #...") from a line fragment that does not start with a quote. - private static string StripComment(string text) + /// + /// Prepares the remainder of a line for : quoted values and flow collections are + /// returned as written, since their own parsers find their end and check what follows; anything else is a + /// plain value, from which a trailing comment is removed. + /// + private static string PrepareValue(string rest) { - if (text.Length > 0 && text[0] == '#') + rest = rest.TrimStart(); + if (rest.Length > 0 && rest[0] is '"' or '\'' or '[' or '{') { - return string.Empty; + return rest.TrimEnd(); } - char quote = '\0'; + return StripComment(rest).Trim(); + } + + /// + /// Removes a trailing comment from plain text. A comment starts at a '#' that begins the text or follows + /// whitespace; quotes inside plain text are ordinary characters, so an apostrophe never suppresses a comment. + /// + private static string StripComment(string text) + { for (int i = 0; i < text.Length; i++) { - char c = text[i]; - if (quote != '\0') - { - if (c == '\\' && quote == '"') - { - i++; - } - else if (c == quote) - { - quote = '\0'; - } - - continue; - } - - if (c is '"' or '\'') - { - quote = c; - } - else if (c == '#' && i > 0 && text[i - 1] == ' ') + if (text[i] == '#' && (i == 0 || text[i - 1] is ' ' or '\t')) { return text.Substring(0, i); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs index bc0e7a70c..331f4468f 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -88,6 +88,39 @@ public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() } } +#if NET + [Fact] + public void WithSkillsFromDirectory_RejectsSymbolicLinkSkillDirectories() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-linkroot-" + Guid.NewGuid().ToString("N")); + try + { + string skills = Path.Combine(root, "skills"); + string outside = Path.Combine(root, "outside"); + Directory.CreateDirectory(skills); + Directory.CreateDirectory(outside); + File.WriteAllText(Path.Combine(outside, "SKILL.md"), "---\nname: alpha\ndescription: d\n---\n"); + File.WriteAllText(Path.Combine(outside, "private.txt"), "private data"); + + try + { + Directory.CreateSymbolicLink(Path.Combine(skills, "alpha"), outside); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(skills)); + Assert.Contains("symbolic link", exception.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +#endif + public override async ValueTask DisposeAsync() { await base.DisposeAsync(); diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs new file mode 100644 index 000000000..da9632d1a --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs @@ -0,0 +1,91 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Differential corpus for . Each expected value was produced by the yaml npm +/// package (2.8.x) parsing the same text with its core schema, which is the resolution the YAML libraries used by +/// other MCP SDKs and hosts apply. A host verifies a skill by parsing the fetched SKILL.md with such a +/// library and comparing it field by field against the published entry, so agreement on these cases is the +/// property that makes entries produced by this reader verifiable. +/// +public class SkillFrontmatterCorpusTests +{ + [Theory] + [InlineData("name: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\nlicense: Apache-2.0\nmetadata:\n author: example-org\n version: \"1.0\"\nallowed-tools: Bash(git:*) Read", "{\"name\": \"pdf-processing\", \"description\": \"Extract, fill, and assemble PDF documents\", \"license\": \"Apache-2.0\", \"metadata\": {\"author\": \"example-org\", \"version\": \"1.0\"}, \"allowed-tools\": \"Bash(git:*) Read\"}")] + [InlineData("description: Follow the team's workflow # author note", "{\"description\": \"Follow the team's workflow\"}")] + [InlineData("description: She said \"go\" # and left", "{\"description\": \"She said \\\"go\\\"\"}")] + [InlineData("description: has#hash inside # but this is a comment", "{\"description\": \"has#hash inside\"}")] + [InlineData("tags: [\"a # b\", 'c # d', e] # trailing", "{\"tags\": [\"a # b\", \"c # d\", \"e\"]}")] + [InlineData("map: { k: \"v # w\", n: 2 } # trailing", "{\"map\": {\"k\": \"v # w\", \"n\": 2}}")] + [InlineData("value: >\n\n Text", "{\"value\": \"\\nText\\n\"}")] + [InlineData("value: |\n\n Text", "{\"value\": \"\\nText\\n\"}")] + [InlineData("value: >\n Folded text\n on two lines.\n\n New paragraph.", "{\"value\": \"Folded text on two lines.\\nNew paragraph.\\n\"}")] + [InlineData("value: |\n Line one.\n Line two.\n\n Indented line.\nnext: 1", "{\"value\": \"Line one.\\nLine two.\\n\\n Indented line.\\n\", \"next\": 1}")] + [InlineData("value: >\n para one\n more indented\n back", "{\"value\": \"para one\\n more indented\\nback\\n\"}")] + [InlineData("value: |-\n a\n b\n\nnext: 1", "{\"value\": \"a\\nb\", \"next\": 1}")] + [InlineData("value: |+\n a\n b\n\nnext: 1", "{\"value\": \"a\\nb\\n\\n\", \"next\": 1}")] + [InlineData("value: >-\n a\n b", "{\"value\": \"a b\"}")] + [InlineData("value: |2\n two extra\n one extra", "{\"value\": \" two extra\\n one extra\\n\"}")] + [InlineData("description: This description\n continues on the next line\n and the one after.\nname: demo", "{\"description\": \"This description continues on the next line and the one after.\", \"name\": \"demo\"}")] + [InlineData("description: first line\n\n after blank\nname: demo", "{\"description\": \"first line\\nafter blank\", \"name\": \"demo\"}")] + [InlineData("a: 0x1F\nb: 0o17\nc: 0xffffffffffffffff\nd: +0x10\ne: -0o10\nf: 007\ng: +42\nh: -7\ni: 1.5\nj: .5\nk: 1e3\nl: -2.5E-1\nm: 1.0.0\nn: 2026-09-09\no: 1_000\np: yes\nq: on\nr: ~\ns: null\nt: Null\nu: true\nv: FALSE\nw:\nx: \"\"\ny: ''\nz: 12345678901234567890123", "{\"a\": 31, \"b\": 15, \"c\": 18446744073709551615, \"d\": \"+0x10\", \"e\": \"-0o10\", \"f\": 7, \"g\": 42, \"h\": -7, \"i\": 1.5, \"j\": 0.5, \"k\": 1000, \"l\": -0.25, \"m\": \"1.0.0\", \"n\": \"2026-09-09\", \"o\": \"1_000\", \"p\": \"yes\", \"q\": \"on\", \"r\": null, \"s\": null, \"t\": null, \"u\": true, \"v\": false, \"w\": null, \"x\": \"\", \"y\": \"\", \"z\": 12345678901234567890123}")] + [InlineData("a: 1.\nb: +.5\nc: 1e\nd: .\nf: 0b101\ng: 1e+3\nh: 5.e2", "{\"a\": 1, \"b\": 0.5, \"c\": \"1e\", \"d\": \".\", \"f\": \"0b101\", \"g\": 1000, \"h\": 500}")] + [InlineData("e: -a", "{\"e\": \"-a\"}")] + [InlineData("name: 'it''s'\nq: \"tab\\there\"\nn: \"new\\nline\"\ne: \"Ê\\x41\"\ns: 'key: value'\nu: \"\\/slash\"", "{\"name\": \"it's\", \"q\": \"tab\\there\", \"n\": \"new\\nline\", \"e\": \"ÊA\", \"s\": \"key: value\", \"u\": \"/slash\"}")] + [InlineData("tags:\n - one\n - \"two\"\n - 3\nsame-indent:\n- a\n- b\nobjects:\n - name: x\n value: 1\n - name: y\nnested:\n - - a\n - b\n - - c", "{\"tags\": [\"one\", \"two\", 3], \"same-indent\": [\"a\", \"b\"], \"objects\": [{\"name\": \"x\", \"value\": 1}, {\"name\": \"y\"}], \"nested\": [[\"a\", \"b\"], [\"c\"]]}")] + [InlineData("a:\n b:\n c:\n d: deep\n e: 1\nf: 2", "{\"a\": {\"b\": {\"c\": {\"d\": \"deep\"}}, \"e\": 1}, \"f\": 2}")] + [InlineData("empty: []\nemptymap: {}\nflow: [a, b c, 'd', 4, true, null]\nfm: { k: v, n: 2, q: 'x y' }", "{\"empty\": [], \"emptymap\": {}, \"flow\": [\"a\", \"b c\", \"d\", 4, true, null], \"fm\": {\"k\": \"v\", \"n\": 2, \"q\": \"x y\"}}")] + [InlineData("\"quoted key\": 1\n'single key': 2\nkey with spaces: 3", "{\"quoted key\": 1, \"single key\": 2, \"key with spaces\": 3}")] + [InlineData("list:\n- a\n\n- b", "{\"list\": [\"a\", \"b\"]}")] + [InlineData("key:\n # comment only\n sub: 1", "{\"key\": {\"sub\": 1}}")] + [InlineData("key: value\n # indented comment\nother: 2", "{\"key\": \"value\", \"other\": 2}")] + [InlineData("seq:\n - a\n -\n - c", "{\"seq\": [\"a\", null, \"c\"]}")] + [InlineData("seq:\n - spaced: 1\n other: 2\n - plain", "{\"seq\": [{\"spaced\": 1, \"other\": 2}, \"plain\"]}")] + [InlineData("key: value with leading spaces ", "{\"key\": \"value with leading spaces\"}")] + [InlineData("description: >\n Line with trailing spaces \n next", "{\"description\": \"Line with trailing spaces next\\n\"}")] + [InlineData("metadata:\n version: 2.1\n major: 2\n flag: true\n ratio: 0.75\n", "{\"metadata\": {\"version\": 2.1, \"major\": 2, \"flag\": true, \"ratio\": 0.75}}")] + [InlineData("value: |+\n a\n\n\nnext: 1", "{\"value\": \"a\\n\\n\\n\", \"next\": 1}")] + [InlineData("value: >\n normal\n indented one\n indented two\n normal again", "{\"value\": \"normal\\n indented one\\n indented two\\nnormal again\\n\"}")] + [InlineData("value: |2\n\n leading blank then text", "{\"value\": \"\\nleading blank then text\\n\"}")] + [InlineData("value: |\nnext: 1", "{\"value\": \"\", \"next\": 1}")] + [InlineData("value: >-\nnext: 1", "{\"value\": \"\", \"next\": 1}")] + [InlineData("\"key: colon\": 1", "{\"key: colon\": 1}")] + [InlineData("value: a,b", "{\"value\": \"a,b\"}")] + [InlineData("value: [a, b, ]", "{\"value\": [\"a\", \"b\"]}")] + [InlineData("value: { a: 1, }", "{\"value\": {\"a\": 1}}")] + [InlineData("seq:\n- a\n- # comment item\n- c", "{\"seq\": [\"a\", null, \"c\"]}")] + [InlineData("seq:\n - key: v\n # comment\n other: w", "{\"seq\": [{\"key\": \"v\", \"other\": \"w\"}]}")] + [InlineData("value: \nnext: 1", "{\"value\": null, \"next\": 1}")] + [InlineData("value: \"smart “quotes” inside\"", "{\"value\": \"smart “quotes” inside\"}")] + [InlineData("value: -1", "{\"value\": -1}")] + [InlineData("value: 1 2", "{\"value\": \"1 2\"}")] + [InlineData("value: 1,000", "{\"value\": \"1,000\"}")] + [InlineData("value: 0.", "{\"value\": 0}")] + [InlineData("value: 00", "{\"value\": 0}")] + [InlineData("value: 08", "{\"value\": 8}")] + [InlineData("value: -0", "{\"value\": 0}")] + [InlineData("value: 3.14e-2", "{\"value\": 0.0314}")] + [InlineData("value: 1E5", "{\"value\": 100000}")] + [InlineData("value: TRUE", "{\"value\": true}")] + [InlineData("value: NULL", "{\"value\": null}")] + [InlineData("value: True dat", "{\"value\": \"True dat\"}")] + [InlineData("value: null value", "{\"value\": \"null value\"}")] + [InlineData("value: spaced ", "{\"value\": \"spaced\"}")] + [InlineData("value: a:b", "{\"value\": \"a:b\"}")] + [InlineData("value: \"esc \\\\ back\"", "{\"value\": \"esc \\\\ back\"}")] + [InlineData("value: '#not a comment'", "{\"value\": \"#not a comment\"}")] + [InlineData("value: x#y", "{\"value\": \"x#y\"}")] + [InlineData("value: x #y", "{\"value\": \"x\"}")] + [InlineData("value: x\t#tab\n", "{\"value\": \"x\"}")] + public void MatchesReferenceParser(string frontmatterBody, string expectedJson) + { + var actual = SkillFrontmatter.Parse("---\n" + frontmatterBody + "\n---\n"); + + var expected = JsonNode.Parse(expectedJson); + Assert.True( + JsonNode.DeepEquals(expected, actual), + $"Expected {expected?.ToJsonString()} but got {actual.ToJsonString()}."); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs index 46d1cc939..446dc76f2 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -61,6 +61,12 @@ public void PreservesKeyOrder() [InlineData("007", "7")] [InlineData("0x1F", "31")] [InlineData("0o17", "15")] + [InlineData("0xffffffffffffffff", "18446744073709551615")] + [InlineData("0o777777777777777777777777", "4722366482869645213695")] + [InlineData("+0x10", "\"+0x10\"")] + [InlineData("-0o10", "\"-0o10\"")] + [InlineData("0x", "\"0x\"")] + [InlineData("0xG", "\"0xG\"")] [InlineData("1.5", "1.5")] [InlineData(".5", "0.5")] [InlineData("1e3", "1000")] @@ -109,11 +115,26 @@ public void StripsCommentsOutsideQuotes() description: has#hash inside # but this is a comment # an indented comment line license: 'MIT' # after quotes + apostrophe: Follow the team's workflow # author note + quote: She said "go" # and left + tags: ["a # b", 'c # d', e] # trailing + map: { k: "v # w" } # trailing + block: | # header comment + text # kept + tab: x #tab before hash + dash: -a """); Assert.Equal("demo", frontmatter["name"]?.GetValue()); Assert.Equal("has#hash inside", frontmatter["description"]?.GetValue()); Assert.Equal("MIT", frontmatter["license"]?.GetValue()); + Assert.Equal("Follow the team's workflow", frontmatter["apostrophe"]?.GetValue()); + Assert.Equal("She said \"go\"", frontmatter["quote"]?.GetValue()); + AssertJson("""["a # b", "c # d", "e"]""", frontmatter["tags"]); + Assert.Equal("v # w", frontmatter["map"]?["k"]?.GetValue()); + Assert.Equal("text # kept\n", frontmatter["block"]?.GetValue()); + Assert.Equal("x", frontmatter["tab"]?.GetValue()); + Assert.Equal("-a", frontmatter["dash"]?.GetValue()); } [Fact] @@ -160,6 +181,18 @@ New paragraph. Assert.Equal("Folded text on two lines.\nNew paragraph.\n", frontmatter["description"]?.GetValue()); } + [Theory] + [InlineData(">", "\nText\n")] + [InlineData(">-", "\nText")] + [InlineData("|", "\nText\n")] + public void PreservesLeadingEmptyLinesInBlockScalars(string header, string expected) + { + var frontmatter = Parse($"value: {header}\n\n Text\nnext: 1"); + + Assert.Equal(expected, frontmatter["value"]?.GetValue()); + Assert.Equal(1, frontmatter["next"]?.GetValue()); + } + [Theory] [InlineData("|-", "a\nb")] [InlineData("|", "a\nb\n")] @@ -262,6 +295,12 @@ public void EmptyFrontmatterIsAnEmptyObject() [InlineData("---\nname: [a, [b]]\n---", "nested flow")] [InlineData("---\nname: [a,\n b]\n---", "unterminated flow")] [InlineData("---\nvalue: .inf\n---", "cannot be represented")] + [InlineData("---\nvalue: @handle\n---", "reserves")] + [InlineData("---\nvalue: `tick\n---", "reserves")] + [InlineData("---\nvalue: %pct\n---", "reserves")] + [InlineData("---\nvalue: [@a]\n---", "reserves")] + [InlineData("---\nvalue: - a\n---", "same line as its key")] + [InlineData("---\nvalue: -\n---", "same line as its key")] [InlineData("---\nvalue: \"\\q\"\n---", "unsupported escape")] [InlineData("---\njust a scalar\n---", "key: value")] [InlineData("---\n- item\n---", "must be a YAML mapping")] From f24ab73dafbb65468e6efd922752cf2bc180dafa Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 07:07:03 +0200 Subject: [PATCH 08/14] Point SkillVerifier remarks at SkillFrontmatter for frontmatter checks The remarks still said the package does not parse YAML. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- .../Client/SkillVerifier.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 524ebd0b4..53aed75dd 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -12,7 +12,8 @@ namespace ModelContextProtocol.Extensions.Skills; /// When a host retrieves a file listed in a skill's manifest, it must verify the content against that entry's /// digest and size, and must treat a read of a file the manifest does not list as a verification failure. These /// helpers implement those checks. Frontmatter verification (re-parsing the fetched SKILL.md and comparing -/// its YAML frontmatter against the entry) is not implemented here, since this package does not parse YAML. +/// its YAML frontmatter against the entry) is not performed automatically; a host can do it with +/// and . /// /// /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest From 7c99f34b42083bd0e577697fe1e040618e5894f0 Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 08:16:14 +0200 Subject: [PATCH 09/14] Link the Skills docs page to the samples by relative path The absolute links pointed at main, where the samples do not exist yet, and failed the markdown link check. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 4d13b0054..0e1e13636 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -198,7 +198,7 @@ Servers built with this package do not declare `directoryRead`, and hosts must n ### Samples -- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer): a Streamable +- [SkillsServer](../../../samples/SkillsServer/README.md): 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 +- [SkillsClient](../../../samples/SkillsClient/README.md): a client that connects to it and discovers, retrieves, and verifies them. From 287c1b5118a6657a92a4cc56f42c3a91b4605016 Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 08:58:54 +0200 Subject: [PATCH 10/14] Address the Copilot review of #1864 Catalogs now receive an McpSkillRequestContext carrying the JSON-RPC request, the caller's ClaimsPrincipal when the transport supplies one, and per-request items. The skills methods are raw handlers and do not pass through the request filters that guard the resource methods, so a catalog whose skills are not visible to every caller needs the caller to decide with; the docs and API remarks now say so plainly. The interface changes before it ships rather than after. InMemoryMcpSkillCatalog keeps its own copy of each entry, so mutating a Skill (or McpServerSkill.ProtocolSkill) after registration cannot make the published manifest disagree with the served bytes. Validation tightens to what the specifications require: resource and skill URIs must be absolute, without query or fragment, and without empty, '.', or '..' path segments, so a prefix check establishes containment; the total size check cannot overflow; and the Agent Skills frontmatter limits are enforced (description at most 1024 characters, compatibility 1 to 500, license and allowed-tools strings, metadata a map of strings). CreateFromDirectory rejects a root directory that is itself a link, to match its documented behaviour. SkillFrontmatter rejects malformed block scalar headers (repeated indicators or trailing text), comments inside flow collections (which YAML treats as running to the end of the line), and \U escapes that are not Unicode scalar values, each with a FormatException. The catalog contract now describes cursors as opaque positions a catalog may resume from rather than tokens it must have issued. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 28 +++-- .../Server/IMcpSkillCatalog.cs | 21 +++- .../Server/InMemoryMcpSkillCatalog.cs | 15 ++- .../Server/McpServerSkill.cs | 12 ++ .../Server/McpSkillRequestContext.cs | 58 ++++++++++ .../Server/McpSkillsBuilderExtensions.cs | 10 +- .../SkillFrontmatter.cs | 40 +++++-- .../SkillValidation.cs | 103 +++++++++++++++++- .../Server/InMemoryMcpSkillCatalogTests.cs | 87 +++++++++++++-- .../Server/McpServerSkillTests.cs | 38 ++++++- .../Server/McpServerSkillsCatalogTests.cs | 23 +++- .../Server/SkillFrontmatterTests.cs | 13 +++ 12 files changed, 400 insertions(+), 48 deletions(-) create mode 100644 src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 0e1e13636..8df02e2fd 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -75,8 +75,9 @@ builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, ref ``` All of these validate the skill against the specification and throw with a -specific message when, for example, the frontmatter `name` does not match the URI, `SKILL.md` is missing, or the -skill exceeds the per-skill limits of 512 files or 16 MiB. File contents are copied when the skill is created, so +specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the +Agent Skills limit of 1024 characters, `metadata` is not a map of strings, 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. 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. @@ -105,21 +106,26 @@ When skills come from a database, a file share, or a large or generated catalog, ```csharp public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog { - public async ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) + public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) { - var (skills, nextCursor) = await repository.GetPageAsync(cursor, pageSize: 50, 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, CancellationToken cancellationToken) => - repository.FindAsync(uri, cancellationToken); + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + repository.FindAsync(GetTenant(context.User), uri, cancellationToken); } ``` +Both methods receive an with the JSON-RPC request, +the caller's when the transport supplies one (the ASP.NET Core +transport does), and any items that incoming-message filters attached to the request. + A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with `resources/read`. A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers -for every skill the server serves. Throw with - for a cursor the catalog did not issue. +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. @@ -175,6 +181,12 @@ that read resources through other means. Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The specification places most of the burden on hosts. In particular: +- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request + filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing + therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some + callers must not see some skills, implement and + decide from the request context's user, and guard the corresponding resources the same way. The built-in + in-memory catalog serves the same entries to every caller. - Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters the model's context. - Digests are unsigned and come from the same server as the content. A match proves consistency between the entry diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs index 9407384a7..c6bcd6377 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -17,6 +17,11 @@ namespace ModelContextProtocol.Extensions.Skills; /// A catalog is responsible only for the entries. The skills' files must additionally be served as ordinary /// resources through resources/read, since that is how hosts fetch skill content. /// +/// +/// The skills methods do not pass through the request filters that guard the built-in resource methods, so a +/// catalog whose skills are not visible to every caller must decide what to return from the +/// it receives, and the corresponding resources must be guarded the same way. +/// /// public interface IMcpSkillCatalog { @@ -26,6 +31,7 @@ public interface IMcpSkillCatalog /// /// An opaque cursor returned by a previous call, or to start at the first page. /// + /// The request being answered, including the caller's identity when the transport supplies one. /// The to monitor for cancellation requests. /// /// A page of entries, and the cursor for the following page when more entries remain. A skill's manifest is @@ -33,21 +39,24 @@ public interface IMcpSkillCatalog /// /// /// Returning an empty page is valid. Hosts must not treat an empty listing as proof that a server has no skills. - /// Throw with for a cursor that - /// this catalog did not issue. + /// Throw with for a cursor this + /// catalog cannot interpret. A cursor is an opaque position, not a capability: a catalog may resume from a + /// well-formed cursor it does not recognize, as does. /// - ValueTask ListAsync(string? cursor, CancellationToken cancellationToken); + ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken); /// /// Gets the entry for a single skill by the URI of its SKILL.md. /// /// The URI of the skill's SKILL.md. + /// The request being answered, including the caller's identity when the transport supplies one. /// The to monitor for cancellation requests. /// - /// The skill's entry, or if this catalog does not serve a skill at . + /// The skill's entry, or if this catalog does not serve a skill at + /// to this caller. /// /// - /// This must answer for every skill the server serves, including skills omitted from . + /// This must answer for every skill the server serves to the caller, including skills omitted from . /// - ValueTask GetAsync(string uri, CancellationToken cancellationToken); + ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken); } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs index 5277376d4..0abbc47b3 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -11,8 +11,13 @@ namespace ModelContextProtocol.Extensions.Skills; /// so a server cannot publish an entry a conforming host would refuse to load. /// /// -/// Entries are ordered by URI so that pagination is stable across calls. Cursors are keyset cursors over that -/// order rather than offsets. +/// The catalog keeps its own copy of every entry, so later changes to the objects passed to the constructor do +/// not affect what is served. Entries are ordered by URI so that pagination is stable across calls. Cursors are +/// keyset cursors over that order rather than offsets. +/// +/// +/// Every caller sees the same entries. For a catalog whose contents depend on the caller, implement +/// directly and consult . /// /// public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog @@ -54,7 +59,7 @@ public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) throw new ArgumentException($"Duplicate skill URI '{skill.Uri}'.", nameof(skills)); } - _byUri.Add(skill.Uri, skill); + _byUri.Add(skill.Uri, SkillValidation.Snapshot(skill)); } _ordered = [.. _byUri.Values]; @@ -68,7 +73,7 @@ public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) public int Count => _ordered.Length; /// - public ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); @@ -97,7 +102,7 @@ public ValueTask ListAsync(string? cursor, CancellationToken cance } /// - public ValueTask GetAsync(string uri, CancellationToken cancellationToken) + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) { #if NET ArgumentNullException.ThrowIfNull(uri); diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 0b60cab08..52e2440a9 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -42,6 +42,10 @@ private McpServerSkill(Skill protocolSkill, IReadOnlyList res /// /// Gets the skill's entry, as returned by skills/list and skills/get. /// + /// + /// The catalog that WithSkills creates keeps its own copy of this entry, so changes made to this object + /// after registration do not affect what is served. + /// public Skill ProtocolSkill { get; } /// @@ -400,6 +404,14 @@ private static List ReadDirectory(string directoryPath) throw new DirectoryNotFoundException($"The skill directory '{fullDirectory}' does not exist."); } + if ((File.GetAttributes(fullDirectory) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{fullDirectory}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + "because a link can point outside the intended location. Pass the target directory instead.", + nameof(directoryPath)); + } + if (!fullDirectory.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { fullDirectory += Path.DirectorySeparatorChar; diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs new file mode 100644 index 000000000..8233642d8 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs @@ -0,0 +1,58 @@ +using ModelContextProtocol.Protocol; +using System.Security.Claims; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Describes the request an is answering. +/// +/// +/// +/// The skills methods are registered as raw request handlers, so they do not pass through the typed request +/// filter pipeline that guards the built-in resource methods (for example, the ASP.NET Core authorization +/// filters). A catalog that must not disclose every skill to every caller makes that decision itself, from the +/// and any that incoming-message filters attached to the request. +/// +/// +/// is populated by the ASP.NET Core transport from the HTTP request's principal. For other +/// transports, or when no authentication is configured, it is . +/// +/// +public sealed class McpSkillRequestContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The JSON-RPC request being answered. + /// is . + public McpSkillRequestContext(JsonRpcRequest jsonRpcRequest) + { +#if NET + ArgumentNullException.ThrowIfNull(jsonRpcRequest); +#else + if (jsonRpcRequest is null) throw new ArgumentNullException(nameof(jsonRpcRequest)); +#endif + + JsonRpcRequest = jsonRpcRequest; + } + + /// + /// Gets the JSON-RPC request being answered. + /// + public JsonRpcRequest JsonRpcRequest { get; } + + /// + /// Gets the authenticated user making the request, or if none is associated with it. + /// + public ClaimsPrincipal? User => JsonRpcRequest.Context?.User; + + /// + /// Gets the per-request items that incoming-message filters attached to the request, if any. + /// + public IDictionary? Items => JsonRpcRequest.Context?.Items; + + /// + /// Gets the protocol version the request was made under, when the transport or the request carried one. + /// + public string? ProtocolVersion => JsonRpcRequest.Context?.ProtocolVersion; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index c57601699..eccf0b1d4 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -195,6 +195,12 @@ public static IMcpServerBuilder WithSkillsFromDirectory( /// automatically, build the skills with and use the /// overload. /// + /// + /// skills/list and skills/get are registered as raw request handlers and do not pass through the + /// request filters that guard the built-in resource methods, such as the ASP.NET Core authorization filters. + /// When some callers must not see some skills, the catalog decides from the + /// it receives, and the corresponding file resources must be guarded separately. + /// /// public static IMcpServerBuilder WithSkills( this IMcpServerBuilder builder, @@ -258,7 +264,7 @@ public void Configure(McpServerOptions options) private async ValueTask HandleListSkillsAsync(JsonRpcRequest request, CancellationToken cancellationToken) { var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams); - var page = await catalog.ListAsync(requestParams?.Cursor, cancellationToken).ConfigureAwait(false); + var page = await catalog.ListAsync(requestParams?.Cursor, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false); var result = new ListSkillsResult { @@ -288,7 +294,7 @@ public void Configure(McpServerOptions options) throw new McpProtocolException("The 'uri' parameter is required.", McpErrorCode.InvalidParams); } - var skill = await catalog.GetAsync(requestParams!.Uri, cancellationToken).ConfigureAwait(false) ?? + var skill = await catalog.GetAsync(requestParams!.Uri, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false) ?? throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams); var result = new GetSkillResult { Skill = skill }; diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index 65ec71aff..cd0c7a971 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -437,27 +437,39 @@ private string CollectPlainContinuation(string first, int parentIndent, int line bool literal = header[0] == '|'; char chomping = 'c'; int explicitIndent = 0; - for (int i = 1; i < header.Length; i++) + int headerPos = 1; + for (; headerPos < header.Length && header[headerPos] != ' '; headerPos++) { - char c = header[i]; + char c = header[headerPos]; if (c is '-' or '+') { + if (chomping != 'c') + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated chomping indicator."); + } + chomping = c == '-' ? 's' : 'k'; } else if (c is >= '1' and <= '9') { + if (explicitIndent != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated indentation indicator."); + } + explicitIndent = c - '0'; } - else if (c == ' ' || c == '#') - { - break; - } else { throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}'."); } } + if (StripComment(header.Substring(headerPos)).Trim().Length != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': only a comment may follow the indicators."); + } + // Gather the raw lines of the block: everything blank, plus everything indented more than the parent. var raw = new List(); int contentIndent = explicitIndent > 0 ? parentIndent + explicitIndent : -1; @@ -676,6 +688,11 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) int start = pos; while (pos < text.Length && terminators.IndexOf(text[pos]) < 0) { + if (text[pos] == '#' && (pos == start || text[pos - 1] is ' ' or '\t')) + { + throw new FormatException($"Line {lineNumber}: a comment inside a flow collection runs to the end of the line, leaving the collection unterminated. Move the comment after the closing bracket."); + } + pos++; } @@ -744,7 +761,16 @@ private static string ParseQuotedScalar(string text, int start, int lineNumber, case 'P': builder.Append('\u2029'); break; case 'x': builder.Append((char)ParseHex(text, i + 1, 2, lineNumber)); i += 2; break; case 'u': builder.Append((char)ParseHex(text, i + 1, 4, lineNumber)); i += 4; break; - case 'U': builder.Append(char.ConvertFromUtf32(ParseHex(text, i + 1, 8, lineNumber))); i += 8; break; + case 'U': + int scalar = ParseHex(text, i + 1, 8, lineNumber); + if (scalar is < 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF)) + { + throw new FormatException($"Line {lineNumber}: '\\U{text.Substring(i + 1, 8)}' is not a valid Unicode scalar value."); + } + + builder.Append(char.ConvertFromUtf32(scalar)); + i += 8; + break; default: throw new FormatException($"Line {lineNumber}: unsupported escape sequence '\\{e}' in double-quoted scalar."); } diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs index 52603e7ae..8cbc4e363 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Nodes; + namespace ModelContextProtocol.Extensions.Skills; /// @@ -93,6 +95,45 @@ public static bool IsValidDigest(string? digest) return true; } + /// + /// Checks that is an absolute URI whose path has no empty, ., or .. + /// segments and no query or fragment, so that a prefix comparison against a skill root is meaningful. + /// + public static void ValidateUriShape(string uri, string what, string paramName) + { + int schemeEnd = uri.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd <= 0) + { + throw new ArgumentException($"{what} '{uri}' must be an absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); + } + + if (uri.IndexOf('?') >= 0 || uri.IndexOf('#') >= 0) + { + throw new ArgumentException($"{what} '{uri}' must not contain a query or fragment.", paramName); + } + + string path = uri.Substring(schemeEnd + 3); + foreach (string segment in path.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == "..") + { + throw new ArgumentException($"{what} '{uri}' must not contain empty, '.', or '..' path segments.", paramName); + } + } + } + + /// + /// Returns a copy of that shares no mutable state with it. + /// + public static Skill Snapshot(Skill skill) => new() + { + Uri = skill.Uri, + Frontmatter = (JsonObject)skill.Frontmatter.DeepClone(), + Resources = skill.Resources.IsDynamic + ? SkillResources.Dynamic + : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })), + }; + /// /// Validates a complete skill entry, throwing describing the first violation found. /// @@ -104,6 +145,7 @@ public static void Validate(Skill skill, string paramName) } string root = GetSkillRoot(skill.Uri, paramName); + ValidateUriShape(skill.Uri, "The skill URI", paramName); string nameSegment = GetNameSegment(root); if (skill.Frontmatter is null) @@ -133,11 +175,41 @@ public static void Validate(Skill skill, string paramName) paramName); } - if (string.IsNullOrEmpty(skill.Description)) + string? description = skill.Description; + if (string.IsNullOrEmpty(description)) { throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName); } + if (description!.Length > MaxDescriptionLength) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a description of {description.Length} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.", + paramName); + } + + ValidateOptionalString(skill, "license", maxLength: null, paramName); + ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); + ValidateOptionalString(skill, "allowed-tools", maxLength: null, paramName); + + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not null) + { + if (metadataNode is not JsonObject metadata) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping; the Agent Skills specification requires a map from string keys to string values.", paramName); + } + + foreach (var entry in metadata) + { + if (entry.Value is not JsonValue value || !value.TryGetValue(out string? _)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata.{entry.Key}' frontmatter value that is not a string; the Agent Skills specification requires string values. Quote it in SKILL.md if it is meant literally.", + paramName); + } + } + } + if (skill.Resources is null) { throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest. Use SkillResources.Dynamic for generated content.", paramName); @@ -180,6 +252,7 @@ public static void Validate(Skill skill, string paramName) throw new ArgumentException($"Skill '{skill.Uri}' has a resource with a null or empty URI.", paramName); } + ValidateUriShape(resource.Uri, $"Skill '{skill.Uri}' lists the resource", paramName); if (!resource.Uri.StartsWith(rootPrefix, StringComparison.Ordinal)) { throw new ArgumentException( @@ -205,6 +278,13 @@ public static void Validate(Skill skill, string paramName) throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with a negative size.", paramName); } + if (resource.Size > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' totals more than the limit of {SkillsProtocol.MaxTotalSizeBytes} bytes per skill.", + paramName); + } + totalSize += resource.Size; hasSkillFile |= string.Equals(resource.Uri, skill.Uri, StringComparison.Ordinal); } @@ -216,10 +296,27 @@ public static void Validate(Skill skill, string paramName) paramName); } - if (totalSize > SkillsProtocol.MaxTotalSizeBytes) + } + + private const int MaxDescriptionLength = 1024; + private const int MaxCompatibilityLength = 500; + + private static void ValidateOptionalString(Skill skill, string key, int? maxLength, string paramName) + { + if (!skill.Frontmatter.TryGetPropertyValue(key, out var node) || node is null) + { + return; + } + + if (node is not JsonValue value || !value.TryGetValue(out string? text)) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string.", paramName); + } + + if (text!.Length == 0 || (maxLength is { } max && text.Length > max)) { throw new ArgumentException( - $"Skill '{skill.Uri}' totals {totalSize} bytes, exceeding the limit of {SkillsProtocol.MaxTotalSizeBytes} bytes per skill.", + $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {text.Length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.", paramName); } } diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs index fc9565e29..7a252f88b 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; @@ -11,6 +12,8 @@ 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"; @@ -34,7 +37,7 @@ public async Task ListAsync_ReturnsEntriesOrderedByUri() { var catalog = CreateCatalog(10, "zebra", "alpha", "middle"); - var page = await catalog.ListAsync(null, TestContext.Current.CancellationToken); + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); Assert.Collection( page.Skills, @@ -55,7 +58,7 @@ public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries() int pages = 0; do { - var page = await catalog.ListAsync(cursor, TestContext.Current.CancellationToken); + 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."); @@ -73,8 +76,8 @@ public async Task ListAsync_LastPageHasNoNextCursor() { var catalog = CreateCatalog(2, "a", "b", "c", "d"); - var first = await catalog.ListAsync(null, TestContext.Current.CancellationToken); - var second = await catalog.ListAsync(first.NextCursor, TestContext.Current.CancellationToken); + 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); @@ -87,7 +90,7 @@ 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, TestContext.Current.CancellationToken); + var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); Assert.Single(page.Skills); Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri); @@ -98,7 +101,7 @@ public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage() { var catalog = CreateCatalog(10); - var page = await catalog.ListAsync(null, TestContext.Current.CancellationToken); + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); Assert.Empty(page.Skills); Assert.Null(page.NextCursor); @@ -110,7 +113,7 @@ public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams() var catalog = CreateCatalog(10, "a"); var exception = await Assert.ThrowsAsync( - async () => await catalog.ListAsync("not-base64!!", TestContext.Current.CancellationToken)); + async () => await catalog.ListAsync("not-base64!!", Context, TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); } @@ -120,7 +123,7 @@ public async Task GetAsync_ReturnsSkillByUri() { var catalog = CreateCatalog(10, "alpha"); - var skill = await catalog.GetAsync("skill://alpha/SKILL.md", TestContext.Current.CancellationToken); + var skill = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); Assert.NotNull(skill); Assert.Equal("alpha", skill.Name); @@ -134,7 +137,23 @@ public async Task GetAsync_WithUnknownUri_ReturnsNull(string uri) { var catalog = CreateCatalog(10, "alpha"); - Assert.Null(await catalog.GetAsync(uri, TestContext.Current.CancellationToken)); + 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] @@ -155,6 +174,19 @@ public void Constructor_WithNullSkills_Throws() => 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_AcceptsDynamicSkill() { @@ -185,6 +217,43 @@ static object[] Case(string reason, Action mutate) } yield return Case("uri not ending in /SKILL.md", s => s.Uri = "skill://alpha/skill.md"); + 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("license not a string", s => s.Frontmatter["license"] = 1); + yield return Case("allowed-tools not a string", s => s.Frontmatter["allowed-tools"] = new JsonArray("Bash")); + yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x"); + yield return Case("metadata value not a string", s => s.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1 }); 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"); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 4600952c3..34ce53cc1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -213,9 +213,9 @@ public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() // Matches the file: fine. McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); - // Typed value in the file must match a typed value 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")]); + // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object. + McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]); // File uses YAML the reader rejects: the explicit object stands on its own. var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), @@ -338,6 +338,38 @@ public void CreateFromDirectory_RejectsSymbolicLinks() } } + [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() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index 64bdbfba6..52310205c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -223,12 +223,25 @@ public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints( /// private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted) : IMcpSkillCatalog { - public ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) => - listed.ListAsync(cursor, cancellationToken); + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + AssertContext(context, SkillsProtocol.MethodSkillsList); + return listed.ListAsync(cursor, context, cancellationToken); + } - public async ValueTask GetAsync(string uri, CancellationToken cancellationToken) => - string.Equals(uri, unlisted.Uri, StringComparison.Ordinal) + public async ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) + { + AssertContext(context, SkillsProtocol.MethodSkillsGet); + return string.Equals(uri, unlisted.Uri, StringComparison.Ordinal) ? unlisted - : await listed.GetAsync(uri, cancellationToken); + : 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/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs index 446dc76f2..501216264 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -95,6 +95,7 @@ public void ResolvesPlainScalarsPerCoreSchema(string yaml, string expectedJson) [InlineData("\"new\\nline\"", "new\nline")] [InlineData("\"quote \\\" inside\"", "quote \" inside")] [InlineData("\"\\u00e9\\x41\"", "ÊA")] + [InlineData("\"\\U0001F600\"", "😀")] [InlineData("\"1.0\"", "1.0")] [InlineData("'true'", "true")] [InlineData("\"a # not a comment\"", "a # not a comment")] @@ -198,6 +199,9 @@ public void PreservesLeadingEmptyLinesInBlockScalars(string header, string expec [InlineData("|", "a\nb\n")] [InlineData("|+", "a\nb\n\n")] [InlineData(">-", "a b")] + [InlineData("|- # comment", "a\nb")] + [InlineData("|2-", "a\nb")] + [InlineData("|-2", "a\nb")] public void HonorsChompingIndicators(string header, string expected) { var frontmatter = Parse($"value: {header}\n a\n b\n\nnext: 1"); @@ -300,6 +304,15 @@ public void EmptyFrontmatterIsAnEmptyObject() [InlineData("---\nvalue: %pct\n---", "reserves")] [InlineData("---\nvalue: [@a]\n---", "reserves")] [InlineData("---\nvalue: - a\n---", "same line as its key")] + [InlineData("---\nvalue: |--\n a\n---", "repeated chomping")] + [InlineData("---\nvalue: |2-2\n a\n---", "repeated indentation")] + [InlineData("---\nvalue: | garbage\n a\n---", "only a comment may follow")] + [InlineData("---\nvalue: |x\n a\n---", "invalid block scalar header")] + [InlineData("---\nvalue: [a # comment, b]\n---", "comment inside a flow collection")] + [InlineData("---\nvalue: { a: b # c }\n---", "comment inside a flow collection")] + [InlineData("---\nvalue: \"\\U0000D800\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\U00110000\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\UFFFFFFFF\"\n---", "not a valid Unicode scalar")] [InlineData("---\nvalue: -\n---", "same line as its key")] [InlineData("---\nvalue: \"\\q\"\n---", "unsupported escape")] [InlineData("---\njust a scalar\n---", "key: value")] From d82ec59d0b14dc7e74f60c89165a0e3947886056 Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 18:15:12 +0200 Subject: [PATCH 11/14] Address the second Copilot review of #1864 The client now validates every entry returned by skills/list and skills/get against the specification's structural requirements and throws SkillVerificationException for one a host must not load. The server-side handlers apply the same validation to entries from a custom catalog and report a failure as an internal error rather than publishing it. The explicit-frontmatter escape hatch now covers only valid YAML the reader does not support (anchors, aliases, tags, complex keys, nested flow collections, multi-line quoted scalars). A SKILL.md that is not UTF-8, has no frontmatter block, or is malformed is rejected regardless, since no host could parse it either. SkillFrontmatter rejects a compact mapping inside a flow sequence ("[a: b]"), which reference parsers read as a mapping, and unpaired surrogates from \u escapes, while still accepting a surrogate pair. URI validation uses System.Uri for syntax and then checks the raw segments, so "1 bad://x/SKILL.md" is rejected and "file:///x/SKILL.md" is accepted. Optional frontmatter fields that are present but null are rejected rather than treated as absent. A self-comparing test assertion is fixed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 15 ++-- .../Client/McpSkillsClientExtensions.cs | 24 +++++- .../Server/McpServerSkill.cs | 35 +++++---- .../Server/McpSkillsBuilderExtensions.cs | 21 +++++ .../SkillFrontmatter.cs | 36 +++++++-- .../SkillValidation.cs | 37 +++++++-- .../Client/McpSkillsClientValidationTests.cs | 77 +++++++++++++++++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 18 +++++ .../Server/McpServerSkillTests.cs | 32 +++++++- .../Server/McpServerSkillsCatalogTests.cs | 40 ++++++++-- .../Server/SkillFrontmatterTests.cs | 8 +- 11 files changed, 296 insertions(+), 47 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 8df02e2fd..f657b37ae 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -93,10 +93,12 @@ host verifies a skill by parsing the fetched `SKILL.md` itself and comparing fie 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, multi-document streams, complex keys, and tab indentation are rejected with a - naming the construct. For such a file, the `Create` and `CreateFromDirectory` -overloads that take an explicit `JsonObject` supply the frontmatter directly. When the reader can parse the file, -an explicit object must match it exactly, or the skill is rejected at construction rather than by every host. +Anchors, aliases, tags, complex keys, nested flow collections, and multi-line quoted scalars are valid YAML the +reader does not support; it rejects them with a naming the construct. For such a +file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the frontmatter +directly. That escape hatch covers only valid-but-unsupported YAML: a file with no frontmatter block, malformed +YAML, or invalid UTF-8 is rejected regardless, since no host could parse it either. When the reader can parse the +file, an explicit object must match it exactly, or the skill is rejected at construction rather than by every host. #### Custom catalogs @@ -166,7 +168,10 @@ Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); ``` -`ReadSkillResourceAsync` throws when the +`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. diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index ae5afdb25..149f68c79 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -39,6 +39,10 @@ public static bool SupportsSkills(this McpClient client) /// The listed skills. /// is . /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification (for example, a name that does not match its + /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries. + /// /// The request failed or the server returned an error response. /// /// @@ -73,9 +77,10 @@ public static async ValueTask> ListSkillsAsync(this McpClient clien /// The client. /// The request parameters, including the cursor of the page to retrieve. /// The to monitor for cancellation requests. The default is . - /// The page, as returned by the server. + /// The page, as returned by the server, with every entry validated against the specification. /// or is . /// The server did not declare the MCP Skills extension. + /// The server returned an entry that violates the specification. /// The request failed or the server returned an error response. public static async ValueTask ListSkillsAsync( this McpClient client, @@ -99,8 +104,15 @@ public static async ValueTask ListSkillsAsync( }; JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - return response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + + foreach (var skill in result.Skills) + { + SkillValidation.ValidateReceived(skill, SkillsProtocol.MethodSkillsList); + } + + return result; } /// @@ -109,9 +121,10 @@ public static async ValueTask ListSkillsAsync( /// The client. /// The URI of the skill's SKILL.md. /// The to monitor for cancellation requests. The default is . - /// The skill's entry. + /// The skill's entry, validated against the specification. /// or is . /// The server did not declare the MCP Skills extension. + /// The server returned an entry that violates the specification. /// /// The request failed or the server returned an error response, including /// when the server serves no skill at . @@ -165,8 +178,11 @@ public static async ValueTask GetSkillAsync( }; JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - return response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); + + SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); + return result; } /// diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 52e2440a9..fb96cac68 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -308,31 +308,38 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj } // Read the frontmatter from SKILL.md (always first after sorting). When the caller supplied frontmatter, - // the file is still read so that the two can be checked against each other; if the file uses YAML the - // reader does not support, the caller's frontmatter stands on its own. + // the file is still read so that the two can be checked against each other. The caller's frontmatter + // stands on its own only when the file uses valid YAML the reader does not support; a file that is not + // UTF-8, has no frontmatter block, or is malformed cannot be reproduced by any host and is rejected. + if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) + { + throw new ArgumentException($"{SkillsProtocol.SkillFileName} is not valid UTF-8.", filesParamName); + } + JsonObject? fileFrontmatter = null; - FormatException? frontmatterError = null; try { - if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) - { - throw new FormatException($"{SkillsProtocol.SkillFileName} is not valid UTF-8."); - } - fileFrontmatter = SkillFrontmatter.Parse(skillMarkdown!); } + catch (SkillFrontmatter.UnsupportedYamlException e) when (frontmatter is null) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} uses YAML that {nameof(SkillFrontmatter)} does not support: {e.Message} " + + "Supply the frontmatter explicitly with the overload that takes a JsonObject.", + filesParamName); + } + catch (SkillFrontmatter.UnsupportedYamlException) + { + // Explicit frontmatter covers this case. + } catch (FormatException e) { - frontmatterError = e; + throw new ArgumentException($"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {e.Message}", filesParamName); } if (frontmatter is null) { - frontmatter = fileFrontmatter ?? throw new ArgumentException( - $"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {frontmatterError!.Message} " + - $"If the file uses YAML that {nameof(SkillFrontmatter)} does not support, supply the frontmatter explicitly " + - "with the overload that takes a JsonObject.", - filesParamName); + frontmatter = fileFrontmatter!; } else if (fileFrontmatter is not null && !JsonNode.DeepEquals(fileFrontmatter, frontmatter)) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index eccf0b1d4..e8e4eac41 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -265,6 +265,10 @@ public void Configure(McpServerOptions options) { var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams); var page = await catalog.ListAsync(requestParams?.Cursor, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false); + foreach (var entry in page.Skills) + { + ValidateCatalogEntry(entry); + } var result = new ListSkillsResult { @@ -296,6 +300,7 @@ public void Configure(McpServerOptions options) var skill = await catalog.GetAsync(requestParams!.Uri, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false) ?? throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams); + ValidateCatalogEntry(skill); var result = new GetSkillResult { Skill = skill }; if (IsJuly2026OrLaterProtocolRequest(request)) @@ -306,6 +311,22 @@ public void Configure(McpServerOptions options) return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); } + /// + /// A custom catalog is trusted to answer, but not to be correct: an invalid entry is a server bug, reported + /// as an internal error rather than published to hosts that would have to reject it. + /// + private static void ValidateCatalogEntry(Skill? entry) + { + try + { + SkillValidation.Validate(entry!, "entry"); + } + catch (ArgumentException e) + { + throw new McpProtocolException($"The skill catalog returned an invalid entry: {e.Message}", McpErrorCode.InternalError); + } + } + private static T? DeserializeParams(JsonRpcRequest request, JsonTypeInfo typeInfo) where T : class { if (request.Params is null) diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index cd0c7a971..4a462d0de 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -28,6 +28,12 @@ public static class SkillFrontmatter { private const string Delimiter = "---"; + /// + /// Thrown for YAML that is valid but outside the subset this reader supports, as opposed to malformed + /// frontmatter. Callers that accept explicitly supplied frontmatter may fall back on it for this case only. + /// + internal sealed class UnsupportedYamlException(string message) : FormatException(message); + /// /// Parses the frontmatter at the start of . /// @@ -222,7 +228,7 @@ private JsonObject ParseMapping(int indent) string content = line.Content; if (content[0] == '?') { - throw new FormatException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter."); + throw new UnsupportedYamlException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter."); } int consumed; @@ -351,7 +357,7 @@ private JsonArray ParseSequence(int indent) return ParseBlockScalar(rest, parentIndent, lineNumber); case '&' or '*' or '!': - throw new FormatException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); case '[': return ParseFlowSequence(rest, lineNumber); @@ -675,7 +681,7 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) char c = text[pos]; if (c is '[' or '{') { - throw new FormatException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); } if (c is '"' or '\'') @@ -697,9 +703,14 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) } string plain = text.Substring(start, pos - start).Trim(); + if (FindKeySeparator(plain) >= 0) + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + if (plain.Length > 0 && plain[0] is '&' or '*' or '!') { - throw new FormatException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); } if (plain.Length > 0 && plain[0] is '@' or '`' or '%') @@ -729,7 +740,20 @@ private static string ParseQuotedScalar(string text, int start, int lineNumber, } end = i + 1; - return builder.ToString(); + string result = builder.ToString(); + for (int k = 0; k < result.Length; k++) + { + if (char.IsHighSurrogate(result[k]) && k + 1 < result.Length && char.IsLowSurrogate(result[k + 1])) + { + k++; + } + else if (char.IsSurrogate(result[k])) + { + throw new FormatException($"Line {lineNumber}: the quoted scalar contains an unpaired surrogate escape, which is not a valid Unicode character."); + } + } + + return result; } if (quote == '"' && c == '\\') @@ -783,7 +807,7 @@ private static string ParseQuotedScalar(string text, int start, int lineNumber, i++; } - throw new FormatException($"Line {lineNumber}: unterminated quoted scalar; multi-line quoted scalars are not supported."); + throw new UnsupportedYamlException($"Line {lineNumber}: unterminated quoted scalar; multi-line quoted scalars are not supported."); } private static int ParseHex(string text, int start, int length, int lineNumber) diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs index 8cbc4e363..2b9212c60 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -102,9 +102,9 @@ public static bool IsValidDigest(string? digest) public static void ValidateUriShape(string uri, string what, string paramName) { int schemeEnd = uri.IndexOf("://", StringComparison.Ordinal); - if (schemeEnd <= 0) + if (schemeEnd <= 0 || !System.Uri.TryCreate(uri, UriKind.Absolute, out _)) { - throw new ArgumentException($"{what} '{uri}' must be an absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); + throw new ArgumentException($"{what} '{uri}' must be a syntactically valid absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); } if (uri.IndexOf('?') >= 0 || uri.IndexOf('#') >= 0) @@ -112,10 +112,13 @@ public static void ValidateUriShape(string uri, string what, string paramName) throw new ArgumentException($"{what} '{uri}' must not contain a query or fragment.", paramName); } - string path = uri.Substring(schemeEnd + 3); - foreach (string segment in path.Split('/')) + // Check the raw text rather than System.Uri's view of it, which compacts dot segments. The authority + // (the first segment) may be empty, as in file:///name/SKILL.md; path segments may not. + string[] segments = uri.Substring(schemeEnd + 3).Split('/'); + for (int i = 0; i < segments.Length; i++) { - if (segment.Length == 0 || segment == "." || segment == "..") + string segment = segments[i]; + if ((segment.Length == 0 && i > 0) || segment == "." || segment == "..") { throw new ArgumentException($"{what} '{uri}' must not contain empty, '.', or '..' path segments.", paramName); } @@ -134,6 +137,22 @@ public static void ValidateUriShape(string uri, string what, string paramName) : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })), }; + /// + /// Validates an entry received from a server, throwing describing the + /// first violation found. Hosts must not load invalid entries, so an invalid entry is a verification failure. + /// + public static void ValidateReceived(Skill skill, string method) + { + try + { + Validate(skill, "skill"); + } + catch (ArgumentException e) + { + throw new SkillVerificationException($"The server returned an invalid skill entry from '{method}': {e.Message}", e); + } + } + /// /// Validates a complete skill entry, throwing describing the first violation found. /// @@ -192,7 +211,7 @@ public static void Validate(Skill skill, string paramName) ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); ValidateOptionalString(skill, "allowed-tools", maxLength: null, paramName); - if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not null) + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode)) { if (metadataNode is not JsonObject metadata) { @@ -303,14 +322,16 @@ public static void Validate(Skill skill, string paramName) private static void ValidateOptionalString(Skill skill, string key, int? maxLength, string paramName) { - if (!skill.Frontmatter.TryGetPropertyValue(key, out var node) || node is null) + if (!skill.Frontmatter.TryGetPropertyValue(key, out var node)) { return; } if (node is not JsonValue value || !value.TryGetValue(out string? text)) { - throw new ArgumentException($"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string.", paramName); + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string. Give it a value, or remove the key if it is not needed.", + paramName); } if (text!.Length == 0 || (maxLength is { } max && text.Length > max)) diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs new file mode 100644 index 000000000..690a25430 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -0,0 +1,77 @@ +#pragma warning disable MCPEXP002 // Raw request handlers are the point of this test. + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// The client extensions validate every entry a server returns against the specification's structural +/// requirements, since hosts must not load invalid entries. These tests stand up a server whose raw +/// skills/list and skills/get handlers return entries the SDK's own server side would never publish. +/// +public class McpSkillsClientValidationTests : ClientServerTestBase +{ + public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= []; + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (_, _) => new ValueTask(JsonNode.Parse(""" + { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] }, + { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" }, + "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] } + """)), + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsGet, + Handler = (_, _) => new ValueTask(JsonNode.Parse(""" + { "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 } ] } } + """)), + }); + }); + } + + [Fact] + public async Task ListSkillsAsync_RejectsAnInvalidEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("skills/list", exception.Message); + Assert.Contains("skill://bad/SKILL.md", exception.Message); + } + + [Fact] + public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://escape/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skills/get", exception.Message); + Assert.Contains("'..'", exception.Message); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs index 7a252f88b..8aa469a63 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -187,6 +187,16 @@ public void Constructor_AcceptsFullAgentSkillsFrontmatter() 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() { @@ -217,6 +227,14 @@ static object[] Case(string reason, Action mutate) } 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("license present but null", s => s.Frontmatter["license"] = null); yield return Case("relative uri", s => { s.Uri = "alpha/SKILL.md"; diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 34ce53cc1..4e3770630 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -24,7 +24,8 @@ public void Create_ComputesManifestFromFileBytes() { byte[] guide = Encoding.UTF8.GetBytes("# Guide\n"); - var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + var frontmatter = Frontmatter(); + var skill = McpServerSkill.Create(SkillUri, frontmatter, [ new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), @@ -32,7 +33,7 @@ public void Create_ComputesManifestFromFileBytes() var entry = skill.ProtocolSkill; Assert.Equal(SkillUri, entry.Uri); - Assert.Same(entry.Frontmatter, entry.Frontmatter); + Assert.Same(frontmatter, entry.Frontmatter); Assert.False(entry.Resources.IsDynamic); var manifest = entry.Resources.Resources!; @@ -83,7 +84,7 @@ public void Create_ProducesOneResourcePerFileWithSpecMetadata() [InlineData("references\\GUIDE.md", "references/GUIDE.md")] public void Create_NormalizesFilePaths(string input, string expectedRelative) { - var files = new List { McpServerSkillFile.FromText(input, "x") }; + var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") }; if (expectedRelative != "SKILL.md") { files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)); @@ -217,12 +218,35 @@ public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } }, [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]); - // File uses YAML the reader rejects: the explicit object stands on its own. + // File uses valid YAML the reader does not support: the explicit object stands on its own. var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]); Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name); } + [Theory] + [InlineData("# no frontmatter block\n", "must begin")] + [InlineData("---\nname: git-workflow\n", "not closed")] + [InlineData("---\nname: a: b\n---\n", "cannot contain")] + [InlineData("---\n\tname: x\n---\n", "tabs")] + public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) + { + // Only valid-but-unsupported YAML may be bypassed. A file no host can parse must not be published. + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", markdown)])); + + Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsNonUtf8SkillFile() + { + var exception = Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [new McpServerSkillFile { Path = "SKILL.md", Content = new byte[] { 0x2D, 0x2D, 0x2D, 0x0A, 0xFF, 0xFE } }])); + + Assert.Contains("UTF-8", exception.Message); + } + [Fact] public void Create_RejectsNullArguments() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index 52310205c..da1160e72 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -19,6 +19,7 @@ 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"; public McpServerSkillsCatalogTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) @@ -42,9 +43,18 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer 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); + unlisted: unlisted, + broken: broken); mcpServerBuilder .WithSkills(catalog, options => @@ -104,6 +114,18 @@ public async Task GetSkillAsync_AnswersForSkillAbsentFromListing() Assert.Equal("hidden", skill.Name); } + [Fact] + public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(BrokenUri, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Contains("invalid entry", exception.Message); + } + [Fact] public async Task DynamicSkill_IsListedWithTheDynamicMarker() { @@ -221,7 +243,7 @@ public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints( /// /// A catalog that lists some skills and serves one more by URI only. /// - private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted) : IMcpSkillCatalog + private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlisted, Skill broken) : IMcpSkillCatalog { public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) { @@ -232,9 +254,17 @@ public ValueTask ListAsync(string? cursor, McpSkillRequestContext public async ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) { AssertContext(context, SkillsProtocol.MethodSkillsGet); - return string.Equals(uri, unlisted.Uri, StringComparison.Ordinal) - ? unlisted - : await listed.GetAsync(uri, context, cancellationToken); + if (string.Equals(uri, unlisted.Uri, StringComparison.Ordinal)) + { + return unlisted; + } + + if (string.Equals(uri, broken.Uri, StringComparison.Ordinal)) + { + return broken; + } + + return await listed.GetAsync(uri, context, cancellationToken); } // The catalog receives the request it is answering, so a per-caller catalog can decide from it. diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs index 501216264..b6595c460 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -96,6 +96,7 @@ public void ResolvesPlainScalarsPerCoreSchema(string yaml, string expectedJson) [InlineData("\"quote \\\" inside\"", "quote \" inside")] [InlineData("\"\\u00e9\\x41\"", "ÊA")] [InlineData("\"\\U0001F600\"", "😀")] + [InlineData("\"\\uD83D\\uDE00\"", "😀")] [InlineData("\"1.0\"", "1.0")] [InlineData("'true'", "true")] [InlineData("\"a # not a comment\"", "a # not a comment")] @@ -313,6 +314,10 @@ public void EmptyFrontmatterIsAnEmptyObject() [InlineData("---\nvalue: \"\\U0000D800\"\n---", "not a valid Unicode scalar")] [InlineData("---\nvalue: \"\\U00110000\"\n---", "not a valid Unicode scalar")] [InlineData("---\nvalue: \"\\UFFFFFFFF\"\n---", "not a valid Unicode scalar")] + [InlineData("---\nvalue: \"\\uD800\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: \"\\uDE00x\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: [a: b]\n---", "compact mappings inside flow sequences")] + [InlineData("---\nvalue: [a, b: c]\n---", "compact mappings inside flow sequences")] [InlineData("---\nvalue: -\n---", "same line as its key")] [InlineData("---\nvalue: \"\\q\"\n---", "unsupported escape")] [InlineData("---\njust a scalar\n---", "key: value")] @@ -322,7 +327,8 @@ public void EmptyFrontmatterIsAnEmptyObject() [InlineData("---\nname: x\n extra: indented\n---", "cannot contain ': '")] public void RejectsUnsupportedOrMalformedInput(string markdown, string messageFragment) { - var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + // Unsupported-but-valid YAML throws a FormatException subclass, so match by assignability. + var exception = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); Assert.Contains(messageFragment, exception.Message, StringComparison.OrdinalIgnoreCase); } From 8794f0324e6b1f79261b80fb36c76c9cd9b30cf0 Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 19:14:18 +0200 Subject: [PATCH 12/14] Bind skills/get answers to the requested URI A skills/get response is only useful if it describes the skill that was asked for. The client now rejects a valid entry for a different URI with SkillVerificationException, and the server handler reports a custom catalog that answers for the wrong URI as an internal error rather than publishing it. Both directions are tested. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- .../Client/McpSkillsClientExtensions.cs | 10 ++++- .../Server/McpSkillsBuilderExtensions.cs | 6 +++ .../Client/McpSkillsClientValidationTests.cs | 38 ++++++++++++++++--- .../Server/McpServerSkillsCatalogTests.cs | 19 ++++++++++ 4 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index 149f68c79..e1de222e4 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -124,7 +124,9 @@ public static async ValueTask ListSkillsAsync( /// The skill's entry, validated against the specification. /// or is . /// The server did not declare the MCP Skills extension. - /// The server returned an entry that violates the specification. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// /// /// The request failed or the server returned an error response, including /// when the server serves no skill at . @@ -182,6 +184,12 @@ public static async ValueTask GetSkillAsync( throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); + if (!string.Equals(result.Skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestParams.Uri}' with the entry for '{result.Skill.Uri}'."); + } + return result; } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index e8e4eac41..931bb85d7 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -301,6 +301,12 @@ public void Configure(McpServerOptions options) var skill = await catalog.GetAsync(requestParams!.Uri, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false) ?? throw new McpProtocolException($"No skill is served at '{requestParams.Uri}'.", McpErrorCode.InvalidParams); ValidateCatalogEntry(skill); + if (!string.Equals(skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + { + throw new McpProtocolException( + $"The skill catalog answered a request for '{requestParams.Uri}' with the entry for '{skill.Uri}'.", + McpErrorCode.InternalError); + } var result = new GetSkillResult { Skill = skill }; if (IsJuly2026OrLaterProtocolRequest(request)) diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs index 690a25430..d6f25478e 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -42,11 +42,22 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.RequestHandlers.Add(new McpServerRequestHandler { Method = SkillsProtocol.MethodSkillsGet, - Handler = (_, _) => new ValueTask(JsonNode.Parse(""" - { "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 } ] } } - """)), + 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)); + }, }); }); } @@ -63,6 +74,23 @@ public async Task ListSkillsAsync_RejectsAnInvalidEntry() Assert.Contains("skill://bad/SKILL.md", exception.Message); } + [Fact] + public async Task GetSkillAsync_RejectsAValidEntryForADifferentUri() + { + await using McpClient client = await CreateMcpClientForServer(); + + // The entry itself is valid; it is just not the skill that was asked for. + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://other/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skill://other/SKILL.md", exception.Message); + Assert.Contains("skill://good/SKILL.md", exception.Message); + + // Asking for the URI the server actually returns succeeds. + var good = await client.GetSkillAsync("skill://good/SKILL.md", TestContext.Current.CancellationToken); + Assert.Equal("good", good.Name); + } + [Fact] public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index da1160e72..2d6d85acb 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -20,6 +20,7 @@ public class McpServerSkillsCatalogTests : ClientServerTestBase 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) @@ -114,6 +115,18 @@ public async Task GetSkillAsync_AnswersForSkillAbsentFromListing() 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() { @@ -264,6 +277,12 @@ public ValueTask ListAsync(string? cursor, McpSkillRequestContext 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); } From aa94cda1c45ce8cf7ec9c79d37a01cc3c227bb8e Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 20:10:16 +0200 Subject: [PATCH 13/14] Enforce skill limits while reading a directory; match Core's URI equivalence CreateFromDirectory now applies the 512-file and 16 MiB limits while walking the directory, using each file's reported length, so an oversized or overly broad directory fails naming the limit before anything is read. The manifest built from the bytes actually read is still validated against the same limits afterwards. WithSkills detects files shared between skills with the same equivalence the server's resource collection uses for concrete URIs, System.Uri equality, under which scheme and authority are case-insensitive. Two skills whose file URIs differ only in authority case previously passed the ordinal check and were then merged silently by the collection, so reads of the second returned the first's bytes. Same content still registers once; different content is an error that names both URIs. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- .../Server/McpServerSkill.cs | 27 +++++++++-- .../Server/McpSkillsBuilderExtensions.cs | 17 ++++--- .../Server/McpServerSkillTests.cs | 45 +++++++++++++++++++ .../McpServerSkillsFromDirectoryTests.cs | 16 +++++++ 4 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index fb96cac68..af3d1a280 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -425,7 +425,8 @@ private static List ReadDirectory(string directoryPath) } var files = new List(); - CollectFiles(fullDirectory, fullDirectory, files); + long totalSize = 0; + CollectFiles(fullDirectory, fullDirectory, files, ref totalSize); return files; } @@ -434,7 +435,7 @@ private static List ReadDirectory(string directoryPath) /// outside the skill directory, and a file reached through one would be published under a URI that looks like /// it lives inside the skill. Rather than try to decide which link targets are acceptable, links are rejected. /// - private static void CollectFiles(string root, string directory, List files) + private static void CollectFiles(string root, string directory, List files, ref long totalSize) { foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) { @@ -450,10 +451,30 @@ private static void CollectFiles(string root, string directory, List= SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"The skill directory '{root}' contains more than {SkillsProtocol.MaxResourcesPerSkill} files, the limit per skill.", + "directoryPath"); + } + + long length = new FileInfo(entry).Length; + if (length > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"The files under '{root}' total more than {SkillsProtocol.MaxTotalSizeBytes} bytes, the limit per skill.", + "directoryPath"); + } + + totalSize += length; + string relativePath = entry.Substring(root.Length).Replace(Path.DirectorySeparatorChar, '/'); if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index 931bb85d7..67ebfefcd 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -49,7 +49,11 @@ public static IMcpServerBuilder WithSkills( #endif var entries = new List(); - var registeredFiles = new Dictionary(StringComparer.Ordinal); + + // The server's resource collection keys concrete resources by System.Uri equality, under which scheme and + // authority are case-insensitive, and it registers silently. Detect shared files under those same + // semantics, so two URIs the collection would merge are caught here rather than served as one another. + var registeredFiles = new Dictionary(); foreach (var skill in skills) { if (skill is null) @@ -63,19 +67,22 @@ public static IMcpServerBuilder WithSkills( for (int i = 0; i < manifest.Count; i++) { var entry = manifest[i]; - if (registeredFiles.TryGetValue(entry.Uri, out string? existingDigest)) + var key = new Uri(entry.Uri, UriKind.Absolute); + if (registeredFiles.TryGetValue(key, out var existing)) { - if (!string.Equals(existingDigest, entry.Digest, StringComparison.Ordinal)) + if (!string.Equals(existing.Digest, entry.Digest, StringComparison.Ordinal)) { throw new ArgumentException( - $"The file '{entry.Uri}' is listed by more than one skill with different content.", + string.Equals(existing.Uri, entry.Uri, StringComparison.Ordinal) + ? $"The file '{entry.Uri}' is listed by more than one skill with different content." + : $"The file '{entry.Uri}' is equivalent to '{existing.Uri}', which another skill lists with different content. Resource URIs are compared case-insensitively in their scheme and authority.", nameof(skills)); } continue; } - registeredFiles.Add(entry.Uri, entry.Digest); + registeredFiles.Add(key, (entry.Uri, entry.Digest)); builder.Services.AddSingleton(skill.Resources[i]); } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 4e3770630..94138ddf4 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -424,6 +424,51 @@ public void CreateFromDirectory_RejectsDirectorySymbolicLinks() } #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() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs index 331f4468f..680392ec3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -68,6 +68,22 @@ public async Task ServedFiles_VerifyAgainstTheirManifest() } } + [Fact] + public void WithSkills_DetectsFilesThatCollideUnderUriEquivalence() + { + // The resource collection compares URIs case-insensitively in scheme and authority, so these two skills + // would silently share one registered resource. With different content that must be an error. + var upper = McpServerSkill.Create("skill://Acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + var lower = McpServerSkill.Create("skill://acme/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: lower\n---\n")]); + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, lower])); + Assert.Contains("equivalent", exception.Message); + + // Identical content is the nested-skill case and is allowed: the file is registered once. + var same = McpServerSkill.Create("skill://ACME/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + new ServiceCollection().AddMcpServer().WithSkills([upper, same]); + } + [Fact] public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() { From bc145973925ca04695279391285a7e5b4ce3b77a Mon Sep 17 00:00:00 2001 From: Peder Date: Wed, 9 Sep 2026 21:29:18 +0200 Subject: [PATCH 14/14] Address review feedback from the author of #1856 Make CacheScopeConverter public in Core instead of compiling its source into the Skills package, which was the only package doing so. Adding a public type passes package validation against the 2.0.0 baseline. Validate only what the Agent Skills specification states as requirements beyond name and description: compatibility is 1 to 500 characters if provided, and metadata is a mapping. license, allowed-tools, and the values inside metadata pass through verbatim. Hosts compare frontmatter against the file and ignore allowed-tools for MCP-origin skills, so rejecting a whole skill over the shape of such a field would help nobody. Repeat the authorization caveat on WithSkills(IEnumerable) and WithSkillsFromDirectory, whose in-memory catalog serves every caller, and explain why a directory without SKILL.md is skipped while an invalid skill is an error. Guard the public verifier and client read path against a Skill whose required Resources property was assigned null, and document SkillVerificationException on the remaining GetSkillAsync overload. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01P6omaTvQiryHtzFHRqUE3b --- docs/concepts/skills/skills.md | 6 ++-- .../Protocol/CacheScopeConverter.cs | 9 ++++-- .../Client/McpSkillsClientExtensions.cs | 8 +++-- .../Client/SkillVerifier.cs | 29 ++++++++++++++----- ...elContextProtocol.Extensions.Skills.csproj | 6 ---- .../Server/McpSkillsBuilderExtensions.cs | 22 ++++++++++++++ .../SkillValidation.cs | 26 ++++++----------- .../Client/SkillVerifierTests.cs | 12 ++++++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 17 ++++++++--- 9 files changed, 94 insertions(+), 41 deletions(-) diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index f657b37ae..c9d0e3042 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -76,8 +76,10 @@ builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, ref All of these validate the skill against the specification and throw with a specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the -Agent Skills limit of 1024 characters, `metadata` is not a map of strings, 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. File contents are copied when the skill is created, so +Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or +the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`, +`compatibility`, and the shape of `metadata` pass through verbatim, as the specification requires; hosts compare +them against the file and ignore fields such as `allowed-tools` for MCP-origin skills. File contents are copied when the skill is created, so later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs. 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.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index e1de222e4..19c649102 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -154,9 +154,12 @@ public static async ValueTask GetSkillAsync(this McpClient client, string /// The client. /// The request parameters. /// The to monitor for cancellation requests. The default is . - /// The result, as returned by the server. + /// The result, as returned by the server, with the entry validated against the specification. /// or is . /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// /// The request failed or the server returned an error response. public static async ValueTask GetSkillAsync( this McpClient client, @@ -233,13 +236,14 @@ public static async ValueTask ReadSkillResourceAsync( if (uri is null) throw new ArgumentNullException(nameof(uri)); #endif - if (skill.Resources.IsDynamic) + if (skill.Resources is { IsDynamic: true }) { throw new InvalidOperationException( $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified. " + $"Use {nameof(McpClient.ReadResourceAsync)} directly if unverifiable content is acceptable."); } + SkillVerifier.ThrowIfDynamic(skill); if (SkillVerifier.FindResource(skill, uri) is null) { throw new SkillVerificationException( diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 53aed75dd..59aa04144 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -155,11 +155,7 @@ public static void Verify(Skill skill, ReadResourceResult result) if (result is null) throw new ArgumentNullException(nameof(result)); #endif - if (skill.Resources.IsDynamic) - { - throw new InvalidOperationException( - $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified."); - } + ThrowIfDynamic(skill); if (result.Contents is not { Count: > 0 }) { @@ -207,7 +203,8 @@ public static void Verify(Skill skill, string uri, ReadResourceResult result) if (result is null) throw new ArgumentNullException(nameof(result)); #endif - if (!skill.Resources.IsDynamic && FindResource(skill, uri) is null) + ThrowIfDynamic(skill); + if (FindResource(skill, uri) is null) { throw new SkillVerificationException( $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + @@ -229,9 +226,27 @@ public static void Verify(Skill skill, string uri, ReadResourceResult result) } } + /// + /// Rejects a skill whose manifest is missing (the property is required but can still be assigned + /// ) or dynamic, neither of which can be verified. + /// + internal static void ThrowIfDynamic(Skill skill) + { + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest.", nameof(skill)); + } + + if (skill.Resources.IsDynamic) + { + throw new InvalidOperationException( + $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified."); + } + } + internal static SkillResource? FindResource(Skill skill, string uri) { - var resources = skill.Resources.Resources; + var resources = skill.Resources?.Resources; if (resources is null) { return null; diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj index 0cbb0696c..00a0bf57f 100644 --- a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -29,12 +29,6 @@ - - diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index 67ebfefcd..927ac2ad6 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -34,6 +34,13 @@ public static class McpSkillsBuilderExtensions /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, /// provided every skill lists it with the same digest. /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// /// public static IMcpServerBuilder WithSkills( this IMcpServerBuilder builder, @@ -113,9 +120,24 @@ public static IMcpServerBuilder WithSkills( /// ), or two skills declare the same name. /// /// + /// /// Each skill is built with : files are read once, /// digests are computed from the bytes served, and symbolic links are rejected. Only immediate subdirectories /// are considered skills; a SKILL.md nested deeper inside a skill is one of that skill's files. + /// + /// + /// A subdirectory without a SKILL.md is not a skill and is ignored, so shared assets or documentation can + /// live alongside skills. A subdirectory with a SKILL.md is a skill, and a skill that fails validation is + /// an error rather than a skipped entry, so that a broken skill is noticed at startup instead of being silently + /// absent from the catalog. + /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// /// public static IMcpServerBuilder WithSkillsFromDirectory( this IMcpServerBuilder builder, diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs index 2b9212c60..9e8f2eefb 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -207,26 +207,18 @@ public static void Validate(Skill skill, string paramName) paramName); } - ValidateOptionalString(skill, "license", maxLength: null, paramName); + // Beyond name and description, only the constraints the Agent Skills specification states as requirements + // are enforced: compatibility is 1 to 500 characters if provided, and metadata is a mapping. Other fields, + // and the values inside metadata, pass through verbatim. Hosts compare frontmatter field by field against + // the file and ignore fields such as allowed-tools for MCP-origin skills, so rejecting a whole skill over + // the shape of a field no host acts on would help nobody. ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); - ValidateOptionalString(skill, "allowed-tools", maxLength: null, paramName); - if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode)) + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not JsonObject) { - if (metadataNode is not JsonObject metadata) - { - throw new ArgumentException($"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping; the Agent Skills specification requires a map from string keys to string values.", paramName); - } - - foreach (var entry in metadata) - { - if (entry.Value is not JsonValue value || !value.TryGetValue(out string? _)) - { - throw new ArgumentException( - $"Skill '{skill.Uri}' has a 'metadata.{entry.Key}' frontmatter value that is not a string; the Agent Skills specification requires string values. Quote it in SKILL.md if it is meant literally.", - paramName); - } - } + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it key-value entries, or remove the key if it is not needed.", + paramName); } if (skill.Resources is null) diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs index 9bac1e82a..0f3501248 100644 --- a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -123,6 +123,18 @@ public void Verify_Skill_RejectsEmptyResult() 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() { diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs index 8aa469a63..9d45e9029 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -187,6 +187,19 @@ public void Constructor_AcceptsFullAgentSkillsFrontmatter() Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); } + [Fact] + public void Constructor_PassesThroughFieldsHostsDoNotActOn() + { + // allowed-tools is experimental and hosts ignore it for MCP-origin skills; license is free-form; metadata + // values are whatever the author wrote. None of these should cost a skill its listing. + var skill = CreateSkill("alpha"); + skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + skill.Frontmatter["license"] = null; + skill.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + [Fact] public void Constructor_AcceptsEmptyAuthority() { @@ -234,7 +247,6 @@ static object[] Case(string reason, Action mutate) }); 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("license present but null", s => s.Frontmatter["license"] = null); yield return Case("relative uri", s => { s.Uri = "alpha/SKILL.md"; @@ -268,10 +280,7 @@ static object[] Case(string reason, Action mutate) 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("license not a string", s => s.Frontmatter["license"] = 1); - yield return Case("allowed-tools not a string", s => s.Frontmatter["allowed-tools"] = new JsonArray("Bash")); yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x"); - yield return Case("metadata value not a string", s => s.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1 }); 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");