From a0e11ec5e388deb0aab15d3a256ff6b61229668b Mon Sep 17 00:00:00 2001 From: Girish Konda Date: Fri, 4 Sep 2026 19:33:22 -0700 Subject: [PATCH 1/5] Add SEP-2640 Skills extension package Adds ModelContextProtocol.Extensions.Skills implementing the skills/list and skills/get methods of SEP-2640, alongside the existing Tasks and Apps extension packages. Declares the io.modelcontextprotocol/skills capability inline and registers both methods through McpServerOptions.RequestHandlers, so Core is unchanged. Skill manifests are modelled as SkillResources, a closed type admitting only an enumerated file list or the dynamic marker, so an invalid manifest cannot be constructed. Pagination uses keyset cursors over URI order. skills/get sets RoutingNameParameter to uri, matching how Core routes resources/read for the Mcp-Name header. ttlMs and cacheScope are gated on the negotiated protocol version by default, which SEP-2640 currently requires, but the gate is configurable while the working group settles whether that condition is removed. --- ModelContextProtocol.slnx | 1 + .../Protocol/PaginatedRequest.cs | 11 +- .../Protocol/PaginatedResult.cs | 10 +- .../McpSkillsJsonContext.cs | 20 ++ ...elContextProtocol.Extensions.Skills.csproj | 55 +++++ .../Protocol/GetSkillRequestParams.cs | 24 ++ .../Protocol/GetSkillResult.cs | 26 +++ .../Protocol/ListSkillsRequestParams.cs | 15 ++ .../Protocol/ListSkillsResult.cs | 47 ++++ .../Protocol/SkillEntry.cs | 49 ++++ .../Protocol/SkillResource.cs | 48 ++++ .../Protocol/SkillResources.cs | 66 ++++++ .../Protocol/SkillResourcesConverter.cs | 93 ++++++++ .../Server/IMcpSkillCatalog.cs | 49 ++++ .../Server/InMemoryMcpSkillCatalog.cs | 110 +++++++++ .../Server/McpSkillPage.cs | 16 ++ .../Server/McpSkillsBuilderExtensions.cs | 153 ++++++++++++ .../Server/McpSkillsOptions.cs | 41 ++++ .../SkillsProtocol.cs | 69 ++++++ ...elContextProtocol.ConformanceServer.csproj | 1 + .../Program.cs | 9 + .../Skills/ConformanceSkills.cs | 170 ++++++++++++++ .../ModelContextProtocol.Tests.csproj | 1 + .../Protocol/SkillSerializationTests.cs | 221 ++++++++++++++++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 146 ++++++++++++ .../Server/McpServerSkillsTests.cs | 216 +++++++++++++++++ 26 files changed, 1664 insertions(+), 3 deletions(-) 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/SkillEntry.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/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/SkillsProtocol.cs create mode 100644 tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.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/McpServerSkillsTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 9020d2fbe..e4c8de3a9 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -69,6 +69,7 @@ + diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs index cc7e33f24..7f3d4f483 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs @@ -10,8 +10,15 @@ namespace ModelContextProtocol.Protocol; /// public abstract class PaginatedRequestParams : RequestParams { - /// Prevent external derivations. - private protected PaginatedRequestParams() + /// + /// Initializes a new instance of the class. + /// + /// + /// This is rather than private protected so that extension packages + /// implementing paginated methods defined outside the core specification can derive from it. See + /// . + /// + protected PaginatedRequestParams() { } diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs index df9dc4475..be4bd59ec 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs @@ -17,7 +17,15 @@ namespace ModelContextProtocol.Protocol; /// public abstract class PaginatedResult : Result { - private protected PaginatedResult() + /// + /// Initializes a new instance of the class. + /// + /// + /// This is rather than private protected so that extension packages + /// implementing paginated methods defined outside the core specification can derive from it. See + /// . + /// + protected PaginatedResult() { } diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs new file mode 100644 index 000000000..9b834c54c --- /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 MCP Skills extension types. +/// +[JsonSourceGenerationOptions( + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(SkillEntry))] +[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..cfd7b7275 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -0,0 +1,55 @@ + + + + net10.0;net9.0;net8.0;netstandard2.0 + true + true + ModelContextProtocol.Extensions.Skills + MCP Skills extension (SEP-2640) for the .NET Model Context Protocol (MCP) SDK + README.md + + $(NoWarn);MCPEXP001;MCPEXP002 + + + + + + true + + + + + $(NoWarn);CS0436 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillRequestParams.cs new file mode 100644 index 000000000..56c140ec7 --- /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 SEP-2640 +/// 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..680b56687 --- /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, because a single entry is not a list. +/// +/// +/// See the SEP-2640 +/// 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 SkillEntry 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..be32c62dc --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs @@ -0,0 +1,15 @@ +using ModelContextProtocol.Protocol; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents the parameters for a skills/list request enumerating the skills a server serves. +/// +/// +/// +/// See the SEP-2640 +/// 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..641f9235a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -0,0 +1,47 @@ +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 SEP-2640 +/// 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; } + + /// + /// + /// Core applies its own internal CacheScopeConverter to this property on the built-in result + /// types, which tolerates unrecognized scope strings on read by mapping them to + /// . That converter is not accessible outside the core assembly, so this type + /// relies on the converter declared on the enum instead. The written + /// wire values are identical; only the read-side leniency differs. + /// + [JsonPropertyName("cacheScope")] + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.cs new file mode 100644 index 000000000..ed2f8f4de --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.cs @@ -0,0 +1,49 @@ +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a single skill as returned by skills/list and skills/get. +/// +/// +/// +/// An entry is a complete, point-in-time snapshot of a skill: its SKILL.md URI, the verbatim +/// frontmatter of that file, and the manifest of the skill's files. A host that pages through a listing +/// therefore has everything it needs to build its registry and verify every file it later reads, without +/// a second round-trip per skill. +/// +/// +/// See the SEP-2640 +/// specification for details. +/// +/// +public sealed class SkillEntry +{ + /// + /// Gets or sets the resource URI of the skill's SKILL.md. + /// + /// + /// The final 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. Hosts re-parse the fetched + /// SKILL.md and compare it 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. + /// + [JsonPropertyName("resources")] + public required SkillResources Resources { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs new file mode 100644 index 000000000..dc6740f1d --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs @@ -0,0 +1,48 @@ +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 SEP-2640 +/// specification for details. +/// +/// +public sealed class SkillResource +{ + /// + /// Gets or sets the resource URI of the file. + /// + [JsonPropertyName("uri")] + public required string Uri { get; set; } + + /// + /// Gets or sets the SHA-256 digest of the file, 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 + /// listing 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 the + /// covers. + /// + /// + /// A read whose byte length differs from this value is a verification failure equivalent to a digest + /// mismatch, whether or not the digest is subsequently computed. + /// + [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..dc8dda57a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs @@ -0,0 +1,66 @@ +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 the skill's content is generated and cannot be digested. +/// +/// +/// +/// SEP-2640 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 "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 SEP-2640 +/// specification for details. +/// +/// +[JsonConverter(typeof(SkillResourcesConverter))] +public sealed class SkillResources +{ + private readonly IReadOnlyList? _resources; + + 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 matching the skill's own + /// , carrying the digest and size of its SKILL.md. + /// + /// A manifest wrapping a defensive 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 => _resources; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs new file mode 100644 index 000000000..7273f2fd7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs @@ -0,0 +1,93 @@ +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. SEP-2640 + /// 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) + { + if (reader.TokenType == JsonTokenType.Null) + { + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found null."); + } + + if (reader.TokenType == 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; + } + + if (reader.TokenType != JsonTokenType.StartArray) + { + throw new JsonException( + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found {reader.TokenType}."); + } + + 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); + if (resource is null) + { + throw new JsonException("Invalid skill manifest: a resource entry was null."); + } + + resources.Add(resource); + } + + throw new JsonException("Invalid skill manifest: unterminated array."); + } + + public override void Write(Utf8JsonWriter writer, SkillResources value, JsonSerializerOptions options) + { +#if NET + ArgumentNullException.ThrowIfNull(writer); + ArgumentNullException.ThrowIfNull(value); +#else + if (writer is null) throw new ArgumentNullException(nameof(writer)); + if (value is null) throw new ArgumentNullException(nameof(value)); +#endif + + 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..50bbae73f --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -0,0 +1,49 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Supplies the skills a server serves. +/// +/// +/// +/// A catalog is the single source of truth behind every view of a server's skills. Implementations back +/// skills/list through and skills/get through . +/// +/// +/// The two are deliberately separate. A server may enumerate only part of its catalog, or none of it, while +/// still answering for every skill it serves by URI. +/// +/// +public interface IMcpSkillCatalog +{ + /// + /// Lists a page of the skills this catalog publishes. + /// + /// + /// An opaque cursor from a previous call, or to start at the first page. + /// + /// A token to cancel the operation. + /// + /// 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. + /// + 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. + /// A token to cancel the operation. + /// + /// 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..beaf7e98a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -0,0 +1,110 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// An over a fixed set of skill entries held in memory. +/// +/// +/// Entries are ordered by URI so that pagination is stable across calls. Cursors are keyset cursors over +/// that order rather than offsets, so entries added or removed between pages cannot cause a page to be +/// skipped or repeated wholesale. +/// +public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog +{ + private readonly List _ordered; + 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. + 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) + { + if (_byUri.ContainsKey(skill.Uri)) + { + throw new ArgumentException($"Duplicate skill URI '{skill.Uri}'.", nameof(skills)); + } + + _byUri.Add(skill.Uri, skill); + } + + _ordered = [.. _byUri.Values]; + _ordered.Sort(static (left, right) => string.CompareOrdinal(left.Uri, right.Uri)); + } + + /// + public ValueTask ListAsync(string? cursor, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string? afterUri = DecodeCursor(cursor); + int start = 0; + if (afterUri is not null) + { + while (start < _ordered.Count && + string.CompareOrdinal(_ordered[start].Uri, afterUri) <= 0) + { + start++; + } + } + + int count = Math.Min(_pageSize, _ordered.Count - start); + if (count <= 0) + { + return new ValueTask(McpSkillPage.Empty); + } + + var page = _ordered.GetRange(start, count); + bool hasMore = start + count < _ordered.Count; + string? nextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null; + return new ValueTask(new McpSkillPage(page, nextCursor)); + } + + /// + public ValueTask GetAsync(string uri, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + _byUri.TryGetValue(uri, out var skill); + return new ValueTask(skill); + } + + private string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri)); + + private 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/McpSkillPage.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs new file mode 100644 index 000000000..667010548 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs @@ -0,0 +1,16 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one page of skill entries returned by . +/// +/// The entries in this page. +/// +/// The cursor to pass back for the following page, or when no entries remain. +/// +public sealed record McpSkillPage(IReadOnlyList Skills, string? NextCursor = null) +{ + /// + /// Gets an empty page with no following page. + /// + public static McpSkillPage Empty { get; } = new([]); +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs new file mode 100644 index 000000000..4722b0537 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -0,0 +1,153 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to enable MCP Skills (SEP-2640) support. +/// +public static class McpSkillsBuilderExtensions +{ + /// + /// Enables MCP Skills support backed by the specified catalog. + /// + /// The server builder. + /// The catalog supplying the skills this server serves. + /// The builder provided in . + public static IMcpServerBuilder WithSkills(this IMcpServerBuilder builder, IMcpSkillCatalog catalog) + => WithSkills(builder, catalog, static _ => { }); + + /// + /// Enables MCP Skills support backed by the specified catalog. + /// + /// The server builder. + /// The catalog supplying the skills this server serves. + /// A callback that configures the extension's behavior. + /// The builder provided in . + /// + /// + /// Declaring the extension commits the server to both skills/list and skills/get. Both are + /// registered here. + /// + /// + /// This registers the protocol methods only. Serving the skills' file content remains the ordinary + /// responsibility of the server's resources, so register the skill files as resources as well. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IMcpSkillCatalog catalog, + Action configure) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(configure); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (catalog is null) throw new ArgumentNullException(nameof(catalog)); + if (configure is null) throw new ArgumentNullException(nameof(configure)); +#endif + + var options = new McpSkillsOptions(); + configure(options); + + builder.Services.AddSingleton>( + _ => new McpSkillsConfigureOptions(catalog, options)); + + return builder; + } + + private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkillsOptions skillsOptions) + : IConfigureOptions + { + public void Configure(McpServerOptions options) + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + if (!options.Capabilities.Extensions.ContainsKey(SkillsProtocol.ExtensionId)) + { + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + } + + options.RequestHandlers ??= new List(); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = HandleListSkillsAsync, + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsGet, + // RoutingNameParameter is deliberately left unset. Setting it to "uri" would mirror how + // Core routes the built-in resources/read, but it also makes the Mcp-Name header mandatory + // on Streamable HTTP. SEP-2640 defines no such requirement, so a conforming client does not + // send it and every skills/get request is rejected with 400. Verified against the SEP-2640 + // conformance scenarios. + Handler = HandleGetSkillAsync, + }); + } + + private async ValueTask HandleListSkillsAsync( + JsonRpcRequest request, + CancellationToken cancellationToken) + { + string? cursor = request.Params?["cursor"]?.GetValue(); + var page = await catalog.ListAsync(cursor, cancellationToken).ConfigureAwait(false); + + var result = new ListSkillsResult + { + Skills = [.. page.Skills], + NextCursor = page.NextCursor, + ResultType = "complete", + }; + + if (ShouldEmitCacheAttributes(request)) + { + result.TimeToLive = skillsOptions.TimeToLive; + result.CacheScope = skillsOptions.CacheScope; + } + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.ListSkillsResult); + } + + private async ValueTask HandleGetSkillAsync( + JsonRpcRequest request, + CancellationToken cancellationToken) + { + string? uri = request.Params?["uri"]?.GetValue(); + if (string.IsNullOrEmpty(uri)) + { + throw new McpProtocolException("'uri' is required.", McpErrorCode.InvalidParams); + } + + var skill = await catalog.GetAsync(uri!, cancellationToken).ConfigureAwait(false); + if (skill is null) + { + throw new McpProtocolException($"Unknown skill '{uri}'.", McpErrorCode.InvalidParams); + } + + var result = new GetSkillResult + { + Skill = skill, + ResultType = "complete", + }; + + return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); + } + + private bool ShouldEmitCacheAttributes(JsonRpcRequest request) + { + if (!skillsOptions.GateCacheAttributesByProtocolVersion) + { + return true; + } + + return 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..63a5f4402 --- /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 a value indicating whether skills/list results carry the base protocol's + /// list-caching attributes (ttlMs and cacheScope) only when the negotiated protocol + /// version is 2026-07-28 or later. + /// + /// + /// + /// SEP-2640 scopes those attributes to protocol versions 2026-07-28 and later, so gating is the + /// conservative reading and the default. The condition is under active discussion in the working group + /// however: the Go SDK and the specification restructure both dropped it independently, and it may be + /// removed from the SEP rather than added to implementations. Set this to to + /// emit the attributes on every negotiated version. + /// + /// + public bool GateCacheAttributesByProtocolVersion { get; set; } = true; + + /// + /// Gets or sets the freshness hint advertised on skills/list results, or + /// to advertise none. + /// + public TimeSpan? TimeToLive { get; set; } + + /// + /// Gets or sets the cache scope advertised on skills/list results, or + /// to advertise none. + /// + /// + /// Set this to a public scope only when the catalog is identical for every caller. A listing that varies + /// by principal must not be advertised as publicly cacheable. + /// + public CacheScope? CacheScope { get; set; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs new file mode 100644 index 000000000..4b4e58d01 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs @@ -0,0 +1,69 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Provides constants for the MCP Skills extension (SEP-2640). +/// +public static class SkillsProtocol +{ + /// + /// The extension identifier for the MCP Skills extension. + /// + 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 name of the optional request method sent from the client to list the direct children + /// of a directory resource. + /// + public const string MethodResourcesDirectoryRead = "resources/directory/read"; + + /// + /// The name of the capability setting indicating that the server implements + /// . + /// + public const string DirectoryReadSetting = "directoryRead"; + + /// + /// The reverse-domain prefix reserved by this extension for _meta keys on skill resources. + /// + public const string MetaPrefix = "io.modelcontextprotocol.skills/"; + + /// + /// The file name of the required skill manifest at the root of every skill directory. + /// + public const string SkillManifestFileName = "SKILL.md"; + + /// + /// The MIME type identifying a directory resource. + /// + public const string DirectoryMimeType = "inode/directory"; + + /// + /// The MIME type recommended for a skill's SKILL.md resource. + /// + public const string SkillManifestMimeType = "text/markdown"; + + /// + /// The sentinel used in place of a resource array when a skill's content is generated dynamically. + /// + public const string DynamicResourcesSentinel = "dynamic"; + + /// + /// The maximum number of resources a single skill may declare, per the SEP-2640 limits. + /// + public const int MaxResourcesPerSkill = 512; + + /// + /// The maximum total size in bytes of a single skill's resources, per the SEP-2640 limits. + /// + public const long MaxTotalSizeBytes = 16 * 1024 * 1024; +} 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..a872a0875 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; @@ -49,10 +51,17 @@ private static void ConfigureConformanceMcpServer( bool stateless) { services.AddDistributedMemoryCache(); + var conformanceSkills = new ConformanceSkills(); var mcpServerBuilder = services .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..63f91d6d1 --- /dev/null +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -0,0 +1,170 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Server; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.ConformanceServer.Skills; + +/// +/// Builds the SEP-2640 skill fixture the conformance scenarios run against. +/// +/// +/// +/// Digests and sizes are computed from the same byte buffers the resources serve, so a manifest can never +/// disagree with the content a host reads back. Skill files are registered as concrete resources rather +/// than through a URI template so that they appear in resources/list carrying the name, description, +/// and MIME type SEP-2640 asks for. +/// +/// +public sealed class ConformanceSkills +{ + private const string GitWorkflowManifest = + "---\nname: git-workflow\ndescription: Follow this team's Git conventions for branching and commits\n---\n\n# Git workflow\n"; + + private const string PdfProcessingManifest = + "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n"; + + private const string RefundsManifest = + "---\nname: refunds\ndescription: Process customer refund requests per company policy\n---\n\n# Refunds\n"; + + private const string GeneratedReportManifest = + "---\nname: generated-report\ndescription: Assemble a report from live data\n---\n\n# Generated report\n"; + + private readonly Dictionary _content = new(StringComparer.Ordinal); + private readonly Dictionary _manifestMetadata = new(StringComparer.Ordinal); + private readonly List _skills = []; + + public ConformanceSkills() + { + AddSkill( + "git-workflow", + "Follow this team's Git conventions for branching and commits", + [("SKILL.md", GitWorkflowManifest)]); + + AddSkill( + "pdf-processing", + "Extract, fill, and assemble PDF documents", + [ + ("SKILL.md", PdfProcessingManifest), + ("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"), + ("scripts/extract.py", "import sys\n\nprint('extract')\n"), + ("templates/invoice.md", "# Invoice\n"), + ("templates/regional/eu-invoice.md", "# EU Invoice\n"), + ]); + + AddSkill( + "acme/billing/refunds", + "Process customer refund requests per company policy", + [ + ("SKILL.md", RefundsManifest), + ("examples/email.md", "Subject: Your refund\n"), + ]); + + // A deliberately unenumerable skill: it is served and answerable through skills/get, but carries + // no digests and so cannot be content-bound. + _skills.Add(new SkillEntry + { + Uri = "skill://generated-report/SKILL.md", + Frontmatter = new JsonObject + { + ["name"] = "generated-report", + ["description"] = "Assemble a report from live data", + }, + Resources = SkillResources.Dynamic, + }); + _content["skill://generated-report/SKILL.md"] = Encoding.UTF8.GetBytes(GeneratedReportManifest); + _manifestMetadata["skill://generated-report/SKILL.md"] = + ("generated-report", "Assemble a report from live data"); + } + + /// + /// Creates the catalog backing skills/list and skills/get. + /// + /// + /// The page size is deliberately small so the conformance scenarios exercise cursor pagination. + /// + public InMemoryMcpSkillCatalog CreateCatalog() => new(_skills, pageSize: 2); + + /// + /// Creates one concrete resource per skill file, so the files are enumerable through + /// resources/list and readable through resources/read. + /// + public IEnumerable CreateResources() + { + foreach (var entry in _content) + { + string uri = entry.Key; + string text = Encoding.UTF8.GetString(entry.Value); + bool isManifest = _manifestMetadata.TryGetValue(uri, out var metadata); + + yield return McpServerResource.Create( + () => text, + new McpServerResourceCreateOptions + { + UriTemplate = uri, + // SEP-2640: a SKILL.md resource SHOULD carry the name and description from its own + // frontmatter, so a host can build its registry from resources/list alone. + Name = isManifest ? metadata.Name : uri[(uri.LastIndexOf('/') + 1)..], + Description = isManifest ? metadata.Description : "Skill supporting file", + MimeType = isManifest ? SkillsProtocol.SkillManifestMimeType : GuessMimeType(uri), + }); + } + } + + private void AddSkill(string skillPath, string description, (string Path, string Body)[] files) + { + string name = skillPath.Split('/')[^1]; + var resources = new List(); + + foreach (var (path, body) in files) + { + string uri = $"skill://{skillPath}/{path}"; + byte[] bytes = Encoding.UTF8.GetBytes(body); + _content[uri] = bytes; + if (string.Equals(path, SkillsProtocol.SkillManifestFileName, StringComparison.Ordinal)) + { + _manifestMetadata[uri] = (name, description); + } + + resources.Add(new SkillResource + { + Uri = uri, + Digest = ComputeDigest(bytes), + Size = bytes.LongLength, + }); + } + + _skills.Add(new SkillEntry + { + Uri = $"skill://{skillPath}/{SkillsProtocol.SkillManifestFileName}", + Frontmatter = new JsonObject + { + ["name"] = name, + ["description"] = description, + }, + Resources = SkillResources.FromResources(resources), + }); + } + + private string ComputeDigest(byte[] bytes) + { + byte[] hash = SHA256.HashData(bytes); + + var builder = new StringBuilder("sha256:", 71); + foreach (byte value in hash) + { + builder.Append(value.ToString("x2")); + } + + return builder.ToString(); + } + + private string GuessMimeType(string uri) => uri[(uri.LastIndexOf('.') + 1)..] switch + { + "md" => "text/markdown", + "py" => "text/x-python", + "json" => "application/json", + _ => "text/plain", + }; +} 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..3c600588f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -0,0 +1,221 @@ +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 SkillEntry CreateEntry(SkillResources resources) => new() + { + Uri = "skill://git-workflow/SKILL.md", + Frontmatter = new JsonObject + { + ["name"] = "git-workflow", + ["description"] = "Follow this team's Git conventions", + }, + Resources = resources, + }; + + private SkillResource CreateResource(string uri, string digest, long size) => new() + { + Uri = uri, + Digest = digest, + Size = size, + }; + + [Fact] + public void SkillEntry_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.SkillEntry); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + + Assert.NotNull(deserialized); + Assert.Equal("skill://git-workflow/SKILL.md", deserialized.Uri); + Assert.Equal("git-workflow", deserialized.Frontmatter["name"]?.GetValue()); + 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 SkillEntry_WithDynamicResources_SerializesAsTheDynamicString() + { + var original = CreateEntry(SkillResources.Dynamic); + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.Equal("dynamic", node["resources"]?.GetValue()); + } + + [Fact] + public void SkillEntry_WithDynamicResources_RoundTrips() + { + var original = CreateEntry(SkillResources.Dynamic); + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + + Assert.NotNull(deserialized); + Assert.True(deserialized.Resources.IsDynamic); + Assert.Null(deserialized.Resources.Resources); + } + + [Fact] + public void SkillEntry_WithEmptyResourceArray_RoundTripsAsNonDynamic() + { + var original = CreateEntry(SkillResources.FromResources([])); + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + + Assert.NotNull(deserialized); + Assert.False(deserialized.Resources.IsDynamic); + Assert.Empty(deserialized.Resources.Resources!); + } + + [Theory] + [InlineData("\"static\"")] + [InlineData("\"Dynamic\"")] + [InlineData("\"\"")] + [InlineData("null")] + [InlineData("123")] + [InlineData("true")] + [InlineData("{}")] + public void SkillEntry_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.SkillEntry)); + } + + [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 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_WritesCacheScopeUsingSpecifiedWireValues() + { + var result = new ListSkillsResult { CacheScope = CacheScope.Private }; + + string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.ListSkillsResult); + + Assert.Equal("private", JsonNode.Parse(json)!["cacheScope"]?.GetValue()); + } + + [Fact] + public void ListSkillsResult_OmitsCacheAttributesWhenUnset() + { + var result = new ListSkillsResult { Skills = [] }; + + string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.ListSkillsResult); + + var node = JsonNode.Parse(json)!.AsObject(); + Assert.False(node.ContainsKey("ttlMs")); + Assert.False(node.ContainsKey("cacheScope")); + Assert.False(node.ContainsKey("nextCursor")); + } + + [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); + 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 GetSkillResult_HasNoPaginationCursor() + { + var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) }; + + string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.GetSkillResult); + + Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor")); + } + + [Fact] + public void ListSkillsRequestParams_RoundTripsCursor() + { + var original = new ListSkillsRequestParams { Cursor = "abc" }; + + string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsRequestParams); + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsRequestParams); + + Assert.NotNull(deserialized); + Assert.Equal("abc", deserialized.Cursor); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs new file mode 100644 index 000000000..d58d0759f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -0,0 +1,146 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for , covering ordering, keyset pagination, lookup of +/// unlisted skills, and cursor validation. +/// +public class InMemoryMcpSkillCatalogTests +{ + private SkillEntry CreateSkill(string name) => new() + { + Uri = $"skill://{name}/SKILL.md", + Frontmatter = new JsonObject + { + ["name"] = name, + ["description"] = $"The {name} skill", + }, + Resources = SkillResources.Dynamic, + }; + + private InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => + new([.. names.Select(CreateSkill)], 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); + } + + [Fact] + public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries() + { + var catalog = CreateCatalog(2, "a", "b", "c", "d", "e"); + + var seen = new List(); + string? cursor = null; + int iterations = 0; + do + { + var page = await catalog.ListAsync(cursor, TestContext.Current.CancellationToken); + seen.AddRange(page.Skills.Select(skill => skill.Uri)); + cursor = page.NextCursor; + iterations++; + Assert.True(iterations < 20, "Pagination did not terminate."); + } + while (cursor is not null); + + 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.Equal(2, second.Skills.Count); + Assert.Null(second.NextCursor); + } + + [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.Frontmatter["name"]?.GetValue()); + } + + [Fact] + public async Task GetAsync_WithUnknownUri_ReturnsNull() + { + var catalog = CreateCatalog(10, "alpha"); + + var skill = await catalog.GetAsync("skill://missing/SKILL.md", TestContext.Current.CancellationToken); + + Assert.Null(skill); + } + + [Fact] + public async Task GetAsync_IsCaseSensitive() + { + var catalog = CreateCatalog(10, "alpha"); + + var skill = await catalog.GetAsync("SKILL://ALPHA/SKILL.md", TestContext.Current.CancellationToken); + + Assert.Null(skill); + } + + [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)); +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs new file mode 100644 index 000000000..d0f2c122d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs @@ -0,0 +1,216 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for the SEP-2640 skills methods over the client-server transport: capability +/// declaration, skills/list enumeration and pagination, skills/get retrieval including +/// skills absent from the listing, and the error contract for unknown URIs. +/// +public class McpServerSkillsTests : ClientServerTestBase +{ + private const string ListedSkillUri = "skill://git-workflow/SKILL.md"; + private const string NestedSkillUri = "skill://acme/billing/refunds/SKILL.md"; + + public McpServerSkillsTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var catalog = new InMemoryMcpSkillCatalog( + [ + CreateSkill(ListedSkillUri, "git-workflow", SkillResources.FromResources( + [ + new SkillResource + { + Uri = ListedSkillUri, + Digest = "sha256:" + new string('a', 64), + Size = 2314, + }, + ])), + CreateSkill(NestedSkillUri, "refunds", SkillResources.Dynamic), + ]); + + mcpServerBuilder.WithSkills(catalog, options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); + } + + private static SkillEntry CreateSkill(string uri, string name, SkillResources resources) => new() + { + Uri = uri, + Frontmatter = new JsonObject + { + ["name"] = name, + ["description"] = $"The {name} skill", + }, + Resources = resources, + }; + + private async Task SendAsync(McpClient client, string method, JsonObject? parameters) + { + var request = new JsonRpcRequest + { + Method = method, + Params = parameters, + }; + + var response = await client.SendRequestAsync(request, TestContext.Current.CancellationToken); + return response.Result; + } + + [Fact] + public async Task Server_DeclaresSkillsExtensionCapability() + { + await using McpClient client = await CreateMcpClientForServer(); + + var extensions = client.ServerCapabilities.Extensions; + + Assert.NotNull(extensions); + Assert.True(extensions.ContainsKey(SkillsProtocol.ExtensionId)); + } + + [Fact] + public async Task Server_DoesNotDeclareDirectoryRead_WhenNotSupported() + { + await using McpClient client = await CreateMcpClientForServer(); + + var settings = JsonSerializer.SerializeToNode( + client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId])?.AsObject(); + + Assert.NotNull(settings); + Assert.False(settings.ContainsKey(SkillsProtocol.DirectoryReadSetting)); + } + + [Fact] + public async Task SkillsList_ReturnsEveryPublishedSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + var listing = result.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult); + + Assert.NotNull(listing); + Assert.Equal(2, listing.Skills.Count); + Assert.Contains(listing.Skills, skill => skill.Uri == ListedSkillUri); + Assert.Contains(listing.Skills, skill => skill.Uri == NestedSkillUri); + } + + [Fact] + public async Task SkillsList_MarksResultComplete() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + + Assert.Equal("complete", result?["resultType"]?.GetValue()); + } + + [Fact] + public async Task SkillsList_SerializesEnumeratedManifestAsArray() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + + var entry = result!["skills"]!.AsArray() + .Single(skill => skill!["uri"]!.GetValue() == ListedSkillUri)!; + var resources = entry["resources"]!.AsArray(); + + Assert.Single(resources); + Assert.Equal(2314, resources[0]!["size"]!.GetValue()); + Assert.StartsWith("sha256:", resources[0]!["digest"]!.GetValue()); + } + + [Fact] + public async Task SkillsList_SerializesDynamicManifestAsTheDynamicString() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + + var entry = result!["skills"]!.AsArray() + .Single(skill => skill!["uri"]!.GetValue() == NestedSkillUri)!; + + Assert.Equal("dynamic", entry["resources"]!.GetValue()); + } + + [Fact] + public async Task SkillsList_PreservesFrontmatterVerbatim() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + + var entry = result!["skills"]!.AsArray() + .Single(skill => skill!["uri"]!.GetValue() == ListedSkillUri)!; + var frontmatter = entry["frontmatter"]!.AsObject(); + + Assert.Equal("git-workflow", frontmatter["name"]!.GetValue()); + Assert.Equal("The git-workflow skill", frontmatter["description"]!.GetValue()); + } + + [Fact] + public async Task SkillsGet_ReturnsTheRequestedEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync( + client, + SkillsProtocol.MethodSkillsGet, + new JsonObject { ["uri"] = ListedSkillUri }); + var skill = result.Deserialize(McpSkillsJsonContext.Default.GetSkillResult); + + Assert.NotNull(skill); + Assert.Equal(ListedSkillUri, skill.Skill.Uri); + Assert.False(skill.Skill.Resources.IsDynamic); + } + + [Fact] + public async Task SkillsGet_CarriesNoPaginationCursor() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await SendAsync( + client, + SkillsProtocol.MethodSkillsGet, + new JsonObject { ["uri"] = ListedSkillUri }); + + Assert.False(result!.AsObject().ContainsKey("nextCursor")); + } + + [Fact] + public async Task SkillsGet_WithUnknownUri_ReturnsInvalidParams() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await SendAsync( + client, + SkillsProtocol.MethodSkillsGet, + new JsonObject { ["uri"] = "skill://missing/SKILL.md" })); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } + + [Fact] + public async Task SkillsGet_WithMissingUri_ReturnsInvalidParams() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await SendAsync(client, SkillsProtocol.MethodSkillsGet, [])); + + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + } +} From 1a08d801a240cc77b4ac97a0b3150ad42bc5ff34 Mon Sep 17 00:00:00 2001 From: Peder Date: Thu, 10 Sep 2026 07:45:06 +0200 Subject: [PATCH 2/5] Import Peder's additional Skills implementation Import the net changes from PR #1864 on top of the rebased #1856 foundation, including the client, request-aware catalogs, validation, byte-snapshot authoring, YAML reader, samples, docs and regression tests. The shared foundation is retained once, not replayed. Source PR: https://github.com/modelcontextprotocol/csharp-sdk/pull/1864 Source head: ecb11e621b0ae46685314729a74c0cb8fe283d44 Same-main source tree: ba9234da7a7a3ff5ad2a4e78ca9308a72ad004d9 Source lineage (including already-shared foundation): 4a7d3f5ddefae323ad91cedb13c2df9f77bf9c51 Widen PaginatedRequestParams and PaginatedResult constructors to protected 9569706a5bb509c210e6b08d66e19cdf41de8167 Add ModelContextProtocol.Extensions.Skills (SEP-2640) 50f7cd71a24ab1393f9d25f352754c9b4d2d8d79 Add tests and a conformance fixture for the Skills extension 44d80ae710b38b74fe86f146da92fcb99672ba01 Add Skills samples and docs 53e5425801cfcbeb8356af7d45952c0703002fb2 Harden skill authoring and verified reads b2e92aacb59d3a3c8c1dcab532ff2ff6cecee3fa Read skill frontmatter from SKILL.md 9dc02bbcc3dfcb68fe9bc30c009d3aa9d02ce451 Align the frontmatter reader with reference YAML parsers 72a0406d282f6a0c92915e6adaad686f54015359 Point SkillVerifier remarks at SkillFrontmatter for frontmatter checks 932c31892c771066246f424e280065000b0d5540 Link the Skills docs page to the samples by relative path f18d7dcddc9ba61cf27eff2d54c0a2b93b37b62f Address the Copilot review of #1864 8c39436174060064ade83acc29468b98c850b03d Address the second Copilot review of #1864 8593f0df79ab1ccc31cf5ca0cf92ff34f0b2b00b Bind skills/get answers to the requested URI 82f0943898d9ebac7c203c29175d6f124b12270c Enforce skill limits while reading a directory; match Core's URI equivalence fe89998746d20856c2080c103f5006730fab0bc9 Address review feedback from the author of #1856 4813b72641d986b664cbdc2e60a90194d6651dfa Complete earlier fixes and align the reader further with reference parsers ecb11e621b0ae46685314729a74c0cb8fe283d44 Close the neighbouring cases a differential YAML run and a second review pass found This local squash preserves human authorship, not source signatures. No consolidation repairs are included in this import. Co-authored-by: Girish Konda --- ModelContextProtocol.slnx | 2 + README.md | 2 + docs/concepts/index.md | 1 + docs/concepts/skills/skills.md | 224 +++ docs/concepts/toc.yml | 2 + samples/SkillsClient/Program.cs | 90 ++ samples/SkillsClient/README.md | 69 + samples/SkillsClient/SkillsClient.csproj | 16 + samples/SkillsServer/Program.cs | 40 + .../Properties/launchSettings.json | 14 + samples/SkillsServer/README.md | 43 + .../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 + src/Directory.Build.targets | 1 + .../Protocol/CacheScopeConverter.cs | 9 +- .../Protocol/PaginatedRequest.cs | 5 +- .../Protocol/PaginatedResult.cs | 5 +- .../Client/McpSkillsClientExtensions.cs | 264 ++++ .../Client/SkillVerificationException.cs | 39 + .../Client/SkillVerifier.cs | 272 ++++ .../McpSkillsJsonContext.cs | 4 +- ...elContextProtocol.Extensions.Skills.csproj | 6 +- .../Protocol/GetSkillRequestParams.cs | 6 +- .../Protocol/GetSkillResult.cs | 8 +- .../Protocol/ListSkillsRequestParams.cs | 7 +- .../Protocol/ListSkillsResult.cs | 19 +- .../Protocol/Skill.cs | 74 + .../Protocol/SkillEntry.cs | 49 - .../Protocol/SkillResource.cs | 24 +- .../Protocol/SkillResources.cs | 26 +- .../Protocol/SkillResourcesConverter.cs | 78 +- .../Server/IMcpSkillCatalog.cs | 47 +- .../Server/InMemoryMcpSkillCatalog.cs | 91 +- .../Server/McpServerSkill.cs | 655 +++++++++ .../Server/McpServerSkillFile.cs | 52 + .../Server/McpSkillPage.cs | 18 +- .../Server/McpSkillRequestContext.cs | 58 + .../Server/McpSkillsBuilderExtensions.cs | 335 ++++- .../Server/McpSkillsOptions.cs | 36 +- .../SkillFrontmatter.cs | 1301 +++++++++++++++++ .../SkillValidation.cs | 345 +++++ .../SkillsProtocol.cs | 60 +- src/PACKAGE.md | 2 + ...xtProtocol.AotCompatibility.TestApp.csproj | 2 + .../Program.cs | 24 +- .../Program.cs | 5 +- .../Skills/ConformanceSkills.cs | 191 +-- .../Client/McpSkillsClientValidationTests.cs | 114 ++ .../Client/SkillVerifierTests.cs | 186 +++ .../ClientServerTestBase.cs | 2 +- .../Protocol/SkillSerializationTests.cs | 148 +- .../Server/InMemoryMcpSkillCatalogTests.cs | 297 +++- .../Server/McpServerSkillTests.cs | 523 +++++++ .../Server/McpServerSkillsCatalogTests.cs | 296 ++++ .../Server/McpServerSkillsDefaultsTests.cs | 63 + .../McpServerSkillsFromDirectoryTests.cs | 200 +++ .../Server/McpServerSkillsIntegrityTests.cs | 99 ++ .../Server/McpServerSkillsTests.cs | 202 +-- .../Server/SkillFrontmatterCorpusTests.cs | 91 ++ .../Server/SkillFrontmatterTests.cs | 479 ++++++ 66 files changed, 6789 insertions(+), 660 deletions(-) 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 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/Protocol/Skill.cs delete mode 100644 src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.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/McpSkillRequestContext.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.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/McpServerSkillsFromDirectoryTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsIntegrityTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterCorpusTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index e4c8de3a9..97e90457c 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -49,6 +49,8 @@ + + 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/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..3ceef88fd --- /dev/null +++ b/docs/concepts/skills/skills.md @@ -0,0 +1,224 @@ +--- +title: Skills +description: Serve and consume Agent Skills over MCP with the Skills extension. +uid: skills +--- + +## Skills + +The Skills extension lets an MCP server publish [Agent Skills](https://agentskills.io/) alongside its tools, +resources, and prompts. A skill is a directory of files, minimally a `SKILL.md` with YAML frontmatter, that gives +an agent structured workflow instructions. Over MCP, each file is an ordinary resource, and the extension adds two +methods for discovering skills and obtaining a verifiable manifest of their files: + +- `skills/list` enumerates the skills a server serves, with pagination. +- `skills/get` returns the entry for a single skill by the URI of its `SKILL.md`. + +Skills are provided by the `ModelContextProtocol.Extensions.Skills` package. The implementation follows the +[Skills extension specification](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx) +(SEP-2640, extension id `io.modelcontextprotocol/skills`). + +### Overview + +A skill entry () is a complete, point-in-time snapshot of a skill: + +- `uri`: the resource URI of its `SKILL.md`, conventionally `skill:///SKILL.md`. The final segment + of `` is the skill's name. +- `frontmatter`: the `SKILL.md` YAML frontmatter, rendered verbatim as a JSON object. `name` and `description` + are always present; every other authored field passes through. +- `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size + (), or the string `"dynamic"` for generated content + that cannot be digested. + +A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each +one against the manifest. That verification, and the rule that an unlisted file is a change to the skill, is what +lets a user's approval bind to specific content. + +### Serving skills + +The simplest way to serve skills is to keep each one in its own directory, as the Agent Skills specification +lays them out, and point `WithSkillsFromDirectory` at the parent: + +```csharp +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +builder.Services + .AddMcpServer() + .WithHttpTransport() + .WithSkillsFromDirectory(Path.Combine(AppContext.BaseDirectory, "Skills"), configure: options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); +``` + +Every immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each +`SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, +and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix` +such as `skill://acme/billing/` to place the skills under an organizational path. + +For finer control, build skills individually with and +register them with `WithSkills`: + +```csharp +var gitWorkflow = McpServerSkill.CreateFromDirectory(Path.Combine(skillsRoot, "git-workflow")); + +var refunds = McpServerSkill.Create( + "skill://acme/billing/refunds/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", skillMarkdown), + McpServerSkillFile.FromText("examples/approved.md", approvedTemplate), + new McpServerSkillFile { Path = "assets/logo.png", Content = logoBytes }, + ]); + +builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]); +``` + +All of these validate the skill against the specification and throw with a +specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the +Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or +the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`, +`compatibility`, and the shape of `metadata` pass through verbatim, as the specification requires; hosts compare +them against the file and ignore fields such as `allowed-tools` for MCP-origin skills. File contents are copied when the skill is created, so +later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not +follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File +names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs. + +#### Frontmatter + + reads the YAML frontmatter of a `SKILL.md` into a + without a YAML library. It accepts the subset of YAML that Agent Skills +frontmatter uses: block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block +scalars, and comments. Unquoted scalars are resolved per the YAML 1.2 core schema (`null`, booleans, integers, finite +floats, otherwise strings), matching the YAML libraries used by other SDKs and by hosts. That matters because a +host verifies a skill by parsing the fetched `SKILL.md` itself and comparing field by field against the published +entry; a value that one side types as a number and the other as a string is a verification failure. Quote values +such as version numbers that are meant to be strings. + +Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are +valid YAML the reader does not support; it rejects them with a naming the construct. For such a +file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the frontmatter +directly. That escape hatch covers only valid-but-unsupported YAML: a file with no frontmatter block, malformed +YAML, or invalid UTF-8 is rejected regardless, since no host could parse it either. When the reader can parse the +file, an explicit object must match it exactly, or the skill is rejected at construction rather than by every host. + +#### Custom catalogs + +When skills come from a database, a file share, or a large or generated catalog, implement + and pass it to `WithSkills`: + +```csharp +public sealed class DatabaseSkillCatalog(SkillRepository repository) : IMcpSkillCatalog +{ + public async ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + string tenant = GetTenant(context.User); + var (skills, nextCursor) = await repository.GetPageAsync(tenant, cursor, pageSize: 50, cancellationToken); + return new McpSkillPage { Skills = skills, NextCursor = nextCursor }; + } + + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + repository.FindAsync(GetTenant(context.User), uri, cancellationToken); +} +``` + +Both methods receive an with the JSON-RPC request, +the caller's when the transport supplies one (the ASP.NET Core +transport does), and any items that incoming-message filters attached to the request. + +A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with +`resources/read`. A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers +for every skill the server serves to the caller. Throw with + for a cursor the catalog cannot interpret. + is the built-in implementation over a fixed +set of entries and can be composed into a custom one. + +#### Dynamic skills + +A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to + and serve it through a catalog. Such a skill +offers no content integrity, and hosts may decline to load it. + +#### Protocol versions and caching hints + +`skills/list` results carry the base protocol's `ttlMs` and `cacheScope` list-caching attributes, configured through +. Those attributes, and `resultType`, are defined +from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, +where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in +list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. + +### Consuming skills + +The client extension methods in cover the +host's side of the protocol: + +```csharp +using ModelContextProtocol.Extensions.Skills; + +if (!client.SupportsSkills()) +{ + return; // Clients issue skills/list and skills/get only after observing the declaration. +} + +// Enumerate, following pagination. The listing may be empty or partial. +IList skills = await client.ListSkillsAsync(); + +// Retrieve one skill by URI, for example one referenced from server instructions. +Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); + +// Read a file and verify it against the held entry's digest and size. +ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); +``` + +`ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural +requirements and throw for an entry a host +must not load, such as one whose manifest omits its own `SKILL.md`, carries a malformed digest, or lists a file +outside the skill. `ReadSkillResourceAsync` throws the same exception when the +content's size or digest does not match the manifest, or when the URI is not listed in it at all. In both cases the +content must not be used. To recover, refresh the entry with `GetSkillAsync` and proceed from the new manifest; +because the manifest changed, any approval bound to the previous one is revoked and must be obtained again. + +The lower-level overloads, `ListSkillsAsync(ListSkillsRequestParams)` and `GetSkillAsync(GetSkillRequestParams)`, +return one page or the raw result and expose the caching hints. + exposes the verification and digest helpers for hosts +that read resources through other means. + +### Security considerations + +Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The +specification places most of the burden on hosts. In particular: + +- `skills/list` and `skills/get` are registered as raw request handlers, so they do not pass through the request + filters that guard the built-in resource methods, including the ASP.NET Core authorization filters. A listing + therefore discloses frontmatter, file names, sizes, and digests to any caller the transport admits. If some + callers must not see some skills, implement and + decide from the request context's user, and guard the corresponding resources the same way. The built-in + in-memory catalog serves the same entries to every caller. +- Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters + the model's context. +- Digests are unsigned and come from the same server as the content. A match proves consistency between the entry + and what was fetched, not that either is trustworthy. +- Bind any persisted per-skill approval to the entry's manifest. A later entry with a different manifest revokes it. +- Do not honor frontmatter fields that widen the model's permissions, such as `allowed-tools`, for MCP-origin skills + without explicit user approval. +- Skill names are labels, not identifiers, and are not unique across servers. Identify a skill by the pair of server + identity and URI. + +The SDK implements digest and size verification and the unlisted-file rule. It does not verify frontmatter against +the fetched `SKILL.md` automatically. A host can do so with + on the fetched text and + +against the entry's frontmatter, treating a parse failure or a difference as a verification failure. + +### Not implemented + +The optional `resources/directory/read` method and its `directoryRead` capability setting are not implemented. +Servers built with this package do not declare `directoryRead`, and hosts must not call the method against them. + +### Samples + +- [SkillsServer](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..a92591154 --- /dev/null +++ b/samples/SkillsClient/README.md @@ -0,0 +1,69 @@ +# Skills Client Sample + +A console client that consumes [Agent Skills](https://agentskills.io/) over MCP through the Skills extension +([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)), +doing what a host's skill-loading path does: + +1. Connects over Streamable HTTP and checks that the server declares `io.modelcontextprotocol/skills` + (`client.SupportsSkills()`). +2. Enumerates the catalog with `skills/list` (`client.ListSkillsAsync()` follows pagination; the per-page + overload exposes the `ttlMs` and `cacheScope` hints). +3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it + found in server instructions. +4. Reads the skill's files with `resources/read` and verifies each against the manifest's digest and size + (`client.ReadSkillResourceAsync(skill, uri)`). +5. Shows that a file the manifest does not list is refused before any request is sent. + +## Run + +Start the [SkillsServer](../SkillsServer) sample in one terminal: + +```bash +dotnet run --project samples/SkillsServer/SkillsServer.csproj +``` + +Then run the client in another: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj +``` + +To run against another Streamable HTTP server, pass its endpoint: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj -- https://skills.example.com/mcp +``` + +Expected output (abridged): + +``` +=== skills/list === + caching hints: ttlMs=300000 cacheScope=Public + skill://git-workflow/SKILL.md + name: git-workflow + manifest: 3 file(s), 1523 bytes + skill://refunds/SKILL.md + name: refunds + ... + +=== skills/get === + skill://git-workflow/SKILL.md (git-workflow) + skill://git-workflow/SKILL.md 724 bytes sha256:… + ... + +=== resources/read (verified) === + skill://git-workflow/SKILL.md verified: + | --- + | name: git-workflow + ... + +=== unlisted file === + refused: 'skill://git-workflow/references/UNLISTED.md' is not listed in the manifest of skill ... +``` + +## Notes + +- Digest verification proves that the entry and the content are consistent. It is not a trust boundary: both come + from the same server. Treat skill content as untrusted model input and tag it with its originating server. +- The SDK does not verify frontmatter automatically. A host can re-parse the fetched `SKILL.md` with + `SkillFrontmatter.Parse` and compare it to the entry with `JsonNode.DeepEquals` before loading a skill. diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj new file mode 100644 index 000000000..01f44dcb0 --- /dev/null +++ b/samples/SkillsClient/SkillsClient.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + + diff --git a/samples/SkillsServer/Program.cs b/samples/SkillsServer/Program.cs new file mode 100644 index 000000000..f16c7c0c1 --- /dev/null +++ b/samples/SkillsServer/Program.cs @@ -0,0 +1,40 @@ +// Demonstrates serving Agent Skills over MCP with the Skills extension (SEP-2640) from a Streamable HTTP server. +// +// Each skill is a directory under ./Skills containing a SKILL.md and, optionally, supporting files. +// WithSkillsFromDirectory reads every skill directory, parses the YAML frontmatter of each SKILL.md, computes the +// SHA-256 digest and size of every file, and registers both the skills' entries (what skills/list and skills/get +// return) and the resources that serve their files (what resources/read returns). + +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; + +var builder = WebApplication.CreateBuilder(args); + +// Every immediate subdirectory of ./Skills that contains a SKILL.md becomes a skill. The frontmatter is read from +// each SKILL.md, and the skill's URI is skill://{name}/SKILL.md. Use McpServerSkill.CreateFromDirectory or +// McpServerSkill.Create with WithSkills for finer control, for example an organizational URI prefix per skill. +string skillsRoot = Path.Combine(AppContext.BaseDirectory, "Skills"); + +builder.Services + .AddMcpServer(options => + { + options.ServerInfo = new Implementation { Name = "SkillsServer", Version = "1.0.0" }; + + // A server may point the agent at a skill directly from its instructions. A host confirms the URI with + // skills/get and reads it with resources/read; no listing is required. + options.ServerInstructions = + "Before making commits in this repository, load the skill at skill://git-workflow/SKILL.md."; + }) + .WithHttpTransport() + .WithSkillsFromDirectory(skillsRoot, configure: options => + { + // Every caller sees the same catalog, so the listing may be shared by intermediaries for a while. + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }); + +var app = builder.Build(); + +app.MapMcp(); + +app.Run(); diff --git a/samples/SkillsServer/Properties/launchSettings.json b/samples/SkillsServer/Properties/launchSettings.json new file mode 100644 index 000000000..bd31121a4 --- /dev/null +++ b/samples/SkillsServer/Properties/launchSettings.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:3001", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md new file mode 100644 index 000000000..6ec5bb535 --- /dev/null +++ b/samples/SkillsServer/README.md @@ -0,0 +1,43 @@ +# Skills Server Sample + +A Streamable HTTP MCP server that serves two [Agent Skills](https://agentskills.io/) through the MCP Skills +extension ([SEP-2640](https://github.com/modelcontextprotocol/ext-skills/blob/main/specification/stable/skills.mdx)). + +Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and supporting files: + +| Skill URI | Files | +| ---------------------------------------- | -------------------------------------------------------------- | +| `skill://git-workflow/SKILL.md` | `SKILL.md`, `references/COMMIT_STYLE.md`, `templates/PULL_REQUEST.md` | +| `skill://refunds/SKILL.md` | `SKILL.md`, `policy/REFUND_POLICY.md`, `examples/approved.md`, `examples/declined.md` | + +`WithSkillsFromDirectory` reads every skill directory, parses the YAML frontmatter of each `SKILL.md`, computes the +SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the +resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from +its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`. + +## Run + +```bash +dotnet run --project samples/SkillsServer/SkillsServer.csproj +``` + +The server listens on `http://localhost:3001` (see `Properties/launchSettings.json`). Then, in another terminal, +run the [SkillsClient](../SkillsClient) sample, which connects to it and walks through discovery, retrieval, and +verified reads: + +```bash +dotnet run --project samples/SkillsClient/SkillsClient.csproj +``` + +Any MCP host that supports Streamable HTTP can also be pointed at `http://localhost:3001`. + +## Notes + +- Skill URIs are scoped to the server that serves them. A URI may carry an organizational prefix, as in + `skill://acme/billing/refunds/SKILL.md`; only the final segment must equal the skill's `name`. +- The server's instructions point the agent at `skill://git-workflow/SKILL.md` directly. A host can confirm a + URI with `skills/get` and read it with `resources/read` without ever listing the catalog. +- The listing is identical for every caller, so it advertises `cacheScope: public` with a five-minute `ttlMs`. + A catalog that varies by principal must not do that. +- See [docs/concepts/skills/skills.md](../../docs/concepts/skills/skills.md) for the full walkthrough, + including how to implement a custom `IMcpSkillCatalog`. diff --git a/samples/SkillsServer/Skills/git-workflow/SKILL.md b/samples/SkillsServer/Skills/git-workflow/SKILL.md new file mode 100644 index 000000000..663f1a173 --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/SKILL.md @@ -0,0 +1,25 @@ +--- +name: git-workflow +description: Follow this team's Git conventions for branching, commit messages, and pull requests. +license: MIT +--- + +# Git workflow + +Use this skill whenever you create branches, commits, or pull requests in this repository. + +## Branches + +- Branch from `main`. +- Name branches `/`, where `` is one of `feat`, `fix`, `docs`, or `chore`. + +## Commits + +Write commit messages in the imperative mood, with a subject line of at most 72 characters. See +`references/COMMIT_STYLE.md` for the full style guide and examples. + +## Pull requests + +- Open pull requests against `main`. +- Fill in the template in `templates/PULL_REQUEST.md`. +- Request review from at least one code owner. diff --git a/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md new file mode 100644 index 000000000..f4505cf9b --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/references/COMMIT_STYLE.md @@ -0,0 +1,24 @@ +# Commit message style + +``` +: + + +``` + +- `` is one of `feat`, `fix`, `docs`, `refactor`, `test`, or `chore`. +- `` is imperative ("Add", not "Added" or "Adds") and does not end with a period. +- The body explains what changed and why, wrapped at 72 columns. Reference issues as `Fixes #123`. + +## Examples + +``` +feat: Add keyset pagination to the skills catalog + +Offset cursors skipped entries when a skill was removed between pages. +Encode the last URI of each page instead. Fixes #42. +``` + +``` +docs: Clarify that frontmatter must match SKILL.md verbatim +``` diff --git a/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md new file mode 100644 index 000000000..c0947267b --- /dev/null +++ b/samples/SkillsServer/Skills/git-workflow/templates/PULL_REQUEST.md @@ -0,0 +1,16 @@ +## Summary + + + +## Changes + +- + +## Testing + + + +## Checklist + +- [ ] Tests added or updated +- [ ] Documentation updated diff --git a/samples/SkillsServer/Skills/refunds/SKILL.md b/samples/SkillsServer/Skills/refunds/SKILL.md new file mode 100644 index 000000000..50853ef59 --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/SKILL.md @@ -0,0 +1,18 @@ +--- +name: refunds +description: Process customer refund requests per company policy. +metadata: + owner: billing-team + version: 2.1.0 +--- + +# Refunds + +Use this skill when a customer asks for a refund. + +1. Confirm the order is within the 30-day refund window. +2. Check whether the item is refundable under `policy/REFUND_POLICY.md`. +3. If it is, issue the refund and reply using `examples/approved.md`. +4. If it is not, reply using `examples/declined.md` and offer store credit where the policy allows it. + +Never issue a refund above 500 USD without a second approval. diff --git a/samples/SkillsServer/Skills/refunds/examples/approved.md b/samples/SkillsServer/Skills/refunds/examples/approved.md new file mode 100644 index 000000000..a7b966133 --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/examples/approved.md @@ -0,0 +1,9 @@ +Subject: Your refund has been issued + +Hi {customer_name}, + +We've issued a refund of {amount} for order {order_id}. It should appear on your original payment method within +5 to 10 business days. + +Thanks for your patience, +{agent_name} diff --git a/samples/SkillsServer/Skills/refunds/examples/declined.md b/samples/SkillsServer/Skills/refunds/examples/declined.md new file mode 100644 index 000000000..a8b53f7cc --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/examples/declined.md @@ -0,0 +1,9 @@ +Subject: About your refund request + +Hi {customer_name}, + +Unfortunately order {order_id} isn't eligible for a refund because {reason}. As an alternative, we'd be glad to +offer you store credit of {credit_amount}. + +Let us know how you'd like to proceed, +{agent_name} diff --git a/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md new file mode 100644 index 000000000..f88ee1afa --- /dev/null +++ b/samples/SkillsServer/Skills/refunds/policy/REFUND_POLICY.md @@ -0,0 +1,8 @@ +# Refund policy + +| Category | Refundable | Window | Notes | +| ------------------- | ---------- | ------- | -------------------------------------- | +| Physical goods | Yes | 30 days | Must be unused and in original packaging | +| Digital downloads | No | | Store credit may be offered | +| Subscriptions | Prorated | Anytime | Refund the unused portion | +| Gift cards | No | | | diff --git a/samples/SkillsServer/SkillsServer.csproj b/samples/SkillsServer/SkillsServer.csproj new file mode 100644 index 000000000..2833a3dd7 --- /dev/null +++ b/samples/SkillsServer/SkillsServer.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + $(NoWarn);MCPEXP001 + + + + + + + + + + + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets index 0c3c50923..daa2f43b3 100644 --- a/src/Directory.Build.targets +++ b/src/Directory.Build.targets @@ -5,6 +5,7 @@ AfterTargets="_GetProjectReferenceVersions" Condition="'$(MSBuildProjectName)' == 'ModelContextProtocol' Or '$(MSBuildProjectName)' == 'ModelContextProtocol.AspNetCore' + Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Skills' Or '$(MSBuildProjectName)' == 'ModelContextProtocol.Extensions.Tasks'"> <_ProjectReferencesWithVersions Update="@(_ProjectReferencesWithVersions)"> diff --git a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs index ef61df263..0a108ff0a 100644 --- a/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs +++ b/src/ModelContextProtocol.Core/Protocol/CacheScopeConverter.cs @@ -16,12 +16,14 @@ namespace ModelContextProtocol.Protocol; /// . /// /// -/// This converter is applied per-property on the cacheable result types. The -/// enum itself retains a standard string converter for any standalone serialization. +/// This converter is applied per-property on the cacheable result types, and is public so that extension +/// packages defining their own types can apply the same read-side leniency. The +/// enum itself retains a standard string converter for any standalone serialization. /// /// -internal sealed class CacheScopeConverter : JsonConverter +public sealed class CacheScopeConverter : JsonConverter { + /// public override CacheScope? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.String) @@ -52,6 +54,7 @@ internal sealed class CacheScopeConverter : JsonConverter return null; } + /// public override void Write(Utf8JsonWriter writer, CacheScope? value, JsonSerializerOptions options) { if (value is null) diff --git a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs index 7f3d4f483..0713feb84 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedRequest.cs @@ -14,9 +14,8 @@ public abstract class PaginatedRequestParams : RequestParams /// Initializes a new instance of the class. /// /// - /// This is rather than private protected so that extension packages - /// implementing paginated methods defined outside the core specification can derive from it. See - /// . + /// 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 be4bd59ec..e496e0921 100644 --- a/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs +++ b/src/ModelContextProtocol.Core/Protocol/PaginatedResult.cs @@ -21,9 +21,8 @@ public abstract class PaginatedResult : Result /// Initializes a new instance of the class. /// /// - /// This is rather than private protected so that extension packages - /// implementing paginated methods defined outside the core specification can derive from it. See - /// . + /// This constructor is rather than private protected so that extension + /// packages implementing paginated methods defined outside the core specification can derive from it. /// protected PaginatedResult() { diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs new file mode 100644 index 000000000..f24319f29 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -0,0 +1,264 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Text.Json; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Extension methods for to discover, retrieve, and verify skills served under the +/// MCP Skills extension (SEP-2640). +/// +public static class McpSkillsClientExtensions +{ + /// + /// Gets whether the server declared the MCP Skills extension in its capabilities. + /// + /// The client. + /// if the server serves skills/list and skills/get; otherwise, . + /// is . + /// + /// Clients must issue skills/list and skills/get only after observing the server's declaration. + /// The other methods in this class throw when it is absent. + /// + public static bool SupportsSkills(this McpClient client) + { +#if NET + ArgumentNullException.ThrowIfNull(client); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); +#endif + + return client.ServerCapabilities?.Extensions?.ContainsKey(SkillsProtocol.ExtensionId) == true; + } + + /// + /// Retrieves the entries of every skill the server lists, following pagination to the end. + /// + /// The client. + /// The to monitor for cancellation requests. The default is . + /// The listed skills. + /// is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification (for example, a name that does not match its + /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries. + /// + /// The request failed or the server returned an error response. + /// + /// + /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than + /// it serves. Do not treat an empty listing as proof that a server has no skills; a skill's entry can always be + /// retrieved by URI with . + /// + /// + /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use + /// , which returns one page at a time. + /// + /// + public static async ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) + { + List? skills = null; + ListSkillsRequestParams requestParams = new(); + do + { + var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); + skills ??= new(page.Skills.Count); + skills.AddRange(page.Skills); + requestParams.Cursor = page.NextCursor; + } + while (requestParams.Cursor is not null); + + return skills; + } + + /// + /// Retrieves one page of the skills the server lists. + /// + /// The client. + /// The request parameters, including the cursor of the page to retrieve. + /// The to monitor for cancellation requests. The default is . + /// The page, as returned by the server, with every entry validated against the specification. + /// or is . + /// The server did not declare the MCP Skills extension. + /// The server returned an entry that violates the specification. + /// The request failed or the server returned an error response. + public static async ValueTask ListSkillsAsync( + this McpClient client, + ListSkillsRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(ListSkillsAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsList, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + if (result.Skills is null) + { + throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); + } + + foreach (var skill in result.Skills) + { + SkillValidation.ValidateReceived(skill, SkillsProtocol.MethodSkillsList); + } + + return result; + } + + /// + /// Retrieves the entry for a single skill by the URI of its SKILL.md. + /// + /// The client. + /// The URI of the skill's SKILL.md. + /// The to monitor for cancellation requests. The default is . + /// The skill's entry, validated against the specification. + /// or is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// + /// + /// The request failed or the server returned an error response, including + /// when the server serves no skill at . + /// + /// + /// A server answers for every skill it serves, whether or not the skill appears in its listing. Use this to + /// confirm a URI referenced from server instructions or another skill, and to refresh one skill's manifest + /// without re-enumerating the catalog. + /// + public static async ValueTask GetSkillAsync(this McpClient client, string uri, CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + var result = await GetSkillAsync(client, new GetSkillRequestParams { Uri = uri }, cancellationToken).ConfigureAwait(false); + return result.Skill; + } + + /// + /// Retrieves the entry for a single skill using explicit request parameters. + /// + /// The client. + /// The request parameters. + /// The to monitor for cancellation requests. The default is . + /// The result, as returned by the server, with the entry validated against the specification. + /// or is . + /// The server did not declare the MCP Skills extension. + /// + /// The server returned an entry that violates the specification, or an entry for a different URI than the one requested. + /// + /// The request failed or the server returned an error response. + public static async ValueTask GetSkillAsync( + this McpClient client, + GetSkillRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + + ThrowIfSkillsNotSupported(client, nameof(GetSkillAsync)); + + JsonRpcRequest request = new() + { + Method = SkillsProtocol.MethodSkillsGet, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.GetSkillRequestParams), + }; + + JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); + + SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); + if (!string.Equals(result.Skill.Uri, requestParams.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The server answered '{SkillsProtocol.MethodSkillsGet}' for '{requestParams.Uri}' with the entry for '{result.Skill.Uri}'."); + } + + return result; + } + + /// + /// Reads one of a skill's files through resources/read and verifies the content against the skill's manifest. + /// + /// The client. + /// The skill entry being acted on. + /// The URI of the file to read. It must be listed in 's manifest. + /// The to monitor for cancellation requests. The default is . + /// The verified contents. + /// An argument is . + /// + /// 's manifest is . Such a skill offers no content + /// integrity; read its files with + /// only if the host has decided to load unverifiable skills. + /// + /// + /// is not listed in the manifest, the server's response does not contain contents for + /// , or any returned content's size or digest does not match its manifest entry. + /// + /// The request failed or the server returned an error response. + /// + /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is + /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with + /// and proceed from the new manifest. + /// + public static async ValueTask ReadSkillResourceAsync( + this McpClient client, + Skill skill, + string uri, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + + SkillVerifier.ThrowIfDynamic(skill); + if (SkillVerifier.FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + var result = await client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false); + SkillVerifier.Verify(skill, uri, result); + return result; + } + + private static void ThrowIfSkillsNotSupported(McpClient client, string operationName) + { + if (!SupportsSkills(client)) + { + throw new InvalidOperationException( + $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not."); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs new file mode 100644 index 000000000..7f1c153d7 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs @@ -0,0 +1,39 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// The exception thrown when a skill file's content does not match its manifest entry, or when a file is read +/// that the manifest does not list. +/// +/// +/// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale +/// because the skill was updated after the entry was fetched. In all cases the content must not be used. To +/// recover from staleness, fetch a fresh entry with skills/get and proceed from its manifest; because the +/// manifest changed, any approval bound to the previous one is revoked. +/// +public sealed class SkillVerificationException : McpException +{ + /// + /// Initializes a new instance of the class. + /// + public SkillVerificationException() + { + } + + /// + /// Initializes a new instance of the class with a specified error message. + /// + /// The message that describes the error. + public SkillVerificationException(string message) : base(message) + { + } + + /// + /// Initializes a new instance of the class with a specified error message + /// and a reference to the inner exception that is the cause of this exception. + /// + /// The message that describes the error. + /// The exception that is the cause of the current exception. + public SkillVerificationException(string message, Exception? innerException) : base(message, innerException) + { + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs new file mode 100644 index 000000000..a10aacf12 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -0,0 +1,272 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using System.Security.Cryptography; +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Verifies skill file content against a skill's manifest, and computes manifest digests. +/// +/// +/// +/// When a host retrieves a file listed in a skill's manifest, it must verify the content against that entry's +/// digest and size, and must treat a read of a file the manifest does not list as a verification failure. These +/// helpers implement those checks. Frontmatter verification (re-parsing the fetched SKILL.md and comparing +/// its YAML frontmatter against the entry) is not performed automatically; a host can do it with +/// and . +/// +/// +/// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest +/// and the content are consistent, not that either is trustworthy. +/// +/// +public static class SkillVerifier +{ + /// + /// Computes the manifest digest of content: sha256: followed by the lowercase hexadecimal SHA-256 of the bytes. + /// + /// The raw bytes. + /// The digest. + public static string ComputeDigest(ReadOnlySpan content) + { +#if NET + Span hash = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(content, hash); +#else + byte[] hash; + using (var sha256 = SHA256.Create()) + { + hash = sha256.ComputeHash(content.ToArray()); + } +#endif + + var builder = new StringBuilder(SkillsProtocol.DigestPrefix, SkillsProtocol.DigestPrefix.Length + (hash.Length * 2)); + foreach (byte b in hash) + { + builder.Append(ToHexChar(b >> 4)).Append(ToHexChar(b & 0xF)); + } + + return builder.ToString(); + + static char ToHexChar(int nibble) => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + } + + /// + /// Verifies raw content against a manifest entry. + /// + /// The manifest entry. + /// The bytes that were read. + /// is . + /// The size or digest of does not match . + public static void Verify(SkillResource expected, ReadOnlySpan content) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); +#endif + + if (content.Length != expected.Size) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' is {content.Length} bytes, but its manifest entry declares {expected.Size} bytes."); + } + + string actualDigest = ComputeDigest(content); + if (!string.Equals(actualDigest, expected.Digest, StringComparison.OrdinalIgnoreCase)) + { + throw new SkillVerificationException( + $"The content of '{expected.Uri}' has digest '{actualDigest}', but its manifest entry declares '{expected.Digest}'."); + } + } + + /// + /// Verifies the contents returned by resources/read against a manifest entry. + /// + /// The manifest entry. + /// The contents that were read. + /// An argument is . + /// + /// The contents are not for 's URI, are neither text nor a blob, or their size or + /// digest does not match . + /// + /// + /// Text contents are hashed as their UTF-8 encoding; blob contents are hashed as their decoded bytes. + /// + public static void Verify(SkillResource expected, ResourceContents contents) + { +#if NET + ArgumentNullException.ThrowIfNull(expected); + ArgumentNullException.ThrowIfNull(contents); +#else + if (expected is null) throw new ArgumentNullException(nameof(expected)); + if (contents is null) throw new ArgumentNullException(nameof(contents)); +#endif + + if (!string.Equals(contents.Uri, expected.Uri, StringComparison.Ordinal)) + { + throw new SkillVerificationException( + $"The contents are for '{contents.Uri}', but verification was requested against the manifest entry for '{expected.Uri}'."); + } + + switch (contents) + { + case TextResourceContents text: + if (text.Text is null) + { + throw new SkillVerificationException($"The text contents of '{contents.Uri}' carry no text and cannot be verified."); + } + + Verify(expected, Encoding.UTF8.GetBytes(text.Text)); + break; + + case BlobResourceContents blob: + ReadOnlyMemory decoded; + try + { + decoded = blob.DecodedData; + } + catch (FormatException e) + { + throw new SkillVerificationException($"The blob contents of '{contents.Uri}' are not valid Base64.", e); + } + + Verify(expected, decoded.Span); + break; + + default: + throw new SkillVerificationException($"The contents of '{contents.Uri}' are neither text nor a blob and cannot be verified."); + } + } + + /// + /// Verifies the result of a resources/read for one of a skill's files against the skill's manifest. + /// + /// The skill entry being acted on. + /// The result of reading a file of the skill. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// The result is empty, contains contents for a URI the manifest does not list, or contains contents whose + /// size or digest does not match the manifest. + /// + public static void Verify(Skill skill, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + ThrowIfDynamic(skill); + + if (result.Contents is not { Count: > 0 }) + { + throw new SkillVerificationException($"The read returned no contents to verify against skill '{skill.Uri}'."); + } + + foreach (var contents in result.Contents) + { + var expected = FindResource(skill, contents.Uri) ?? + throw new SkillVerificationException( + $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + + Verify(expected, contents); + } + } + + /// + /// Verifies the result of reading a specific file of a skill through resources/read against the skill's + /// manifest, additionally requiring that the result actually contains the requested file. + /// + /// The skill entry being acted on. + /// The URI that was requested. It must be listed in 's manifest. + /// The result of the read. + /// An argument is . + /// 's manifest is , which cannot be verified. + /// + /// is not listed in the manifest, the result does not contain contents for + /// , or any contents in the result fail verification per . + /// + /// + /// Checking every returned content against the manifest is not enough on its own: a server could answer a read + /// of one file with another, correctly digested, file of the same skill. Binding the result to the requested + /// URI closes that gap. + /// + public static void Verify(Skill skill, string uri, ReadResourceResult result) + { +#if NET + ArgumentNullException.ThrowIfNull(skill); + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(result); +#else + if (skill is null) throw new ArgumentNullException(nameof(skill)); + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (result is null) throw new ArgumentNullException(nameof(result)); +#endif + + ThrowIfDynamic(skill); + if (FindResource(skill, uri) is null) + { + throw new SkillVerificationException( + $"'{uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + + "refresh the entry with skills/get before reading it."); + } + + Verify(skill, result); + + bool found = false; + foreach (var contents in result.Contents) + { + found |= string.Equals(contents.Uri, uri, StringComparison.Ordinal); + } + + if (!found) + { + throw new SkillVerificationException( + $"The read of '{uri}' returned no contents for that URI. The server answered with a different file of skill '{skill.Uri}'."); + } + } + + /// + /// Rejects a skill whose manifest is missing (the property is required but can still be assigned + /// ) or dynamic, neither of which can be verified. + /// + internal static void ThrowIfDynamic(Skill skill) + { + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest.", nameof(skill)); + } + + if (skill.Resources.IsDynamic) + { + throw new InvalidOperationException( + $"Skill '{skill.Uri}' declares dynamic resources, which carry no digests and cannot be verified. " + + $"Use {nameof(McpClient.ReadResourceAsync)} directly if unverifiable content is acceptable."); + } + } + + internal static SkillResource? FindResource(Skill skill, string uri) + { + var resources = skill.Resources?.Resources; + if (resources is null) + { + return null; + } + + foreach (var resource in resources) + { + if (string.Equals(resource.Uri, uri, StringComparison.Ordinal)) + { + return resource; + } + } + + return null; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs index 9b834c54c..10e451bc2 100644 --- a/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs +++ b/src/ModelContextProtocol.Extensions.Skills/McpSkillsJsonContext.cs @@ -3,12 +3,12 @@ namespace ModelContextProtocol.Extensions.Skills; /// -/// Provides source-generated JSON serialization metadata for MCP Skills extension types. +/// Provides source-generated JSON serialization metadata for the MCP Skills extension types. /// [JsonSourceGenerationOptions( DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] -[JsonSerializable(typeof(SkillEntry))] +[JsonSerializable(typeof(Skill))] [JsonSerializable(typeof(SkillResource))] [JsonSerializable(typeof(SkillResources))] [JsonSerializable(typeof(ListSkillsRequestParams))] diff --git a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj index cfd7b7275..00a0bf57f 100644 --- a/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj +++ b/src/ModelContextProtocol.Extensions.Skills/ModelContextProtocol.Extensions.Skills.csproj @@ -11,8 +11,8 @@ $(NoWarn);MCPEXP001;MCPEXP002 @@ -31,7 +31,7 @@ - + skills/get request retrieving a single skill's entry by URI. /// /// -/// See the SEP-2640 -/// specification for details. +/// See the Skills extension specification +/// for details. /// public sealed class GetSkillRequestParams : RequestParams { @@ -16,7 +16,7 @@ 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 + /// 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")] diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs index 680b56687..2c9432fc3 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs @@ -9,11 +9,11 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// 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, because a single entry is not a list. +/// result. The result carries no pagination cursor. /// /// -/// See the SEP-2640 -/// specification for details. +/// See the Skills extension specification +/// for details. /// /// public sealed class GetSkillResult : Result @@ -22,5 +22,5 @@ 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 SkillEntry Skill { get; set; } + public required Skill Skill { get; set; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs index be32c62dc..41bd87a95 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsRequestParams.cs @@ -1,5 +1,4 @@ using ModelContextProtocol.Protocol; -using System.Text.Json.Serialization; namespace ModelContextProtocol.Extensions.Skills; @@ -7,9 +6,7 @@ namespace ModelContextProtocol.Extensions.Skills; /// Represents the parameters for a skills/list request enumerating the skills a server serves. /// /// -/// -/// See the SEP-2640 -/// specification for details. -/// +/// 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 index 641f9235a..bc18bbcb9 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -9,16 +9,15 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// 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. +/// 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 SEP-2640 -/// specification for details. +/// See the Skills extension specification +/// for details. /// /// public sealed class ListSkillsResult : PaginatedResult, ICacheableResult @@ -27,7 +26,7 @@ public sealed class ListSkillsResult : PaginatedResult, ICacheableResult /// Gets or sets the skill entries. /// [JsonPropertyName("skills")] - public IList Skills { get; set; } = []; + public IList Skills { get; set; } = []; /// [JsonPropertyName("ttlMs")] @@ -35,13 +34,7 @@ public sealed class ListSkillsResult : PaginatedResult, ICacheableResult public TimeSpan? TimeToLive { get; set; } /// - /// - /// Core applies its own internal CacheScopeConverter to this property on the built-in result - /// types, which tolerates unrecognized scope strings on read by mapping them to - /// . That converter is not accessible outside the core assembly, so this type - /// relies on the converter declared on the enum instead. The written - /// wire values are identical; only the read-side leniency differs. - /// [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/SkillEntry.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.cs deleted file mode 100644 index ed2f8f4de..000000000 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillEntry.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System.Text.Json.Nodes; -using System.Text.Json.Serialization; - -namespace ModelContextProtocol.Extensions.Skills; - -/// -/// Represents a single skill as returned by skills/list and skills/get. -/// -/// -/// -/// An entry is a complete, point-in-time snapshot of a skill: its SKILL.md URI, the verbatim -/// frontmatter of that file, and the manifest of the skill's files. A host that pages through a listing -/// therefore has everything it needs to build its registry and verify every file it later reads, without -/// a second round-trip per skill. -/// -/// -/// See the SEP-2640 -/// specification for details. -/// -/// -public sealed class SkillEntry -{ - /// - /// Gets or sets the resource URI of the skill's SKILL.md. - /// - /// - /// The final 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. Hosts re-parse the fetched - /// SKILL.md and compare it 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. - /// - [JsonPropertyName("resources")] - public required SkillResources Resources { get; set; } -} diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs index dc6740f1d..d58590a1b 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResource.cs @@ -3,45 +3,43 @@ namespace ModelContextProtocol.Extensions.Skills; /// -/// Represents a single file belonging to a skill, as listed in a manifest. +/// 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. +/// metadata type. A carries only the integrity information a host needs to verify +/// a skill's file: its URI, digest, and size. /// /// -/// See the SEP-2640 -/// specification for details. +/// See the Skills extension specification +/// for details. /// /// public sealed class SkillResource { /// - /// Gets or sets the resource URI of the file. + /// 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, formatted as sha256:{hex} where {hex} - /// is 64 lowercase hexadecimal characters. + /// 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 - /// listing and the content are consistent; it is not a security boundary and must not be treated as one. + /// 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 the - /// covers. + /// 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, whether or not the digest is subsequently computed. + /// 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 index dc8dda57a..68aa5b2dd 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResources.cs @@ -4,26 +4,24 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// Represents a skill's file manifest: either a complete enumeration of the skill's files, or the -/// "dynamic" marker indicating the skill's content is generated and cannot be digested. +/// "dynamic" marker indicating that the skill's content is generated and cannot be digested. /// /// /// -/// SEP-2640 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 "dynamic", is invalid and hosts must -/// not load it. Use or to construct one; there is +/// 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 SEP-2640 -/// specification for details. +/// See the Skills extension specification +/// for details. /// /// [JsonConverter(typeof(SkillResourcesConverter))] public sealed class SkillResources { - private readonly IReadOnlyList? _resources; - - private SkillResources(IReadOnlyList? resources) => _resources = resources; + private SkillResources(IReadOnlyList? resources) => Resources = resources; /// /// Gets a manifest representing a skill whose content is generated dynamically. @@ -38,10 +36,10 @@ public sealed class SkillResources /// Creates a manifest enumerating every file of a skill. /// /// - /// The skill's complete file list. It must include an entry matching the skill's own - /// , carrying the digest and size of its SKILL.md. + /// 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 defensive copy of . + /// A manifest wrapping a copy of . /// is . public static SkillResources FromResources(IEnumerable resources) { @@ -57,10 +55,10 @@ public static SkillResources FromResources(IEnumerable resources) /// /// Gets a value indicating whether this manifest is the "dynamic" marker. /// - public bool IsDynamic => _resources is null; + public bool IsDynamic => Resources is null; /// /// Gets the enumerated files, or when is . /// - public IReadOnlyList? Resources => _resources; + public IReadOnlyList? Resources { get; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs index 7273f2fd7..8c8aea26e 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/SkillResourcesConverter.cs @@ -13,69 +13,55 @@ 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. SEP-2640 - /// 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. + /// 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) { - if (reader.TokenType == JsonTokenType.Null) + switch (reader.TokenType) { - throw new JsonException( - $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found null."); - } - - if (reader.TokenType == JsonTokenType.String) - { - string? sentinel = reader.GetString(); - if (!string.Equals(sentinel, SkillsProtocol.DynamicResourcesSentinel, StringComparison.Ordinal)) - { + case JsonTokenType.Null: throw new JsonException( - $"Invalid skill manifest: expected the string '{SkillsProtocol.DynamicResourcesSentinel}' but found '{sentinel}'."); - } + $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found null."); - return SkillResources.Dynamic; - } + 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}'."); + } - if (reader.TokenType != JsonTokenType.StartArray) - { - throw new JsonException( - $"Invalid skill manifest: expected an array or the string '{SkillsProtocol.DynamicResourcesSentinel}' but found {reader.TokenType}."); - } + return SkillResources.Dynamic; - var resources = new List(); - while (reader.Read()) - { - if (reader.TokenType == JsonTokenType.EndArray) - { - return SkillResources.FromResources(resources); - } + 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); - if (resource is null) - { - throw new JsonException("Invalid skill manifest: a resource entry was null."); - } + var resource = JsonSerializer.Deserialize(ref reader, McpSkillsJsonContext.Default.SkillResource) ?? + throw new JsonException("Invalid skill manifest: a resource entry was null."); + resources.Add(resource); + } - resources.Add(resource); - } + throw new JsonException("Invalid skill manifest: unterminated array."); - 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 NET - ArgumentNullException.ThrowIfNull(writer); - ArgumentNullException.ThrowIfNull(value); -#else - if (writer is null) throw new ArgumentNullException(nameof(writer)); - if (value is null) throw new ArgumentNullException(nameof(value)); -#endif - if (value.IsDynamic) { writer.WriteStringValue(SkillsProtocol.DynamicResourcesSentinel); diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs index 50bbae73f..c6bcd6377 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -5,12 +5,22 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// -/// A catalog is the single source of truth behind every view of a server's skills. Implementations back -/// skills/list through and skills/get through . +/// 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 are deliberately separate. A server may enumerate only part of its catalog, or none of it, while -/// still answering for every skill it serves by URI. +/// The two methods are deliberately independent. A server may enumerate only part of its catalog, or none of +/// it, while still answering for every skill it serves by URI. +/// +/// +/// A catalog is responsible only for the entries. The skills' files must additionally be served as ordinary +/// resources through resources/read, since that is how hosts fetch skill content. +/// +/// +/// The skills methods do not pass through the request filters that guard the built-in resource methods, so a +/// catalog whose skills are not visible to every caller must decide what to return from the +/// it receives, and the corresponding resources must be guarded the same way. /// /// public interface IMcpSkillCatalog @@ -19,31 +29,34 @@ public interface IMcpSkillCatalog /// Lists a page of the skills this catalog publishes. /// /// - /// An opaque cursor from a previous call, or to start at the first page. + /// An opaque cursor returned by a previous call, or to start at the first page. /// - /// A token to cancel the operation. + /// The request being answered, including the caller's identity when the transport supplies one. + /// The to monitor for cancellation requests. /// - /// A page of entries, and the cursor for the following page when more entries remain. A skill's manifest - /// is never split across pages. + /// 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. + /// Returning an empty page is valid. Hosts must not treat an empty listing as proof that a server has no skills. + /// Throw with for a cursor this + /// catalog cannot interpret. A cursor is an opaque position, not a capability: a catalog may resume from a + /// well-formed cursor it does not recognize, as does. /// - ValueTask ListAsync(string? cursor, 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. - /// A token to cancel the operation. + /// 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 beaf7e98a..652026641 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -6,27 +6,39 @@ namespace ModelContextProtocol.Extensions.Skills; /// An over a fixed set of skill entries held in memory. /// /// -/// Entries are ordered by URI so that pagination is stable across calls. Cursors are keyset cursors over -/// that order rather than offsets, so entries added or removed between pages cannot cause a page to be -/// skipped or repeated wholesale. +/// +/// Every entry is validated against the specification's structural requirements when the catalog is constructed, +/// so a server cannot publish an entry a conforming host would refuse to load. +/// +/// +/// The catalog keeps its own copy of every entry and hands out copies, so neither later changes to the objects +/// passed to the constructor nor changes to a returned entry affect what is served. Entries are ordered by URI so that pagination is stable across calls. Cursors are +/// keyset cursors over that order rather than offsets. +/// +/// +/// Every caller sees the same entries. For a catalog whose contents depend on the caller, implement +/// directly and consult . +/// /// public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog { - private readonly List _ordered; - private readonly Dictionary _byUri; + 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. - /// + /// The maximum number of entries returned per call. Defaults to 50. /// is . /// is less than 1. - /// Two skills share the same URI. - public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) + /// + /// 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); @@ -37,61 +49,78 @@ public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50 #endif _pageSize = pageSize; - _byUri = new Dictionary(StringComparer.Ordinal); + _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); + _byUri.Add(skill.Uri, SkillValidation.Snapshot(skill)); } _ordered = [.. _byUri.Values]; - _ordered.Sort(static (left, right) => string.CompareOrdinal(left.Uri, right.Uri)); + 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) + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - string? afterUri = DecodeCursor(cursor); int start = 0; - if (afterUri is not null) + if (DecodeCursor(cursor) is { } afterUri) { - while (start < _ordered.Count && - string.CompareOrdinal(_ordered[start].Uri, afterUri) <= 0) - { - start++; - } + int index = Array.BinarySearch(_orderedUris, afterUri, StringComparer.Ordinal); + start = index >= 0 ? index + 1 : ~index; } - int count = Math.Min(_pageSize, _ordered.Count - start); + int count = Math.Min(_pageSize, _ordered.Length - start); if (count <= 0) { return new ValueTask(McpSkillPage.Empty); } - var page = _ordered.GetRange(start, count); - bool hasMore = start + count < _ordered.Count; - string? nextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null; - return new ValueTask(new McpSkillPage(page, nextCursor)); + var page = new Skill[count]; + for (int i = 0; i < count; i++) + { + page[i] = SkillValidation.Snapshot(_ordered[start + i]); + } + + bool hasMore = start + count < _ordered.Length; + return new ValueTask(new McpSkillPage + { + Skills = page, + NextCursor = hasMore ? EncodeCursor(page[count - 1].Uri) : null, + }); } /// - public ValueTask GetAsync(string uri, CancellationToken cancellationToken) + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) { +#if NET + ArgumentNullException.ThrowIfNull(uri); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); +#endif + cancellationToken.ThrowIfCancellationRequested(); - _byUri.TryGetValue(uri, out var skill); - return new ValueTask(skill); + return new ValueTask(_byUri.TryGetValue(uri, out var skill) ? SkillValidation.Snapshot(skill) : null); } - private string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri)); + private static string EncodeCursor(string uri) => Convert.ToBase64String(Encoding.UTF8.GetBytes(uri)); - private string? DecodeCursor(string? cursor) + private static string? DecodeCursor(string? cursor) { if (string.IsNullOrEmpty(cursor)) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs new file mode 100644 index 000000000..35b04303a --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -0,0 +1,655 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents a skill served by an MCP server: its entry together with the +/// instances that serve the skill's files. +/// +/// +/// +/// The specification requires a skill's manifest to carry the SHA-256 digest and size of every file, and a host +/// refuses content whose bytes do not match. Building a skill through +/// or (or their overloads) computes the manifest from the same bytes the +/// resources serve, so the two cannot disagree. +/// +/// +/// The skill's frontmatter is read from its SKILL.md by . Hosts re-parse the +/// fetched SKILL.md and compare it field by field against the published entry, treating any discrepancy as +/// a verification failure, so the two must agree. The overloads that accept an explicit +/// exist for frontmatter the reader cannot handle; when it can read the file, an explicit object that differs from +/// it is rejected. +/// +/// +/// Register skills with , +/// which registers both the catalog entries and the file resources, or point +/// +/// at a directory of skills. +/// +/// +public sealed class McpServerSkill +{ + private readonly Skill _entry; + + private McpServerSkill(Skill entry, IReadOnlyList resources) + { + // The entry may hold the caller's frontmatter object; keep a private copy so later changes to it do not + // reach the skill. + _entry = SkillValidation.Snapshot(entry); + Resources = resources; + } + + /// + /// Gets a copy of the skill's entry, as returned by skills/list and skills/get. + /// + /// + /// Each access returns a new copy. The entry was computed from the same bytes serve, + /// and the skill keeps that entry itself, so changes made to a returned copy affect neither the skill nor + /// what WithSkills registers. + /// + public Skill ProtocolSkill => SkillValidation.Snapshot(_entry); + + /// + /// Gets the resources serving the skill's files, one per file, each addressable at the URI its manifest entry names. + /// + public IReadOnlyList Resources { get; } + + /// + /// Creates a skill from its files, reading the frontmatter from SKILL.md and deriving the skill's URI + /// from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// is . + /// + /// omits SKILL.md, contains a duplicate or unsafe path, or exceeds the + /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (see + /// ) or is missing a required field. + /// + public static McpServerSkill Create(IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(files); +#else + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files, reading the frontmatter from SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md; omits SKILL.md, + /// contains a duplicate or unsafe path, or exceeds the specification's per-skill limits; or the SKILL.md + /// frontmatter cannot be read (see ), is missing a required field, or has a + /// name that does not match . + /// + public static McpServerSkill Create(string uri, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, files, nameof(files)); + } + + /// + /// Creates a skill from its files with explicitly supplied frontmatter. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's files. Exactly one must have the path SKILL.md. + /// The skill. + /// An argument is . + /// + /// does not end in /SKILL.md; is missing a required + /// field, has a name that does not match , or differs from the frontmatter in + /// SKILL.md; or omits SKILL.md, contains a duplicate or unsafe path, or + /// exceeds the specification's per-skill limits. + /// + /// + /// Prefer , which reads the frontmatter from the + /// file. This overload exists for frontmatter that cannot read. When it can read + /// the file, the supplied object must match it exactly, since hosts compare the two field by field and refuse + /// the skill on any difference. + /// + public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(files); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (files is null) throw new ArgumentNullException(nameof(files)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter, files, nameof(files)); + } + + /// + /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md + /// and deriving the skill's URI from the frontmatter's name as skill://{name}/SKILL.md. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// + /// Files are read once, when this method is called. Changes on disk afterwards are not reflected in the + /// manifest or the served content. + /// + /// + /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file + /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a + /// regular file or directory, or build the skill with and + /// explicit files. + /// + /// + public static McpServerSkill CreateFromDirectory(string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// See for how files and links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + /// + /// Creates a skill from every file in a directory, recursively, with explicitly supplied frontmatter. + /// + /// + /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path + /// segment preceding /SKILL.md must equal the skill's name. + /// + /// + /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and + /// description fields and reproduce the authored frontmatter exactly. + /// + /// The skill's root directory. It must contain a SKILL.md. + /// The skill. + /// An argument is . + /// does not exist. + /// + /// The directory's contents do not form a valid skill (see ), + /// or the directory contains a symbolic link or other reparse point. + /// + /// + /// Prefer , which reads the frontmatter from the file. This + /// overload exists for frontmatter that cannot read. When it can read the file, + /// the supplied object must match it exactly. See for how files and + /// links are handled. + /// + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + { +#if NET + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(frontmatter); + ArgumentNullException.ThrowIfNull(directoryPath); +#else + if (uri is null) throw new ArgumentNullException(nameof(uri)); + if (frontmatter is null) throw new ArgumentNullException(nameof(frontmatter)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); +#endif + + return CreateCore(uri, uriPrefix: null, frontmatter, ReadDirectory(directoryPath), nameof(directoryPath)); + } + + private const string DefaultUriPrefix = "skill://"; + + /// + /// Creates a skill from a directory, deriving its URI as {uriPrefix}{name}/SKILL.md. Used by + /// WithSkillsFromDirectory. + /// + internal static McpServerSkill CreateFromDirectory(string directoryPath, string uriPrefix, string paramName) => + CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath), paramName); + + private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName) + { + // A violation found in the entry is reported against the frontmatter argument when the caller supplied + // one, and against the files (or directory) it was read from otherwise. + string entryParamName = frontmatter is null ? filesParamName : nameof(frontmatter); + + // Normalize and order the files: SKILL.md first, then the rest by path, so the manifest is deterministic. + var normalized = new List<(string Path, McpServerSkillFile File)>(); + var seenPaths = new HashSet(StringComparer.Ordinal); + foreach (var file in files) + { + if (file is null) + { + throw new ArgumentException("The skill's files must not contain null entries.", filesParamName); + } + + string path = NormalizePath(file.Path); + if (!seenPaths.Add(path)) + { + throw new ArgumentException($"The skill's files contain the path '{path}' more than once.", filesParamName); + } + + normalized.Add((path, file)); + } + + if (!seenPaths.Contains(SkillsProtocol.SkillFileName)) + { + throw new ArgumentException($"The skill's files must include '{SkillsProtocol.SkillFileName}' at the skill's root.", filesParamName); + } + + normalized.Sort(static (left, right) => + { + bool leftIsSkillFile = left.Path == SkillsProtocol.SkillFileName; + bool rightIsSkillFile = right.Path == SkillsProtocol.SkillFileName; + if (leftIsSkillFile != rightIsSkillFile) + { + return leftIsSkillFile ? -1 : 1; + } + + return string.CompareOrdinal(left.Path, right.Path); + }); + + // Snapshot every file's bytes once. The caller's ReadOnlyMemory may alias an array the caller goes + // on to mutate, and the digest published in the manifest must describe exactly the bytes served. + var contents = new byte[normalized.Count][]; + for (int i = 0; i < normalized.Count; i++) + { + contents[i] = normalized[i].File.Content.ToArray(); + } + + // Read the frontmatter from SKILL.md (always first after sorting). When the caller supplied frontmatter, + // the file is still read so that the two can be checked against each other. The caller's frontmatter + // stands on its own only when the file uses valid YAML the reader does not support; a file that is not + // UTF-8, has no frontmatter block, or is malformed cannot be reproduced by any host and is rejected. + if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) + { + throw new ArgumentException($"{SkillsProtocol.SkillFileName} is not valid UTF-8.", filesParamName); + } + + JsonObject? fileFrontmatter = null; + try + { + fileFrontmatter = SkillFrontmatter.Parse(skillMarkdown!); + } + catch (SkillFrontmatter.UnsupportedYamlException e) when (frontmatter is null) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} uses YAML that {nameof(SkillFrontmatter)} does not support: {e.Message} " + + "Supply the frontmatter explicitly with the overload that takes a JsonObject.", + filesParamName); + } + catch (SkillFrontmatter.UnsupportedYamlException) + { + // Explicit frontmatter covers this case. + } + catch (FormatException e) + { + throw new ArgumentException($"The frontmatter of {SkillsProtocol.SkillFileName} could not be read: {e.Message}", filesParamName); + } + + if (frontmatter is null) + { + frontmatter = fileFrontmatter!; + } + else if (fileFrontmatter is not null && !JsonNode.DeepEquals(fileFrontmatter, frontmatter)) + { + throw new ArgumentException( + $"The supplied frontmatter does not match the frontmatter in {SkillsProtocol.SkillFileName}. Hosts compare the two field by field " + + "and refuse the skill on any difference. Fix the mismatch, or omit the frontmatter argument to have it read from the file. " + + $"From the file: {fileFrontmatter.ToJsonString()} Supplied: {frontmatter.ToJsonString()}", + nameof(frontmatter)); + } + + if (uri is null) + { + string? name = new Skill { Uri = string.Empty, Frontmatter = frontmatter, Resources = SkillResources.Dynamic }.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException( + $"The frontmatter of {SkillsProtocol.SkillFileName} must declare a string 'name' for the skill's URI to be derived from it.", + filesParamName); + } + + uri = uriPrefix + name + "/" + SkillsProtocol.SkillFileName; + } + + string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); + + // Build and validate the entry before creating any resources, so an invalid skill fails fast with a + // message about the entry rather than about a resource. + var manifest = new List(normalized.Count); + for (int i = 0; i < normalized.Count; i++) + { + manifest.Add(new SkillResource + { + Uri = root + "/" + EscapePath(normalized[i].Path), + Digest = SkillVerifier.ComputeDigest(contents[i]), + Size = contents[i].Length, + }); + } + + var skill = new Skill + { + Uri = uri, + Frontmatter = frontmatter, + Resources = SkillResources.FromResources(manifest), + }; + + SkillValidation.Validate(skill, entryParamName); + + var resources = new McpServerResource[normalized.Count]; + for (int i = 0; i < normalized.Count; i++) + { + var (path, file) = normalized[i]; + bool isSkillFile = path == SkillsProtocol.SkillFileName; + resources[i] = CreateResource( + manifest[i].Uri, + name: isSkillFile ? skill.Name! : path, + description: isSkillFile ? skill.Description : null, + mimeType: file.MimeType ?? (isSkillFile ? SkillsProtocol.SkillFileMimeType : GuessMimeType(path, contents[i])), + contents[i]); + } + + return new McpServerSkill(skill, resources); + } + + private static List ReadDirectory(string directoryPath) + { + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skill directory '{fullDirectory}' does not exist."); + } + + if ((File.GetAttributes(fullDirectory) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{fullDirectory}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + "because a link can point outside the intended location. Pass the target directory instead.", + nameof(directoryPath)); + } + + if (!fullDirectory.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal)) + { + fullDirectory += Path.DirectorySeparatorChar; + } + + var files = new List(); + long totalSize = 0; + CollectFiles(fullDirectory, fullDirectory, files, ref totalSize); + return files; + } + + /// + /// Walks a skill directory without following links. A symbolic link (or any other reparse point) can point + /// outside the skill directory, and a file reached through one would be published under a URI that looks like + /// it lives inside the skill. Rather than try to decide which link targets are acceptable, links are rejected. + /// + private static void CollectFiles(string root, string directory, List files, ref long totalSize) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{entry}' is a symbolic link or other reparse point. Links are not followed when loading a skill directory, " + + $"because a link can point outside the skill. Replace it with a regular file or directory, or build the skill " + + $"with {nameof(McpServerSkill)}.{nameof(Create)} and explicit files.", + "directoryPath"); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + CollectFiles(root, entry, files, ref totalSize); + continue; + } + + // Apply the specification's per-skill limits before reading, so an oversized or overly broad directory + // fails with the limit named rather than after allocating everything under it. The manifest built from + // the bytes actually read is validated against the same limits afterwards. + if (files.Count >= SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"The skill directory '{root}' contains more than {SkillsProtocol.MaxResourcesPerSkill} files, the limit per skill.", + "directoryPath"); + } + + long length = new FileInfo(entry).Length; + if (length > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"The files under '{root}' total more than {SkillsProtocol.MaxTotalSizeBytes} bytes, the limit per skill.", + "directoryPath"); + } + + totalSize += length; + + string relativePath = entry.Substring(root.Length).Replace(Path.DirectorySeparatorChar, '/'); + if (Path.AltDirectorySeparatorChar != Path.DirectorySeparatorChar) + { + relativePath = relativePath.Replace(Path.AltDirectorySeparatorChar, '/'); + } + + files.Add(new McpServerSkillFile + { + Path = relativePath, + Content = ReadExactly(entry, length), + }); + } + } + + /// + /// Reads a file whose length was checked against the per-skill limits a moment ago, allocating only that + /// length, and fails if the file turns out to be a different size, so a file that grows or shrinks between the + /// check and the read can neither exceed the limit nor be served with a manifest that does not describe it. + /// + private static byte[] ReadExactly(string path, long expectedLength) + { + var buffer = new byte[expectedLength]; + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + int total = 0; + while (total < buffer.Length) + { + int read = stream.Read(buffer, total, buffer.Length - total); + if (read == 0) + { + break; + } + + total += read; + } + + if (total != buffer.Length || stream.ReadByte() != -1) + { + throw new ArgumentException($"'{path}' changed size while it was being read.", "directoryPath"); + } + + return buffer; + } + + /// + /// Percent-encodes each segment of a normalized relative path so that characters with URI syntax (such as + /// {, ?, #, or a space) in a file name stay literal. Without this, a file named + /// {name}.md would register as a resource template rather than a concrete resource. + /// + private static string EscapePath(string normalizedPath) + { + string[] segments = normalizedPath.Split('/'); + for (int i = 0; i < segments.Length; i++) + { + segments[i] = Uri.EscapeDataString(segments[i]); + } + + return string.Join("/", segments); + } + + private static string NormalizePath(string? path) + { + if (string.IsNullOrEmpty(path)) + { + throw new ArgumentException("A skill file must have a non-empty path.", nameof(McpServerSkillFile.Path)); + } + + string normalized = path!.Replace('\\', '/'); + if (normalized.StartsWith("./", StringComparison.Ordinal)) + { + normalized = normalized.Substring(2); + } + + if (normalized.Length == 0 || normalized[0] == '/' || normalized[normalized.Length - 1] == '/') + { + throw new ArgumentException($"The skill file path '{path}' must be relative to the skill's root and must not end in a separator.", nameof(McpServerSkillFile.Path)); + } + + foreach (string segment in normalized.Split('/')) + { + if (segment.Length == 0 || segment == "." || segment == "..") + { + throw new ArgumentException($"The skill file path '{path}' must not contain empty, '.', or '..' segments.", nameof(McpServerSkillFile.Path)); + } + } + + return normalized; + } + + private static McpServerResource CreateResource(string uri, string name, string? description, string mimeType, ReadOnlyMemory content) + { + // Text is served as TextResourceContents only when the bytes round-trip through UTF-8 exactly, because the + // host hashes the UTF-8 encoding of the text it receives and compares it against the manifest digest, which + // was computed over the raw bytes. Anything else is served as a blob, which round-trips by construction. + ResourceContents Read() => + IsTextualMimeType(mimeType) && TryDecodeUtf8(content.Span, out string? text) + ? new TextResourceContents { Uri = uri, MimeType = mimeType, Text = text! } + : BlobResourceContents.FromBytes(content, uri, mimeType); + + return McpServerResource.Create(Read, new McpServerResourceCreateOptions + { + UriTemplate = uri, + Name = name, + Description = description, + MimeType = mimeType, + }); + } + + private static bool TryDecodeUtf8(ReadOnlySpan bytes, out string? text) + { + try + { +#if NET + text = s_strictUtf8.GetString(bytes); +#else + text = s_strictUtf8.GetString(bytes.ToArray()); +#endif + return true; + } + catch (DecoderFallbackException) + { + text = null; + return false; + } + } + + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); + + private static bool IsTextualMimeType(string mimeType) => + mimeType.StartsWith("text/", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+json", StringComparison.OrdinalIgnoreCase) || + mimeType.EndsWith("+xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/xml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/yaml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/toml", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/javascript", StringComparison.OrdinalIgnoreCase) || + mimeType.Equals("application/x-sh", StringComparison.OrdinalIgnoreCase); + + private static string GuessMimeType(string path, ReadOnlySpan content) + { + int dot = path.LastIndexOf('.'); + string extension = dot < 0 || dot < path.LastIndexOf('/') ? string.Empty : path.Substring(dot + 1).ToLowerInvariant(); + + return extension switch + { + "md" or "markdown" => "text/markdown", + "txt" => "text/plain", + "csv" => "text/csv", + "html" or "htm" => "text/html", + "css" => "text/css", + "js" or "mjs" => "text/javascript", + "ts" => "text/typescript", + "py" => "text/x-python", + "cs" => "text/x-csharp", + "sh" or "bash" => "application/x-sh", + "json" => "application/json", + "yaml" or "yml" => "application/yaml", + "toml" => "application/toml", + "xml" => "application/xml", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" or "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "pdf" => "application/pdf", + _ => TryDecodeUtf8(content, out _) ? "text/plain" : "application/octet-stream", + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs new file mode 100644 index 000000000..a4f9f0040 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkillFile.cs @@ -0,0 +1,52 @@ +using System.Text; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Represents one file of a skill authored through . +/// +public sealed class McpServerSkillFile +{ + /// + /// Gets the file's path relative to the skill's root directory, using / as the separator + /// (for example, SKILL.md or references/GUIDE.md). + /// + public required string Path { get; init; } + + /// + /// Gets the file's raw content. Its digest and size in the skill's manifest are computed from exactly these bytes. + /// + public required ReadOnlyMemory Content { get; init; } + + /// + /// Gets the MIME type to advertise for the file's resource, or to infer one from the + /// file's extension. + /// + public string? MimeType { get; init; } + + /// + /// Creates a file from UTF-8 encoded text. + /// + /// The file's path relative to the skill's root directory. + /// The file's content. + /// The MIME type to advertise, or to infer one from . + /// The file. + /// or is . + public static McpServerSkillFile FromText(string path, string text, string? mimeType = null) + { +#if NET + ArgumentNullException.ThrowIfNull(path); + ArgumentNullException.ThrowIfNull(text); +#else + if (path is null) throw new ArgumentNullException(nameof(path)); + if (text is null) throw new ArgumentNullException(nameof(text)); +#endif + + return new McpServerSkillFile + { + Path = path, + Content = Encoding.UTF8.GetBytes(text), + MimeType = mimeType, + }; + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs index 667010548..ed1f9a55a 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillPage.cs @@ -3,14 +3,20 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// Represents one page of skill entries returned by . /// -/// The entries in this page. -/// -/// The cursor to pass back for the following page, or when no entries remain. -/// -public sealed record McpSkillPage(IReadOnlyList Skills, string? NextCursor = null) +public sealed class McpSkillPage { /// /// Gets an empty page with no following page. /// - public static McpSkillPage Empty { get; } = new([]); + public static McpSkillPage Empty { get; } = new() { Skills = [] }; + + /// + /// Gets the entries in this page. + /// + public required IReadOnlyList Skills { get; init; } + + /// + /// Gets the cursor to pass back for the following page, or when no entries remain. + /// + public string? NextCursor { get; init; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs new file mode 100644 index 000000000..8233642d8 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillRequestContext.cs @@ -0,0 +1,58 @@ +using ModelContextProtocol.Protocol; +using System.Security.Claims; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Describes the request an is answering. +/// +/// +/// +/// The skills methods are registered as raw request handlers, so they do not pass through the typed request +/// filter pipeline that guards the built-in resource methods (for example, the ASP.NET Core authorization +/// filters). A catalog that must not disclose every skill to every caller makes that decision itself, from the +/// and any that incoming-message filters attached to the request. +/// +/// +/// is populated by the ASP.NET Core transport from the HTTP request's principal. For other +/// transports, or when no authentication is configured, it is . +/// +/// +public sealed class McpSkillRequestContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The JSON-RPC request being answered. + /// is . + public McpSkillRequestContext(JsonRpcRequest jsonRpcRequest) + { +#if NET + ArgumentNullException.ThrowIfNull(jsonRpcRequest); +#else + if (jsonRpcRequest is null) throw new ArgumentNullException(nameof(jsonRpcRequest)); +#endif + + JsonRpcRequest = jsonRpcRequest; + } + + /// + /// Gets the JSON-RPC request being answered. + /// + public JsonRpcRequest JsonRpcRequest { get; } + + /// + /// Gets the authenticated user making the request, or if none is associated with it. + /// + public ClaimsPrincipal? User => JsonRpcRequest.Context?.User; + + /// + /// Gets the per-request items that incoming-message filters attached to the request, if any. + /// + public IDictionary? Items => JsonRpcRequest.Context?.Items; + + /// + /// Gets the protocol version the request was made under, when the transport or the request carried one. + /// + public string? ProtocolVersion => JsonRpcRequest.Context?.ProtocolVersion; +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index 4722b0537..1bfa446b9 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Extensions.Skills; @@ -13,48 +14,248 @@ namespace ModelContextProtocol.Extensions.Skills; public static class McpSkillsBuilderExtensions { /// - /// Enables MCP Skills support backed by the specified catalog. + /// Enables MCP Skills support for a fixed set of skills, serving both their entries and their files. /// /// The server builder. - /// The catalog supplying the skills this server serves. + /// The skills to serve. + /// An optional callback that configures the extension's behavior. /// The builder provided in . - public static IMcpServerBuilder WithSkills(this IMcpServerBuilder builder, IMcpSkillCatalog catalog) - => WithSkills(builder, catalog, static _ => { }); + /// 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 and the identical URI text. URIs that differ only in the + /// case of their scheme or authority are rejected, because the server's resource collection treats them as one. + /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// + /// + public static IMcpServerBuilder WithSkills( + this IMcpServerBuilder builder, + IEnumerable skills, + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(skills); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (skills is null) throw new ArgumentNullException(nameof(skills)); +#endif + + var entries = new List(); + + // The server's resource collection keys concrete resources by System.Uri equality, under which scheme and + // authority are case-insensitive, and it registers silently. Detect shared files under those same + // semantics, so two URIs the collection would merge are caught here rather than served as one another. + var registeredFiles = new Dictionary(); + foreach (var skill in skills) + { + if (skill is null) + { + throw new ArgumentException("The skills must not contain null entries.", nameof(skills)); + } + + var protocolSkill = skill.ProtocolSkill; + entries.Add(protocolSkill); + + var manifest = protocolSkill.Resources.Resources!; + for (int i = 0; i < manifest.Count; i++) + { + var entry = manifest[i]; + var key = new Uri(entry.Uri, UriKind.Absolute); + if (registeredFiles.TryGetValue(key, out var existing)) + { + if (!string.Equals(existing.Uri, entry.Uri, StringComparison.Ordinal)) + { + // Even with identical content the alias cannot be served: the collection registers one + // resource, whose contents carry the first URI, so a verified read of the second fails. + throw new ArgumentException( + $"The file '{entry.Uri}' is equivalent to '{existing.Uri}', listed by another skill. Resource URIs are compared " + + "case-insensitively in their scheme and authority, so the two would be served as one. Use identical URIs for a shared file.", + nameof(skills)); + } + + if (!string.Equals(existing.Digest, entry.Digest, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The file '{entry.Uri}' is listed by more than one skill with different content.", + nameof(skills)); + } + + continue; + } + + registeredFiles.Add(key, (entry.Uri, entry.Digest)); + builder.Services.AddSingleton(skill.Resources[i]); + } + } + + return WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); + } + + /// + /// Enables MCP Skills support for every skill directory found directly under , + /// serving both their entries and their files. + /// + /// The server builder. + /// + /// A directory whose immediate subdirectories are skills. Each subdirectory containing a SKILL.md becomes + /// one skill; subdirectories without one are ignored. + /// + /// + /// The prefix of each skill's URI. A skill's SKILL.md is published at {uriPrefix}{name}/SKILL.md, + /// where name is the skill's frontmatter name. Defaults to skill://; use a longer prefix such as + /// skill://acme/billing/ to place the skills under an organizational path. + /// + /// An optional callback that configures the extension's behavior. + /// The builder provided in . + /// An argument is . + /// does not exist. + /// + /// No subdirectory contains a SKILL.md, a skill directory is invalid (see + /// ), or two skills declare the same name. + /// + /// + /// + /// Each skill is built with : files are read once, + /// digests are computed from the bytes served, and symbolic links are rejected. Only immediate subdirectories + /// are considered skills; a SKILL.md nested deeper inside a skill is one of that skill's files. + /// + /// + /// A subdirectory without a SKILL.md is not a skill and is ignored, so shared assets or documentation can + /// live alongside skills. A subdirectory with a SKILL.md is a skill, and a skill that fails validation is + /// an error rather than a skipped entry, so that a broken skill is noticed at startup instead of being silently + /// absent from the catalog. + /// + /// + /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not + /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core + /// authorization filters. When some callers must not see some skills, implement + /// and use , and guard + /// the corresponding file resources separately. + /// + /// + public static IMcpServerBuilder WithSkillsFromDirectory( + this IMcpServerBuilder builder, + string directoryPath, + string uriPrefix = "skill://", + Action? configure = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(directoryPath); + ArgumentNullException.ThrowIfNull(uriPrefix); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); + if (uriPrefix is null) throw new ArgumentNullException(nameof(uriPrefix)); +#endif + + if (uriPrefix.Length == 0) + { + throw new ArgumentException("The URI prefix must not be empty.", nameof(uriPrefix)); + } + + if (!uriPrefix.EndsWith("/", StringComparison.Ordinal)) + { + uriPrefix += "/"; + } + + string fullDirectory = Path.GetFullPath(directoryPath); + if (!Directory.Exists(fullDirectory)) + { + throw new DirectoryNotFoundException($"The skills directory '{fullDirectory}' does not exist."); + } + + var skillDirectories = new List(Directory.EnumerateDirectories(fullDirectory)); + skillDirectories.Sort(StringComparer.Ordinal); + + var skills = new List(); + foreach (string skillDirectory in skillDirectories) + { + // The per-skill loader rejects links inside a skill; the same rule applies to the skill directory + // itself, which could otherwise be a link to a directory outside the skills root. + if ((File.GetAttributes(skillDirectory) & FileAttributes.ReparsePoint) != 0) + { + throw new ArgumentException( + $"'{skillDirectory}' is a symbolic link or other reparse point. Links are not followed when loading skills, " + + "because a link can point outside the skills directory. Replace it with a regular directory.", + nameof(directoryPath)); + } + + if (!File.Exists(Path.Combine(skillDirectory, SkillsProtocol.SkillFileName))) + { + continue; + } + + skills.Add(McpServerSkill.CreateFromDirectory(skillDirectory, uriPrefix, nameof(directoryPath))); + } + + if (skills.Count == 0) + { + throw new ArgumentException( + $"No skill directories were found under '{fullDirectory}'. Each skill must be an immediate subdirectory containing a {SkillsProtocol.SkillFileName}.", + nameof(directoryPath)); + } + + return WithSkills(builder, skills, configure); + } /// /// Enables MCP Skills support backed by the specified catalog. /// /// The server builder. /// The catalog supplying the skills this server serves. - /// A callback that configures the extension's behavior. + /// An optional callback that configures the extension's behavior. /// The builder provided in . + /// or is . /// /// - /// Declaring the extension commits the server to both skills/list and skills/get. Both are - /// registered here. + /// 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. /// /// - /// This registers the protocol methods only. Serving the skills' file content remains the ordinary - /// responsibility of the server's resources, so register the skill files as resources as well. + /// skills/list and skills/get are registered as raw request handlers and do not pass through the + /// request filters that guard the built-in resource methods, such as the ASP.NET Core authorization filters. + /// When some callers must not see some skills, the catalog decides from the + /// it receives, and the corresponding file resources must be guarded separately. /// /// public static IMcpServerBuilder WithSkills( this IMcpServerBuilder builder, IMcpSkillCatalog catalog, - Action configure) + Action? configure = null) { #if NET ArgumentNullException.ThrowIfNull(builder); ArgumentNullException.ThrowIfNull(catalog); - ArgumentNullException.ThrowIfNull(configure); #else if (builder is null) throw new ArgumentNullException(nameof(builder)); if (catalog is null) throw new ArgumentNullException(nameof(catalog)); - if (configure is null) throw new ArgumentNullException(nameof(configure)); #endif var options = new McpSkillsOptions(); - configure(options); + configure?.Invoke(options); builder.Services.AddSingleton>( _ => new McpSkillsConfigureOptions(catalog, options)); @@ -67,6 +268,12 @@ private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkil { 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)) @@ -74,6 +281,10 @@ public void Configure(McpServerOptions options) 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 { @@ -82,72 +293,108 @@ public void Configure(McpServerOptions options) }); 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, - // RoutingNameParameter is deliberately left unset. Setting it to "uri" would mirror how - // Core routes the built-in resources/read, but it also makes the Mcp-Name header mandatory - // on Streamable HTTP. SEP-2640 defines no such requirement, so a conforming client does not - // send it and every skills/get request is rejected with 400. Verified against the SEP-2640 - // conformance scenarios. Handler = HandleGetSkillAsync, }); } - private async ValueTask HandleListSkillsAsync( - JsonRpcRequest request, - CancellationToken cancellationToken) + private async ValueTask HandleListSkillsAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - string? cursor = request.Params?["cursor"]?.GetValue(); - var page = await catalog.ListAsync(cursor, cancellationToken).ConfigureAwait(false); + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.ListSkillsRequestParams); + var page = await catalog.ListAsync(requestParams?.Cursor, new McpSkillRequestContext(request), cancellationToken).ConfigureAwait(false); + foreach (var entry in page.Skills) + { + ValidateCatalogEntry(entry); + } var result = new ListSkillsResult { Skills = [.. page.Skills], NextCursor = page.NextCursor, - ResultType = "complete", }; - if (ShouldEmitCacheAttributes(request)) + // 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.TimeToLive = skillsOptions.TimeToLive; - result.CacheScope = skillsOptions.CacheScope; + 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) + private async ValueTask HandleGetSkillAsync(JsonRpcRequest request, CancellationToken cancellationToken) { - string? uri = request.Params?["uri"]?.GetValue(); - if (string.IsNullOrEmpty(uri)) + var requestParams = DeserializeParams(request, McpSkillsJsonContext.Default.GetSkillRequestParams); + if (string.IsNullOrEmpty(requestParams?.Uri)) { - throw new McpProtocolException("'uri' is required.", McpErrorCode.InvalidParams); + throw new McpProtocolException("The 'uri' parameter is required.", McpErrorCode.InvalidParams); } - var skill = await catalog.GetAsync(uri!, cancellationToken).ConfigureAwait(false); - if (skill is null) + 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($"Unknown skill '{uri}'.", McpErrorCode.InvalidParams); + throw new McpProtocolException( + $"The skill catalog answered a request for '{requestParams.Uri}' with the entry for '{skill.Uri}'.", + McpErrorCode.InternalError); } - var result = new GetSkillResult + var result = new GetSkillResult { Skill = skill }; + if (IsJuly2026OrLaterProtocolRequest(request)) { - Skill = skill, - ResultType = "complete", - }; + result.ResultType = "complete"; + } return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); } - private bool ShouldEmitCacheAttributes(JsonRpcRequest request) + /// + /// 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) { - if (!skillsOptions.GateCacheAttributesByProtocolVersion) + try { - return true; + SkillValidation.Validate(entry!, "entry"); + } + catch (ArgumentException e) + { + throw new McpProtocolException($"The skill catalog returned an invalid entry: {e.Message}", McpErrorCode.InternalError); + } + } + + private static T? DeserializeParams(JsonRpcRequest request, JsonTypeInfo typeInfo) where T : class + { + if (request.Params is null) + { + return null; } - return McpProtocolVersions.IsJuly2026OrLaterProtocolVersion(request.Context?.ProtocolVersion); + 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 index 63a5f4402..c0f2ca926 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs @@ -8,34 +8,34 @@ namespace ModelContextProtocol.Extensions.Skills; public sealed class McpSkillsOptions { /// - /// Gets or sets a value indicating whether skills/list results carry the base protocol's - /// list-caching attributes (ttlMs and cacheScope) only when the negotiated protocol - /// version is 2026-07-28 or later. + /// Gets or sets the freshness hint advertised on skills/list results. /// /// /// - /// SEP-2640 scopes those attributes to protocol versions 2026-07-28 and later, so gating is the - /// conservative reading and the default. The condition is under active discussion in the working group - /// however: the Go SDK and the specification restructure both dropped it independently, and it may be - /// removed from the SEP rather than added to implementations. Set this to to - /// emit the attributes on every negotiated version. + /// 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 bool GateCacheAttributesByProtocolVersion { get; set; } = true; - - /// - /// Gets or sets the freshness hint advertised on skills/list results, or - /// to advertise none. - /// public TimeSpan? TimeToLive { get; set; } /// - /// Gets or sets the cache scope advertised on skills/list results, or - /// to advertise none. + /// Gets or sets the cache scope advertised on skills/list results. /// /// - /// Set this to a public scope only when the catalog is identical for every caller. A listing that varies - /// by principal must not be advertised as publicly cacheable. + /// + /// Set this to only when the catalog is identical for every caller. + /// A listing that varies by principal must not be advertised as publicly cacheable. + /// + /// + /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that + /// revision or later, a value is sent as , + /// matching what the SDK does for the built-in list methods. On earlier revisions the attribute is not sent. + /// /// public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs new file mode 100644 index 000000000..2f38cefe0 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -0,0 +1,1301 @@ +using System.Globalization; +using System.Numerics; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Reads the YAML frontmatter of a SKILL.md into a , without a YAML library. +/// Servers use it to publish a skill's entry from its file; hosts can use it to compare a fetched SKILL.md against the entry. +/// +/// +/// +/// Agent Skills frontmatter is a small, regular subset of YAML: a block mapping of scalars, with at most a nested +/// mapping (metadata) and the occasional sequence. This reader accepts that subset deliberately and +/// rejects everything else with a naming the construct, so that a skill whose +/// frontmatter it cannot represent faithfully is never published with a guessed rendering. Anchors, aliases, +/// tags, multi-document streams, complex keys, multi-line flow collections, 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 = "---"; + + /// YAML's white space characters. Other Unicode spaces, such as U+00A0, are scalar content. + private static readonly char[] s_yamlWhitespace = [' ', '\t']; + + /// The deepest nesting of block collections the reader accepts. Frontmatter is shallow; this only exists to keep a hostile file from exhausting the stack. + private const int MaxNestingDepth = 32; + + /// + /// Thrown 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); + + /// Thrown when a quoted scalar has no closing quote on its line. Malformed unless a later line closes it. + private sealed class UnterminatedQuoteException(string message, char quote) : FormatException(message) + { + public char Quote { get; } = quote; + } + + /// Thrown when a flow collection has no closing bracket on its line. Malformed unless a later line closes it. + private sealed class UnterminatedFlowException(string message, char closer) : FormatException(message) + { + public char Closer { get; } = closer; + } + + /// + /// Parses the frontmatter at the start of . + /// + /// The full text of a SKILL.md. + /// The frontmatter as a JSON object. + /// + /// The text does not begin with a ----delimited frontmatter block, the block is not a mapping, or it + /// uses YAML this reader does not support. + /// + public static JsonObject Parse(string skillMarkdown) + { + var lines = SplitLines(skillMarkdown); + if (lines.Count == 0 || lines[0].TrimEnd(s_yamlWhitespace) != Delimiter) + { + throw new FormatException($"{SkillsProtocol.SkillFileName} must begin with a line containing only '{Delimiter}' that opens the YAML frontmatter."); + } + + int end = -1; + for (int i = 1; i < lines.Count; i++) + { + string trimmed = lines[i].TrimEnd(s_yamlWhitespace); + if (trimmed == Delimiter || trimmed == "...") + { + end = i; + break; + } + } + + if (end < 0) + { + throw new FormatException($"The YAML frontmatter of {SkillsProtocol.SkillFileName} is not closed by a line containing only '{Delimiter}'."); + } + + var body = new List(end - 1); + for (int i = 1; i < end; i++) + { + 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++; + } + + // Tabs cannot indent block structure, but they may separate a value from its indicator and are content + // inside block scalars, so a leading tab only marks the line here; the parser rejects it where it matters. + int contentStart = indent; + while (contentStart < raw.Length && raw[contentStart] is ' ' or '\t') + { + contentStart++; + } + + Indent = indent; + HasTabIndentation = contentStart != indent; + Content = raw.Substring(contentStart); + IsBlank = Content.Length == 0 || Content[0] == '#'; + } + + public string Raw { get; } + public int Number { get; } + public int Indent { get; } + public string Content { get; } + + /// Whether a tab appears in the line's leading white space, which YAML does not allow for indentation. + public bool HasTabIndentation { get; } + + /// Whether the line is empty or a comment, and therefore structurally insignificant. + public bool IsBlank { get; } + } + + private sealed class Parser(List lines) + { + private readonly List _lines = lines; + private int _pos; + private int _depth; + + 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]; + if (_depth >= MaxNestingDepth) + { + throw new FormatException($"Line {line.Number}: the frontmatter is nested more than {MaxNestingDepth} levels deep."); + } + + _depth++; + try + { + return IsSequenceEntry(line.Content) ? ParseSequence(indent) : ParseMapping(indent); + } + finally + { + _depth--; + } + } + + private static bool IsSequenceEntry(string content) => + content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] is ' ' or '\t'); + + /// + /// Whether a sequence item's text is a "key: value" line, with a plain or quoted key, that begins a nested + /// block mapping. + /// + private static bool IsCompactMappingEntry(string item, int lineNumber) + { + if (item[0] is '"' or '\'') + { + try + { + ParseQuotedScalar(item, 0, lineNumber, out int afterQuote); + int colon = SkipSpaces(item, afterQuote); + return colon < item.Length && item[colon] == ':' && (colon + 1 == item.Length || item[colon + 1] is ' ' or '\t'); + } + catch (FormatException) + { + // Not a well-formed quoted key; the item is parsed as a value and reported there. + return false; + } + } + + return item[0] is not ('[' or '{' or '|' or '>' or '&' or '*' or '!') && FindKeySeparator(item) >= 0; + } + + private static void ThrowIfTabIndented(Line line) + { + if (line.HasTabIndentation) + { + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + } + + /// + /// Rejects a plain scalar that starts with a character YAML gives another meaning: an anchor, alias, or tag + /// (valid YAML this reader does not support), a reserved character, a flow indicator, a block scalar header, + /// or a block indicator followed by white space. + /// + private static void ThrowIfReservedPlainStart(string text, int lineNumber) + { + if (text.Length == 0) + { + return; + } + + char c = text[0]; + switch (c) + { + case '&' or '*' or '!': + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + + case '@' or '`' or '%': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reserves. Quote the value."); + + case ']' or '}' or ',': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with the flow indicator '{c}'. Quote the value."); + + case '|' or '>': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reads as a block scalar header. Quote the value."); + + case '-' or '?' or ':' when text.Length == 1 || text[1] is ' ' or '\t': + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}' followed by white space, which YAML reads as a block indicator. Quote the value."); + } + } + + private JsonObject ParseMapping(int indent) + { + var result = new JsonObject(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + ThrowIfTabIndented(line); + if (IsSequenceEntry(line.Content)) + { + throw new FormatException($"Line {line.Number}: a sequence entry cannot appear directly inside a mapping. Put it under a key."); + } + + string content = line.Content; + if (content[0] == '?' && (content.Length == 1 || content[1] is ' ' or '\t')) + { + throw new UnsupportedYamlException($"Line {line.Number}: complex mapping keys ('? ') are not supported in frontmatter."); + } + + if (content[0] is '[' or '{') + { + throw new UnsupportedYamlException($"Line {line.Number}: flow collections are not supported as mapping keys in frontmatter."); + } + + int consumed; + string key; + if (content[0] is '"' or '\'') + { + key = ParseQuotedScalar(content, 0, line.Number, out consumed); + int colon = SkipSpaces(content, consumed); + if (colon >= content.Length || content[colon] != ':' || (colon + 1 < content.Length && content[colon + 1] is not (' ' or '\t'))) + { + throw new FormatException($"Line {line.Number}: expected ':' after the quoted key."); + } + + consumed = colon + 1; + } + else + { + int separator = FindKeySeparator(content); + if (separator < 0) + { + throw new FormatException($"Line {line.Number}: expected a 'key: value' pair."); + } + + string rawKey = content.Substring(0, separator).TrimEnd(s_yamlWhitespace); + if (rawKey.Length == 0) + { + throw new FormatException($"Line {line.Number}: empty mapping key."); + } + + ThrowIfReservedPlainStart(content, line.Number); + + // Keys resolve like values: "TRUE:" is the key "true" and "0x10:" is "16" to a YAML parser. + key = KeyToString(ResolvePlainScalar(rawKey, line.Number)); + consumed = separator + 1; + } + + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {line.Number}: duplicate key '{key}'."); + } + + string rest = PrepareValue(content.Substring(consumed), out bool commentEndedValue); + _pos++; + result[key] = ParseValue(rest, commentEndedValue, indent, line.Number, allowSameIndentSequence: true); + } + + return result; + } + + private JsonArray ParseSequence(int indent) + { + var result = new JsonArray(); + while (true) + { + SkipBlank(); + if (_pos >= _lines.Count) + { + break; + } + + var line = _lines[_pos]; + if (line.Indent < indent || (line.Indent == indent && !IsSequenceEntry(line.Content))) + { + break; + } + + if (line.Indent > indent) + { + throw new FormatException($"Line {line.Number}: unexpected indentation."); + } + + ThrowIfTabIndented(line); + // Everything after the '-', white space included, so that the item's column is known exactly. + string item = line.Content.Substring(1); + _pos++; + + string trimmedItem = item.TrimStart(s_yamlWhitespace); + if (trimmedItem.Length == 0 || trimmedItem[0] == '#') + { + // "- " followed by nothing: the value is the more-indented block that follows, or null. + var next = PeekSignificant(); + result.Add(next is { } n && n.Indent > indent ? ParseBlock(n.Indent) : null); + continue; + } + + int itemIndent = indent + 1 + (item.Length - trimmedItem.Length); + if (IsSequenceEntry(trimmedItem) || IsCompactMappingEntry(trimmedItem, line.Number)) + { + if (item.IndexOf('\t', 0, item.Length - trimmedItem.Length) >= 0) + { + // The nested node's indentation would be set by a tab. + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + // A compact nested node ("- key: value" or "- - x"): re-read this line as if the item started on + // its own line at the item's column, then continue with the block at that indentation. + _pos--; + _lines[_pos] = new Line(new string(' ', itemIndent) + trimmedItem, line.Number); + result.Add(ParseBlock(itemIndent)); + continue; + } + + string itemValue = PrepareValue(trimmedItem, out bool itemCommentEndedValue); + result.Add(ParseValue(itemValue, itemCommentEndedValue, indent, line.Number, allowSameIndentSequence: false)); + } + + return result; + } + + /// + /// Parses the value that follows a key or sequence dash. is the remainder of the + /// line, with comments removed; the line itself has already been consumed. + /// + private JsonNode? ParseValue(string rest, bool commentEndedValue, int parentIndent, int lineNumber, bool allowSameIndentSequence) + { + if (rest.Length == 0) + { + var next = PeekSignificant(); + if (next is { } n) + { + if (n.Indent > parentIndent) + { + return ParseBlock(n.Indent); + } + + if (allowSameIndentSequence && n.Indent == parentIndent && IsSequenceEntry(n.Content)) + { + return ParseSequence(parentIndent); + } + } + + return null; + } + + switch (rest[0]) + { + case '|' or '>': + return ParseBlockScalar(rest, parentIndent, lineNumber); + + case '&' or '*' or '!': + throw new UnsupportedYamlException($"Line {lineNumber}: YAML anchors, aliases, and tags are not supported in frontmatter."); + + case '[' or '{': + try + { + return rest[0] == '[' ? ParseFlowSequence(rest, lineNumber) : ParseFlowMapping(rest, lineNumber); + } + catch (UnterminatedFlowException e) when (ClosesLater(e.Closer)) + { + // Valid YAML (a flow collection spanning lines) that this reader does not support, as + // opposed to a collection that is never closed, which is malformed. + throw new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter."); + } + + case '"' or '\'': + string quoted; + int consumed; + try + { + quoted = ParseQuotedScalar(rest, 0, lineNumber, out consumed); + } + catch (UnterminatedQuoteException e) when (ClosesLater(e.Quote)) + { + // Valid YAML (a quoted scalar spanning lines) that this reader does not support, as opposed + // to a quote that is never closed, which is malformed and stays a plain FormatException. + throw new UnsupportedYamlException($"Line {lineNumber}: multi-line quoted scalars are not supported in frontmatter."); + } + + if (StripComment(rest.Substring(consumed)).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value."); + } + + return JsonValue.Create(quoted); + + default: + if (IsSequenceEntry(rest)) + { + throw new FormatException($"Line {lineNumber}: a sequence cannot start on the same line as its key. Put the '- ' entries on the following lines, or quote the value if '-' is meant literally."); + } + + ThrowIfReservedPlainStart(rest, lineNumber); + if (FindKeySeparator(rest) >= 0) + { + throw new FormatException($"Line {lineNumber}: a plain scalar cannot contain ': '. Quote the value if it is meant literally."); + } + + string plain = commentEndedValue ? rest : CollectPlainContinuation(rest, parentIndent, lineNumber, out commentEndedValue); + + // A comment ends a plain scalar, on whichever of its lines it appears. A more-indented line after + // it cannot continue the scalar and cannot start a nested block either, so it is an error, as it + // is to a YAML parser. + if (commentEndedValue && PeekSignificant() is { } following && following.Indent > parentIndent) + { + throw new FormatException( + $"Line {following.Number}: this line cannot continue the value that started on line {lineNumber}, because a comment ended that value."); + } + + return ResolvePlainScalar(plain, lineNumber); + } + } + + private bool ClosesLater(char quote) + { + for (int i = _pos; i < _lines.Count; i++) + { + if (_lines[i].Raw.IndexOf(quote) >= 0) + { + return true; + } + } + + return false; + } + + /// + /// Folds the more-indented continuation lines of a plain multi-line scalar into it. A trailing comment on a + /// continuation line ends the scalar, which reports. + /// + private string CollectPlainContinuation(string first, int parentIndent, int lineNumber, out bool commentEndedValue) + { + commentEndedValue = false; + var builder = new StringBuilder(first); + int pendingNewlines = 0; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + pendingNewlines++; + _pos++; + continue; + } + + if (line.Indent <= parentIndent || line.Content[0] == '#') + { + break; + } + + string stripped = StripComment(line.Content); + string text = stripped.Trim(s_yamlWhitespace); + if (text.Length == 0) + { + break; + } + + if (FindKeySeparator(text) >= 0) + { + throw new FormatException( + $"Line {line.Number}: a plain scalar cannot contain ': '. If this line is meant to be a key, check its indentation; " + + $"the value that started on line {lineNumber} continues onto any more-indented line."); + } + + builder.Append(pendingNewlines > 0 ? new string('\n', pendingNewlines) : " "); + pendingNewlines = 0; + builder.Append(text); + _pos++; + + if (stripped.Length != line.Content.Length) + { + commentEndedValue = true; + break; + } + } + + // Trailing blank lines belong to whatever follows, so give them back. + _pos -= pendingNewlines; + return builder.ToString(); + } + + private JsonNode? ParseBlockScalar(string header, int parentIndent, int lineNumber) + { + bool literal = header[0] == '|'; + char chomping = 'c'; + int explicitIndent = 0; + int headerPos = 1; + for (; headerPos < header.Length && header[headerPos] != ' '; headerPos++) + { + char c = header[headerPos]; + if (c is '-' or '+') + { + if (chomping != 'c') + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated chomping indicator."); + } + + chomping = c == '-' ? 's' : 'k'; + } + else if (c is >= '1' and <= '9') + { + if (explicitIndent != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': repeated indentation indicator."); + } + + explicitIndent = c - '0'; + } + else + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}'."); + } + } + + if (StripComment(header.Substring(headerPos)).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: invalid block scalar header '{header}': only a comment may follow the indicators."); + } + + // Gather the raw lines of the block: everything blank, plus everything indented more than the parent. + var raw = new List(); + int contentIndent = explicitIndent > 0 ? parentIndent + explicitIndent : -1; + int widestLeadingEmptyLine = 0; + while (_pos < _lines.Count) + { + var line = _lines[_pos]; + if (line.Content.Length == 0) + { + // A white-space-only line is empty unless it reaches past the content indentation, in which case + // the excess is content. Until the indentation is known, the widest such line is remembered: + // YAML rejects a leading empty line wider than the first content line. A tab short of the + // content indentation is neither indentation nor content. + if (line.HasTabIndentation && (contentIndent < 0 || line.Indent < contentIndent)) + { + throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); + } + + if (contentIndent < 0) + { + widestLeadingEmptyLine = Math.Max(widestLeadingEmptyLine, line.Raw.Length); + raw.Add(string.Empty); + } + else + { + raw.Add(line.Raw.Length > contentIndent ? line.Raw.Substring(contentIndent) : string.Empty); + } + + _pos++; + continue; + } + + if (line.Indent <= parentIndent) + { + break; + } + + if (contentIndent < 0) + { + contentIndent = line.Indent; + if (widestLeadingEmptyLine > contentIndent) + { + throw new FormatException( + $"Line {line.Number}: a leading empty line of the block scalar is indented more than its first content line. Use an explicit indentation indicator, or remove the extra white space."); + } + } + else if (line.Indent < contentIndent) + { + break; + } + + raw.Add(line.Raw.Substring(contentIndent)); + _pos++; + } + + // Trailing blank lines are subject to chomping; count and remove them. + int trailing = 0; + while (raw.Count > 0 && raw[raw.Count - 1].Length == 0) + { + raw.RemoveAt(raw.Count - 1); + trailing++; + } + + var builder = new StringBuilder(); + if (literal) + { + for (int i = 0; i < raw.Count; i++) + { + if (i > 0) + { + builder.Append('\n'); + } + + builder.Append(raw[i]); + } + } + else + { + // Folded: adjacent non-empty, non-indented lines join with a space; empty lines become newlines; + // more-indented lines keep their line breaks. + bool previousMoreIndented = false; + int emptyRun = 0; + for (int i = 0; i < raw.Count; i++) + { + string text = raw[i]; + if (text.Length == 0) + { + emptyRun++; + continue; + } + + bool moreIndented = text[0] == ' '; + if (builder.Length == 0) + { + // Leading empty lines are content: each becomes a line break. + builder.Append('\n', emptyRun); + } + else if (emptyRun > 0) + { + builder.Append('\n', emptyRun + (moreIndented || previousMoreIndented ? 1 : 0)); + } + else + { + builder.Append(moreIndented || previousMoreIndented ? '\n' : ' '); + } + + emptyRun = 0; + builder.Append(text); + previousMoreIndented = moreIndented; + } + } + + if (builder.Length > 0 || trailing > 0) + { + switch (chomping) + { + case 'c' when builder.Length > 0: + builder.Append('\n'); + break; + case 'k': + builder.Append('\n', builder.Length > 0 ? trailing + 1 : trailing); + break; + } + } + + return JsonValue.Create(builder.ToString()); + } + + private static JsonArray ParseFlowSequence(string text, int lineNumber) + { + var result = new JsonArray(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); + } + + if (text[pos] == ']') + { + pos++; + break; + } + + if (text[pos] == ',') + { + throw new FormatException($"Line {lineNumber}: unexpected ',' in flow sequence; every entry needs a value."); + } + + result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]")); + pos = SkipSpaces(text, pos); + if (pos < text.Length && text[pos] == ':') + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); + } + + if (text[pos] == ',') + { + pos++; + } + else if (text[pos] != ']') + { + throw new FormatException($"Line {lineNumber}: expected ',' or ']' in flow sequence."); + } + } + + if (StripComment(text.Substring(pos)).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow sequence."); + } + + return result; + } + + private static JsonObject ParseFlowMapping(string text, int lineNumber) + { + var result = new JsonObject(); + int pos = 1; + while (true) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); + } + + if (text[pos] == '}') + { + pos++; + break; + } + + if (text[pos] == ',') + { + throw new FormatException($"Line {lineNumber}: unexpected ',' in flow mapping; every entry needs a key."); + } + + JsonNode? keyNode; + bool hasValue; + if (text[pos] is '"' or '\'') + { + keyNode = JsonValue.Create(ParseQuotedScalar(text, pos, lineNumber, out int afterQuote)); + pos = SkipSpaces(text, afterQuote); + hasValue = pos < text.Length && text[pos] == ':'; + if (hasValue) + { + pos++; + } + } + else + { + // A plain key ends at ',' or '}' (giving it a null value) or at a ':' that is followed by a + // space, a comma, a closing brace, or the end of the text. A ':' followed by anything else is + // part of the key, so "{version:1}" is the key "version:1" with a null value. + int start = pos; + int colonAt = -1; + while (pos < text.Length && text[pos] is not (',' or '}')) + { + if (text[pos] == ':' && (pos + 1 == text.Length || text[pos + 1] is ' ' or '\t' or ',' or '}')) + { + colonAt = pos; + break; + } + + 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 brace."); + } + + pos++; + } + + string rawKey = text.Substring(start, pos - start).Trim(s_yamlWhitespace); + if (rawKey.Length > 0 && rawKey[0] is '[' or '{') + { + throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + } + + ThrowIfReservedPlainStart(rawKey, lineNumber); + keyNode = ResolvePlainScalar(rawKey, lineNumber); + hasValue = colonAt >= 0; + if (hasValue) + { + pos = colonAt + 1; + } + } + + string key = KeyToString(keyNode); + if (result.ContainsKey(key)) + { + throw new FormatException($"Line {lineNumber}: duplicate key '{key}'."); + } + + result[key] = hasValue ? ParseFlowScalar(text, ref pos, lineNumber, ",}") : null; + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); + } + + if (text[pos] == ',') + { + pos++; + } + else if (text[pos] != '}') + { + throw new FormatException($"Line {lineNumber}: expected ',' or '}}' in flow mapping."); + } + } + + if (StripComment(text.Substring(pos)).Trim(s_yamlWhitespace).Length != 0) + { + throw new FormatException($"Line {lineNumber}: unexpected content after the flow mapping."); + } + + return result; + } + + private static JsonNode? ParseFlowScalar(string text, ref int pos, int lineNumber, string terminators) + { + pos = SkipSpaces(text, pos); + if (pos >= text.Length) + { + throw new FormatException($"Line {lineNumber}: unexpected end of flow collection."); + } + + char c = text[pos]; + if (c is '[' or '{') + { + throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); + } + + if (c is '"' or '\'') + { + string quoted = ParseQuotedScalar(text, pos, lineNumber, out int consumed); + pos = consumed; + return JsonValue.Create(quoted); + } + + int start = pos; + while (pos < text.Length && terminators.IndexOf(text[pos]) < 0) + { + if (text[pos] == '#' && (pos == start || text[pos - 1] is ' ' or '\t')) + { + throw new FormatException($"Line {lineNumber}: a comment inside a flow collection runs to the end of the line, leaving the collection unterminated. Move the comment after the closing bracket."); + } + + pos++; + } + + string plain = text.Substring(start, pos - start).Trim(s_yamlWhitespace); + if (FindKeySeparator(plain) >= 0) + { + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); + } + + ThrowIfReservedPlainStart(plain, lineNumber); + return ResolvePlainScalar(plain, lineNumber); + } + + /// Parses a quoted scalar starting at ; returns the index after the closing quote. + private static string ParseQuotedScalar(string text, int start, int lineNumber, out int end) + { + char quote = text[start]; + var builder = new StringBuilder(); + int i = start + 1; + while (i < text.Length) + { + char c = text[i]; + if (c == quote) + { + if (quote == '\'' && i + 1 < text.Length && text[i + 1] == '\'') + { + builder.Append('\''); + i += 2; + continue; + } + + end = i + 1; + string result = builder.ToString(); + for (int k = 0; k < result.Length; k++) + { + if (char.IsHighSurrogate(result[k]) && k + 1 < result.Length && char.IsLowSurrogate(result[k + 1])) + { + k++; + } + else if (char.IsSurrogate(result[k])) + { + throw new FormatException($"Line {lineNumber}: the quoted scalar contains an unpaired surrogate escape, which is not a valid Unicode character."); + } + } + + return result; + } + + if (quote == '"' && c == '\\') + { + if (++i >= text.Length) + { + break; + } + + char e = text[i]; + switch (e) + { + case '0': builder.Append('\0'); break; + case 'a': builder.Append('\a'); break; + case 'b': builder.Append('\b'); break; + case 't' or '\t': builder.Append('\t'); break; + case 'n': builder.Append('\n'); break; + case 'v': builder.Append('\v'); break; + case 'f': builder.Append('\f'); break; + case 'r': builder.Append('\r'); break; + case 'e': builder.Append('\u001B'); break; + case ' ': builder.Append(' '); break; + case '"': builder.Append('"'); break; + case '/': builder.Append('/'); break; + case '\\': builder.Append('\\'); break; + case 'N': builder.Append('\u0085'); break; + case '_': builder.Append('\u00A0'); break; + case 'L': builder.Append('\u2028'); break; + case 'P': builder.Append('\u2029'); break; + case 'x': builder.Append((char)ParseHex(text, i + 1, 2, lineNumber)); i += 2; break; + case 'u': builder.Append((char)ParseHex(text, i + 1, 4, lineNumber)); i += 4; break; + case 'U': + int scalar = ParseHex(text, i + 1, 8, lineNumber); + if (scalar is < 0 or > 0x10FFFF or (>= 0xD800 and <= 0xDFFF)) + { + throw new FormatException($"Line {lineNumber}: '\\U{text.Substring(i + 1, 8)}' is not a valid Unicode scalar value."); + } + + builder.Append(char.ConvertFromUtf32(scalar)); + i += 8; + break; + default: + throw new FormatException($"Line {lineNumber}: unsupported escape sequence '\\{e}' in double-quoted scalar."); + } + + i++; + continue; + } + + builder.Append(c); + i++; + } + + throw new UnterminatedQuoteException($"Line {lineNumber}: unterminated quoted scalar.", quote); + } + + private static int ParseHex(string text, int start, int length, int lineNumber) + { + if (start + length > text.Length || + !int.TryParse(text.Substring(start, length), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture, out int value)) + { + throw new FormatException($"Line {lineNumber}: invalid hexadecimal escape in double-quoted scalar."); + } + + return value; + } + + /// + /// Renders a resolved key as a JSON object key the way reference parsers do: strings as themselves, null as + /// the empty string, and booleans and numbers as their JSON text. + /// + private static string KeyToString(JsonNode? key) => + key is null ? string.Empty + : key is JsonValue value && value.TryGetValue(out string? text) ? text + : key.ToJsonString(); + + /// Resolves an unquoted scalar per the YAML 1.2 core schema. + private static JsonNode? ResolvePlainScalar(string text, int lineNumber) + { + switch (text) + { + case "" or "~" or "null" or "Null" or "NULL": + return null; + case "true" or "True" or "TRUE": + return JsonValue.Create(true); + case "false" or "False" or "FALSE": + return JsonValue.Create(false); + } + + if (TryResolveNumber(text, lineNumber) is { } number) + { + return number; + } + + return JsonValue.Create(text); + } + + private static JsonNode? TryResolveNumber(string text, int lineNumber) + { + // Hexadecimal and octal integers take no sign in the core schema, and may exceed 64 bits. + if (text.StartsWith("0x", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, IsHexDigit)) + { + var value = BigInteger.Parse("0" + text.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + + if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7')) + { + BigInteger value = BigInteger.Zero; + for (int j = 2; j < text.Length; j++) + { + value = (value * 8) + (text[j] - '0'); + } + + return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + } + + int i = 0; + bool negative = false; + if (text[0] is '+' or '-') + { + negative = text[0] == '-'; + i = 1; + } + + if (i >= text.Length) + { + return null; + } + + string body = text.Substring(i); + + if (body is ".inf" or ".Inf" or ".INF" or ".nan" or ".NaN" or ".NAN") + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented in JSON."); + } + + // Integer: digits only. + if (IsAll(body, 0, IsDigit)) + { + string digits = body.TrimStart('0').PadLeft(1, '0'); + return JsonNode.Parse(negative && digits != "0" ? "-" + digits : digits); + } + + // Float: [digits][.digits][e[+-]digits], with at least one digit somewhere in the mantissa. + int mantissaEnd = 0; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + } + + int integerDigits = mantissaEnd; + int fractionDigits = 0; + if (mantissaEnd < body.Length && body[mantissaEnd] == '.') + { + mantissaEnd++; + while (mantissaEnd < body.Length && IsDigit(body[mantissaEnd])) + { + mantissaEnd++; + fractionDigits++; + } + } + + if (integerDigits + fractionDigits == 0) + { + return null; + } + + int exponentEnd = mantissaEnd; + if (exponentEnd < body.Length && body[exponentEnd] is 'e' or 'E') + { + exponentEnd++; + if (exponentEnd < body.Length && body[exponentEnd] is '+' or '-') + { + exponentEnd++; + } + + int exponentDigits = 0; + while (exponentEnd < body.Length && IsDigit(body[exponentEnd])) + { + exponentEnd++; + exponentDigits++; + } + + if (exponentDigits == 0) + { + return null; + } + } + + if (exponentEnd != body.Length) + { + return null; + } + + if (!double.TryParse(body, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) || double.IsInfinity(parsed)) + { + throw new FormatException($"Line {lineNumber}: '{text}' cannot be represented as a JSON number."); + } + + // JSON has no negative zero worth preserving: reference parsers render -0 and -0.0 as 0. + return JsonValue.Create(negative && parsed != 0 ? -parsed : parsed); + } + + private static bool IsDigit(char c) => c is >= '0' and <= '9'; + + private static bool IsHexDigit(char c) => c is (>= '0' and <= '9') or (>= 'a' and <= 'f') or (>= 'A' and <= 'F'); + + private static bool IsAll(string text, int start, Func predicate) + { + if (start >= text.Length) + { + return false; + } + + for (int i = start; i < text.Length; i++) + { + if (!predicate(text[i])) + { + return false; + } + } + + return true; + } + + private static int SkipSpaces(string text, int pos) + { + while (pos < text.Length && text[pos] is ' ' or '\t') + { + pos++; + } + + return pos; + } + + /// Finds the ':' that separates a plain key from its value: the first ':' followed by white 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] is ' ' or '\t')) + { + return i; + } + + if (content[i] is ' ' or '\t' && i + 1 < content.Length && content[i + 1] == '#') + { + // Anything after " #" is a comment; a key cannot be separated inside one. + return -1; + } + } + + return -1; + } + + /// + /// Prepares the remainder of a line for : quoted values and flow collections are + /// returned as written, since their own parsers find their end and check what follows; anything else is a + /// plain value, from which a trailing comment is removed. + /// + private static string PrepareValue(string rest, out bool commentEndedValue) + { + commentEndedValue = false; + rest = rest.TrimStart(s_yamlWhitespace); + if (rest.Length > 0 && rest[0] is '"' or '\'' or '[' or '{') + { + return rest.TrimEnd(s_yamlWhitespace); + } + + string stripped = StripComment(rest); + commentEndedValue = stripped.Length != rest.Length && stripped.Trim(s_yamlWhitespace).Length > 0; + return stripped.Trim(s_yamlWhitespace); + } + + /// + /// Removes a trailing comment from plain text. A comment starts at a '#' that begins the text or follows + /// whitespace; quotes inside plain text are ordinary characters, so an apostrophe never suppresses a comment. + /// + private static string StripComment(string text) + { + for (int i = 0; i < text.Length; i++) + { + if (text[i] == '#' && (i == 0 || text[i - 1] is ' ' or '\t')) + { + return text.Substring(0, i); + } + } + + return text; + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs new file mode 100644 index 000000000..cb3ac0168 --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -0,0 +1,345 @@ +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Validates skill entries against the structural requirements of the Skills extension specification, so that +/// a server never publishes an entry a conforming host would refuse to load. +/// +internal static class SkillValidation +{ + private const string SkillFileSuffix = "/" + SkillsProtocol.SkillFileName; + + /// + /// Returns the skill root (the SKILL.md URI with its /SKILL.md suffix removed), or throws if + /// does not end in /SKILL.md. + /// + public static string GetSkillRoot(string skillUri, string paramName) + { + if (string.IsNullOrEmpty(skillUri)) + { + throw new ArgumentException("A skill URI must not be null or empty.", paramName); + } + + if (!skillUri.EndsWith(SkillFileSuffix, StringComparison.Ordinal) || skillUri.Length == SkillFileSuffix.Length) + { + throw new ArgumentException( + $"The skill URI '{skillUri}' must be the URI of the skill's {SkillsProtocol.SkillFileName}, ending in '{SkillFileSuffix}'.", + paramName); + } + + return skillUri.Substring(0, skillUri.Length - SkillFileSuffix.Length); + } + + /// + /// Returns the final path segment of a skill root, which the specification requires to equal the skill's name. + /// + public static string GetNameSegment(string skillRoot) + { + int slash = skillRoot.LastIndexOf('/'); + return slash < 0 ? skillRoot : skillRoot.Substring(slash + 1); + } + + /// + /// Returns whether satisfies the Agent Skills naming rules: 1 to 64 characters of + /// Unicode lowercase (or caseless) letters, digits, and hyphens, with no leading, trailing, or consecutive hyphens. + /// + public static bool IsValidSkillName(string name) + { + if (name.Length is 0 or > 64) + { + return false; + } + + char previous = '-'; + foreach (char c in name) + { + bool isAlphanumeric = (char.IsLetter(c) && !char.IsUpper(c)) || char.IsDigit(c); + if (!isAlphanumeric && c != '-') + { + return false; + } + + if (c == '-' && previous == '-') + { + return false; + } + + previous = c; + } + + return previous != '-'; + } + + /// + /// Returns whether has the form sha256:{hex} with 64 lowercase hexadecimal digits. + /// + public static bool IsValidDigest(string? digest) + { + const int HexLength = 64; + if (digest is null || + digest.Length != SkillsProtocol.DigestPrefix.Length + HexLength || + !digest.StartsWith(SkillsProtocol.DigestPrefix, StringComparison.Ordinal)) + { + return false; + } + + for (int i = SkillsProtocol.DigestPrefix.Length; i < digest.Length; i++) + { + if (digest[i] is not ((>= '0' and <= '9') or (>= 'a' and <= 'f'))) + { + return false; + } + } + + return true; + } + + /// + /// Checks that is an absolute URI whose path has no empty, ., or .. + /// segments and no query or fragment, so that a prefix comparison against a skill root is meaningful. + /// + public static void ValidateUriShape(string uri, string what, string paramName) + { + int schemeEnd = uri.IndexOf("://", StringComparison.Ordinal); + if (schemeEnd <= 0 || !System.Uri.TryCreate(uri, UriKind.Absolute, out _)) + { + throw new ArgumentException($"{what} '{uri}' must be a syntactically valid absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); + } + + if (uri.IndexOf('?') >= 0 || uri.IndexOf('#') >= 0) + { + throw new ArgumentException($"{what} '{uri}' must not contain a query or fragment.", paramName); + } + + // Check the raw text rather than System.Uri's view of it, which decodes and compacts dot segments, so that + // "%2e%2e" is caught as well as "..". The authority (the first segment) may be empty, as in + // file:///name/SKILL.md; path segments may not. + string[] segments = uri.Substring(schemeEnd + 3).Split('/'); + for (int i = 0; i < segments.Length; i++) + { + string segment = segments[i]; + if (segment.Length == 0 && i > 0) + { + throw new ArgumentException($"{what} '{uri}' must not contain empty path segments.", paramName); + } + + string decoded = System.Uri.UnescapeDataString(segment); + if (decoded == "." || decoded == ".." || decoded.IndexOf('/') >= 0 || decoded.IndexOf('\\') >= 0) + { + throw new ArgumentException( + $"{what} '{uri}' must not contain '.', '..', or encoded separator path segments, which would resolve outside the skill.", + paramName); + } + } + } + + /// + /// Returns a copy of that shares no mutable state with it. + /// + public static Skill Snapshot(Skill skill) => new() + { + Uri = skill.Uri, + Frontmatter = (JsonObject)skill.Frontmatter.DeepClone(), + Resources = skill.Resources.IsDynamic + ? SkillResources.Dynamic + : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })), + }; + + /// + /// Validates an entry received from a server, throwing describing the + /// first violation found. Hosts must not load invalid entries, so an invalid entry is a verification failure. + /// + public static void ValidateReceived(Skill skill, string method) + { + try + { + Validate(skill, "skill"); + } + catch (ArgumentException e) + { + throw new SkillVerificationException($"The server returned an invalid skill entry from '{method}': {e.Message}", e); + } + } + + /// + /// Validates a complete skill entry, throwing describing the first violation found. + /// + public static void Validate(Skill skill, string paramName) + { + if (skill is null) + { + throw new ArgumentNullException(paramName); + } + + string root = GetSkillRoot(skill.Uri, paramName); + ValidateUriShape(skill.Uri, "The skill URI", paramName); + string nameSegment = GetNameSegment(root); + + if (skill.Frontmatter is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no frontmatter.", paramName); + } + + string? name = skill.Name; + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'name' in its frontmatter.", paramName); + } + + if (!IsValidSkillName(name!)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', which does not satisfy the Agent Skills naming rules " + + "(1 to 64 lowercase or caseless letters, digits, and single hyphens, not starting or ending with a hyphen).", + paramName); + } + + if (!string.Equals(name, nameSegment, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has the name '{name}', but the path segment preceding /{SkillsProtocol.SkillFileName} is '{nameSegment}'. " + + "The specification requires them to be equal.", + paramName); + } + + string? description = skill.Description; + if (string.IsNullOrEmpty(description)) + { + throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName); + } + + if (description!.Length > MaxDescriptionLength) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a description of {description.Length} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.", + paramName); + } + + // Beyond name and description, only the constraints the Agent Skills specification states as requirements + // are enforced: compatibility is 1 to 500 characters if provided, and metadata is a mapping. Other fields, + // and the values inside metadata, pass through verbatim. Hosts compare frontmatter field by field against + // the file and ignore fields such as allowed-tools for MCP-origin skills, so rejecting a whole skill over + // the shape of a field no host acts on would help nobody. + ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); + + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not JsonObject) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it key-value entries, or remove the key if it is not needed.", + paramName); + } + + if (skill.Resources is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest. Use SkillResources.Dynamic for generated content.", paramName); + } + + if (skill.Resources.IsDynamic) + { + return; + } + + var resources = skill.Resources.Resources!; + if (resources.Count == 0) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has an empty resources manifest. A manifest must list every file of the skill, {SkillsProtocol.SkillFileName} included.", + paramName); + } + + if (resources.Count > SkillsProtocol.MaxResourcesPerSkill) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists {resources.Count} files, exceeding the limit of {SkillsProtocol.MaxResourcesPerSkill} per skill.", + paramName); + } + + string rootPrefix = root + "/"; + var seen = new HashSet(StringComparer.Ordinal); + bool hasSkillFile = false; + long totalSize = 0; + + foreach (var resource in resources) + { + if (resource is null) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a null entry in its resources manifest.", paramName); + } + + if (string.IsNullOrEmpty(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' has a resource with a null or empty URI.", paramName); + } + + ValidateUriShape(resource.Uri, $"Skill '{skill.Uri}' lists the resource", paramName); + if (!resource.Uri.StartsWith(rootPrefix, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}', which is not within the skill's directory '{root}'.", + paramName); + } + + if (!seen.Add(resource.Uri)) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' more than once.", paramName); + } + + if (!IsValidDigest(resource.Digest)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with the digest '{resource.Digest}', " + + "which is not of the form 'sha256:' followed by 64 lowercase hexadecimal digits.", + paramName); + } + + if (resource.Size < 0) + { + throw new ArgumentException($"Skill '{skill.Uri}' lists the resource '{resource.Uri}' with a negative size.", paramName); + } + + if (resource.Size > SkillsProtocol.MaxTotalSizeBytes - totalSize) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' totals more than the limit of {SkillsProtocol.MaxTotalSizeBytes} bytes per skill.", + paramName); + } + + totalSize += resource.Size; + hasSkillFile |= string.Equals(resource.Uri, skill.Uri, StringComparison.Ordinal); + } + + if (!hasSkillFile) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' does not list its own {SkillsProtocol.SkillFileName} in its resources manifest.", + paramName); + } + + } + + private const int MaxDescriptionLength = 1024; + private const int MaxCompatibilityLength = 500; + + private static void ValidateOptionalString(Skill skill, string key, int? maxLength, string paramName) + { + if (!skill.Frontmatter.TryGetPropertyValue(key, out var node)) + { + return; + } + + if (node is not JsonValue value || !value.TryGetValue(out string? text)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string. Give it a value, or remove the key if it is not needed.", + paramName); + } + + if (text!.Length == 0 || (maxLength is { } max && text.Length > max)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {text.Length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.", + paramName); + } + } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs index 4b4e58d01..8940fffd2 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillsProtocol.cs @@ -3,10 +3,15 @@ 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. + /// 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"; @@ -21,49 +26,32 @@ public static class SkillsProtocol public const string MethodSkillsGet = "skills/get"; /// - /// The name of the optional request method sent from the client to list the direct children - /// of a directory resource. + /// The file name of the manifest every skill directory must contain at its root. /// - public const string MethodResourcesDirectoryRead = "resources/directory/read"; + public const string SkillFileName = "SKILL.md"; /// - /// The name of the capability setting indicating that the server implements - /// . + /// The maximum number of files a single skill may declare in its manifest, included. /// - public const string DirectoryReadSetting = "directoryRead"; - - /// - /// The reverse-domain prefix reserved by this extension for _meta keys on skill resources. - /// - public const string MetaPrefix = "io.modelcontextprotocol.skills/"; - - /// - /// The file name of the required skill manifest at the root of every skill directory. - /// - public const string SkillManifestFileName = "SKILL.md"; - - /// - /// The MIME type identifying a directory resource. - /// - public const string DirectoryMimeType = "inode/directory"; + /// + /// 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 MIME type recommended for a skill's SKILL.md resource. + /// The maximum total size in bytes of a single skill's files, summed over its manifest. /// - public const string SkillManifestMimeType = "text/markdown"; + /// + /// 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 sentinel used in place of a resource array when a skill's content is generated dynamically. - /// - public const string DynamicResourcesSentinel = "dynamic"; + /// The value of a skill's resources field when its content is generated dynamically. + internal const string DynamicResourcesSentinel = "dynamic"; - /// - /// The maximum number of resources a single skill may declare, per the SEP-2640 limits. - /// - public const int MaxResourcesPerSkill = 512; + /// The prefix of a manifest digest. + internal const string DigestPrefix = "sha256:"; - /// - /// The maximum total size in bytes of a single skill's resources, per the SEP-2640 limits. - /// - public const long MaxTotalSizeBytes = 16 * 1024 * 1024; + /// The MIME type recommended for a skill's SKILL.md resource. + internal const string SkillFileMimeType = "text/markdown"; } diff --git a/src/PACKAGE.md b/src/PACKAGE.md index a61dfd3ee..062f5a2ee 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -18,6 +18,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![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/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index 626891314..cf8096b18 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -19,11 +19,13 @@ + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index e71c6a6c9..80ae614c1 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 ModelContextProtocol.Tests.Utils; @@ -12,7 +13,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(); @@ -42,6 +50,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/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index a872a0875..328b523de 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -51,17 +51,16 @@ private static void ConfigureConformanceMcpServer( bool stateless) { services.AddDistributedMemoryCache(); - var conformanceSkills = new ConformanceSkills(); var mcpServerBuilder = services .AddMcpServer() .WithHttpTransport(options => options.Stateless = stateless) .WithDistributedCacheEventStreamStore() - .WithSkills(conformanceSkills.CreateCatalog(), options => + .WithSkills(ConformanceSkills.CreateCatalog(), options => { options.TimeToLive = TimeSpan.FromMinutes(5); options.CacheScope = CacheScope.Public; }) - .WithResources(conformanceSkills.CreateResources()) + .WithResources(ConformanceSkills.CreateResources()) .WithTasks( new InMemoryMcpTaskStore { diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs index 63f91d6d1..81fe15827 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -1,7 +1,5 @@ using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Server; -using System.Security.Cryptography; -using System.Text; using System.Text.Json.Nodes; namespace ModelContextProtocol.ConformanceServer.Skills; @@ -11,160 +9,89 @@ namespace ModelContextProtocol.ConformanceServer.Skills; /// /// /// -/// Digests and sizes are computed from the same byte buffers the resources serve, so a manifest can never -/// disagree with the content a host reads back. Skill files are registered as concrete resources rather -/// than through a URI template so that they appear in resources/list carrying the name, description, -/// and MIME type SEP-2640 asks for. +/// The three static skills are authored through , so their frontmatter is read from +/// their SKILL.md and their manifests are computed from the same bytes their resources serve. A fourth, +/// deliberately unenumerable skill is added to the catalog by hand: it is served and answerable through +/// skills/get, but carries no digests and so cannot be content-bound. +/// +/// +/// The page size is deliberately small so the scenarios exercise cursor pagination. /// /// -public sealed class ConformanceSkills +public static class ConformanceSkills { - private const string GitWorkflowManifest = - "---\nname: git-workflow\ndescription: Follow this team's Git conventions for branching and commits\n---\n\n# Git workflow\n"; - - private const string PdfProcessingManifest = - "---\nname: pdf-processing\ndescription: Extract, fill, and assemble PDF documents\n---\n\n# PDF processing\n"; - - private const string RefundsManifest = - "---\nname: refunds\ndescription: Process customer refund requests per company policy\n---\n\n# Refunds\n"; - - private const string GeneratedReportManifest = - "---\nname: generated-report\ndescription: Assemble a report from live data\n---\n\n# Generated report\n"; + private const string GeneratedReportUri = "skill://generated-report/SKILL.md"; - private readonly Dictionary _content = new(StringComparer.Ordinal); - private readonly Dictionary _manifestMetadata = new(StringComparer.Ordinal); - private readonly List _skills = []; - - public ConformanceSkills() - { - AddSkill( - "git-workflow", - "Follow this team's Git conventions for branching and commits", - [("SKILL.md", GitWorkflowManifest)]); - - AddSkill( - "pdf-processing", - "Extract, fill, and assemble PDF documents", + private static readonly McpServerSkill[] s_skills = + [ + McpServerSkill.Create( + "skill://git-workflow/SKILL.md", [ - ("SKILL.md", PdfProcessingManifest), - ("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"), - ("scripts/extract.py", "import sys\n\nprint('extract')\n"), - ("templates/invoice.md", "# Invoice\n"), - ("templates/regional/eu-invoice.md", "# EU Invoice\n"), - ]); + SkillFile("git-workflow", "Follow this team's Git conventions for branching and commits", "# Git workflow\n"), + ]), - AddSkill( - "acme/billing/refunds", - "Process customer refund requests per company policy", + McpServerSkill.Create( + "skill://pdf-processing/SKILL.md", + [ + SkillFile("pdf-processing", "Extract, fill, and assemble PDF documents", "# PDF processing\n"), + McpServerSkillFile.FromText("references/FORMS.md", "# Forms\n\nField reference for PDF form filling.\n"), + McpServerSkillFile.FromText("scripts/extract.py", "import sys\n\nprint('extract')\n"), + McpServerSkillFile.FromText("templates/invoice.md", "# Invoice\n"), + McpServerSkillFile.FromText("templates/regional/eu-invoice.md", "# EU Invoice\n"), + ]), + + McpServerSkill.Create( + "skill://acme/billing/refunds/SKILL.md", [ - ("SKILL.md", RefundsManifest), - ("examples/email.md", "Subject: Your refund\n"), - ]); + SkillFile("refunds", "Process customer refund requests per company policy", "# Refunds\n"), + McpServerSkillFile.FromText("examples/email.md", "Subject: Your refund\n"), + ]), + ]; - // A deliberately unenumerable skill: it is served and answerable through skills/get, but carries - // no digests and so cannot be content-bound. - _skills.Add(new SkillEntry - { - Uri = "skill://generated-report/SKILL.md", - Frontmatter = new JsonObject - { - ["name"] = "generated-report", - ["description"] = "Assemble a report from live data", - }, - Resources = SkillResources.Dynamic, - }); - _content["skill://generated-report/SKILL.md"] = Encoding.UTF8.GetBytes(GeneratedReportManifest); - _manifestMetadata["skill://generated-report/SKILL.md"] = - ("generated-report", "Assemble a report from live data"); - } + 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. /// - /// - /// The page size is deliberately small so the conformance scenarios exercise cursor pagination. - /// - public InMemoryMcpSkillCatalog CreateCatalog() => new(_skills, pageSize: 2); + public static IMcpSkillCatalog CreateCatalog() => + new InMemoryMcpSkillCatalog([.. s_skills.Select(s => s.ProtocolSkill), s_generatedReport], pageSize: 2); /// - /// Creates one concrete resource per skill file, so the files are enumerable through - /// resources/list and readable through resources/read. + /// Creates the resources serving every skill file, so the files are enumerable through resources/list + /// and readable through resources/read. /// - public IEnumerable CreateResources() - { - foreach (var entry in _content) - { - string uri = entry.Key; - string text = Encoding.UTF8.GetString(entry.Value); - bool isManifest = _manifestMetadata.TryGetValue(uri, out var metadata); - - yield return McpServerResource.Create( - () => text, - new McpServerResourceCreateOptions - { - UriTemplate = uri, - // SEP-2640: a SKILL.md resource SHOULD carry the name and description from its own - // frontmatter, so a host can build its registry from resources/list alone. - Name = isManifest ? metadata.Name : uri[(uri.LastIndexOf('/') + 1)..], - Description = isManifest ? metadata.Description : "Skill supporting file", - MimeType = isManifest ? SkillsProtocol.SkillManifestMimeType : GuessMimeType(uri), - }); - } - } - - private void AddSkill(string skillPath, string description, (string Path, string Body)[] files) + public static IEnumerable CreateResources() { - string name = skillPath.Split('/')[^1]; - var resources = new List(); - - foreach (var (path, body) in files) + foreach (var skill in s_skills) { - string uri = $"skill://{skillPath}/{path}"; - byte[] bytes = Encoding.UTF8.GetBytes(body); - _content[uri] = bytes; - if (string.Equals(path, SkillsProtocol.SkillManifestFileName, StringComparison.Ordinal)) + foreach (var resource in skill.Resources) { - _manifestMetadata[uri] = (name, description); + yield return resource; } - - resources.Add(new SkillResource - { - Uri = uri, - Digest = ComputeDigest(bytes), - Size = bytes.LongLength, - }); } - _skills.Add(new SkillEntry - { - Uri = $"skill://{skillPath}/{SkillsProtocol.SkillManifestFileName}", - Frontmatter = new JsonObject + 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 { - ["name"] = name, - ["description"] = description, - }, - Resources = SkillResources.FromResources(resources), - }); - } - - private string ComputeDigest(byte[] bytes) - { - byte[] hash = SHA256.HashData(bytes); - - var builder = new StringBuilder("sha256:", 71); - foreach (byte value in hash) - { - builder.Append(value.ToString("x2")); - } - - return builder.ToString(); + UriTemplate = GeneratedReportUri, + Name = "generated-report", + Description = "Assemble a report from live data", + MimeType = "text/markdown", + }); } - private string GuessMimeType(string uri) => uri[(uri.LastIndexOf('.') + 1)..] switch + private static JsonObject Frontmatter(string name, string description) => new() { - "md" => "text/markdown", - "py" => "text/x-python", - "json" => "application/json", - _ => "text/plain", + ["name"] = name, + ["description"] = description, }; + + private static McpServerSkillFile SkillFile(string name, string description, string body) => + McpServerSkillFile.FromText(SkillsProtocol.SkillFileName, $"---\nname: {name}\ndescription: {description}\n---\n\n{body}"); } diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs new file mode 100644 index 000000000..2fa4222e0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -0,0 +1,114 @@ +#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 = (request, _) => new ValueTask(JsonNode.Parse(request.Params?["cursor"]?.GetValue() == "no-skills-array" ? """{ "skills": null }""" : """ + { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] }, + { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" }, + "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] } + """)), + }); + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsGet, + Handler = (request, _) => + { + // Any request other than the escaping skill is answered with a valid entry for a different URI. + string? requested = request.Params?["uri"]?.GetValue(); + string json = requested == "skill://escape/SKILL.md" + ? """ + { "skill": { "uri": "skill://escape/SKILL.md", "frontmatter": { "name": "escape", "description": "d" }, + "resources": [ { "uri": "skill://escape/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 }, + { "uri": "skill://escape/../secret.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } } + """ + : """ + { "skill": { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] } } + """; + return new ValueTask(JsonNode.Parse(json)); + }, + }); + }); + } + + [Fact] + public async Task ListSkillsAsync_RejectsAnInvalidEntry() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("skills/list", exception.Message); + Assert.Contains("skill://bad/SKILL.md", exception.Message); + } + + [Fact] + public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray() + { + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "no-skills-array" }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetSkillAsync_RejectsAValidEntryForADifferentUri() + { + await using McpClient client = await CreateMcpClientForServer(); + + // The entry itself is valid; it is just not the skill that was asked for. + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://other/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skill://other/SKILL.md", exception.Message); + Assert.Contains("skill://good/SKILL.md", exception.Message); + + // Asking for the URI the server actually returns succeeds. + var good = await client.GetSkillAsync("skill://good/SKILL.md", TestContext.Current.CancellationToken); + Assert.Equal("good", good.Name); + } + + [Fact] + public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://escape/SKILL.md", TestContext.Current.CancellationToken)); + + Assert.Contains("skills/get", exception.Message); + Assert.Contains("'..'", exception.Message); + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs new file mode 100644 index 000000000..c8164defc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -0,0 +1,186 @@ +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// Tests for : digest formatting and content verification against a manifest. +/// +public class SkillVerifierTests +{ + private const string Uri = "skill://alpha/SKILL.md"; + + private static SkillResource Entry(byte[] content) => new() + { + Uri = Uri, + Digest = SkillVerifier.ComputeDigest(content), + Size = content.Length, + }; + + [Theory] + [InlineData("", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")] + [InlineData("abc", "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")] + public void ComputeDigest_MatchesKnownVectors(string input, string expected) + { + Assert.Equal(expected, SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(input))); + } + + [Fact] + public void Verify_AcceptsMatchingBytes() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + + SkillVerifier.Verify(Entry(content), content); + } + + [Fact] + public void Verify_RejectsSizeMismatch() + { + byte[] content = Encoding.UTF8.GetBytes("# Hello\n"); + var entry = Entry(content); + entry.Size += 1; + + var exception = Assert.Throws(() => SkillVerifier.Verify(entry, content)); + Assert.Contains("bytes", exception.Message); + } + + [Fact] + public void Verify_RejectsDigestMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("# Hello\n")); + + var exception = Assert.Throws(() => + SkillVerifier.Verify(entry, Encoding.UTF8.GetBytes("# Hellp\n"))); + Assert.Contains("digest", exception.Message); + } + + [Fact] + public void Verify_AcceptsUppercaseDigestFromServer() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var entry = Entry(content); + entry.Digest = entry.Digest.ToUpperInvariant().Replace("SHA256", "sha256"); + + SkillVerifier.Verify(entry, content); + } + + [Fact] + public void Verify_TextContents_HashesUtf8Encoding() + { + const string Text = "# Héllo\n"; + var entry = Entry(Encoding.UTF8.GetBytes(Text)); + + SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = Text }); + } + + [Fact] + public void Verify_Contents_RejectsMissingTextAsVerificationFailure() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => SkillVerifier.Verify(entry, new TextResourceContents { Uri = Uri, Text = null! })); + } + + [Fact] + public void Verify_BlobContents_HashesDecodedBytes() + { + byte[] content = [0x00, 0xFF, 0x10, 0x80]; + var entry = Entry(content); + + SkillVerifier.Verify(entry, BlobResourceContents.FromBytes(content, Uri)); + } + + [Fact] + public void Verify_Contents_RejectsUriMismatch() + { + var entry = Entry(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => + SkillVerifier.Verify(entry, new TextResourceContents { Uri = "skill://alpha/other.md", Text = "x" })); + } + + [Fact] + public void Verify_Skill_ChecksEveryContentAgainstManifest() + { + byte[] content = Encoding.UTF8.GetBytes("x"); + var skill = CreateSkill(content); + + SkillVerifier.Verify(skill, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "y" }] })); + } + + [Fact] + public void Verify_Skill_RejectsUnlistedFile() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/new.md", Text = "x" }] })); + Assert.Contains("not listed", exception.Message); + } + + [Fact] + public void Verify_Skill_RejectsEmptyResult() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + + Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); + } + + [Fact] + public void Verify_Skill_RejectsMissingManifestWithoutCrashing() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + skill.Resources = null!; + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + [Fact] + public void Verify_Skill_ThrowsForDynamicSkill() + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + skill.Resources = SkillResources.Dynamic; + + Assert.Throws(() => SkillVerifier.Verify(skill, + new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + [Fact] + public void Verify_SkillAndUri_RequiresTheRequestedFileInTheResult() + { + byte[] skillFile = Encoding.UTF8.GetBytes("x"); + byte[] other = Encoding.UTF8.GetBytes("other"); + var skill = CreateSkill(skillFile); + skill.Resources = SkillResources.FromResources( + [ + Entry(skillFile), + new SkillResource { Uri = "skill://alpha/other.md", Digest = SkillVerifier.ComputeDigest(other), Size = other.Length }, + ]); + + // A correctly digested but different file of the same skill must not satisfy a read of SKILL.md. + var substituted = new ReadResourceResult { Contents = [new TextResourceContents { Uri = "skill://alpha/other.md", Text = "other" }] }; + SkillVerifier.Verify(skill, substituted); + var exception = Assert.Throws(() => SkillVerifier.Verify(skill, Uri, substituted)); + Assert.Contains("returned no contents for that URI", exception.Message); + + SkillVerifier.Verify(skill, Uri, new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] }); + + Assert.Throws(() => + SkillVerifier.Verify(skill, "skill://alpha/unlisted.md", new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); + } + + private static Skill CreateSkill(byte[] skillFileContent) => new() + { + Uri = Uri, + Frontmatter = new JsonObject { ["name"] = "alpha", ["description"] = "d" }, + Resources = SkillResources.FromResources([Entry(skillFileContent)]), + }; +} diff --git a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs index 4e500b5ab..125d78e63 100644 --- a/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs +++ b/tests/ModelContextProtocol.Tests/ClientServerTestBase.cs @@ -62,7 +62,7 @@ protected McpServer StartServer() return Server; } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { await _cts.CancelAsync(); diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs index 3c600588f..d0826dc71 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -6,12 +6,12 @@ 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. +/// 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 SkillEntry CreateEntry(SkillResources resources) => new() + private static Skill CreateEntry(SkillResources resources) => new() { Uri = "skill://git-workflow/SKILL.md", Frontmatter = new JsonObject @@ -22,7 +22,7 @@ public class SkillSerializationTests Resources = resources, }; - private SkillResource CreateResource(string uri, string digest, long size) => new() + private static SkillResource CreateResource(string uri, string digest, long size) => new() { Uri = uri, Digest = digest, @@ -30,7 +30,7 @@ public class SkillSerializationTests }; [Fact] - public void SkillEntry_WithEnumeratedResources_RoundTrips() + public void Skill_WithEnumeratedResources_RoundTrips() { var original = CreateEntry(SkillResources.FromResources( [ @@ -38,12 +38,13 @@ public void SkillEntry_WithEnumeratedResources_RoundTrips() CreateResource("skill://git-workflow/examples/email.md", "sha256:" + new string('b', 64), 962), ])); - string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); - var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + 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.Frontmatter["name"]?.GetValue()); + 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; @@ -55,23 +56,19 @@ public void SkillEntry_WithEnumeratedResources_RoundTrips() } [Fact] - public void SkillEntry_WithDynamicResources_SerializesAsTheDynamicString() + public void Skill_WithDynamicResources_SerializesAsTheDynamicString() { - var original = CreateEntry(SkillResources.Dynamic); - - string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); + 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 SkillEntry_WithDynamicResources_RoundTrips() + public void Skill_WithDynamicResources_RoundTrips() { - var original = CreateEntry(SkillResources.Dynamic); - - string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); - var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + 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); @@ -79,18 +76,58 @@ public void SkillEntry_WithDynamicResources_RoundTrips() } [Fact] - public void SkillEntry_WithEmptyResourceArray_RoundTripsAsNonDynamic() + public void Skill_WithEmptyResourceArray_RoundTripsAsNonDynamic() { - var original = CreateEntry(SkillResources.FromResources([])); - - string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.SkillEntry); - var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry); + 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\"")] @@ -99,7 +136,8 @@ public void SkillEntry_WithEmptyResourceArray_RoundTripsAsNonDynamic() [InlineData("123")] [InlineData("true")] [InlineData("{}")] - public void SkillEntry_WithInvalidResourcesValue_Throws(string resourcesJson) + [InlineData("[null]")] + public void Skill_WithInvalidResourcesValue_Throws(string resourcesJson) { string json = $$""" { @@ -109,8 +147,20 @@ public void SkillEntry_WithInvalidResourcesValue_Throws(string resourcesJson) } """; - Assert.ThrowsAny( - () => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.SkillEntry)); + 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] @@ -144,8 +194,11 @@ public void ListSkillsResult_RoundTripsCursorAndCacheAttributes() }; string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsResult); - var deserialized = JsonSerializer.Deserialize(json, 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); @@ -155,26 +208,28 @@ public void ListSkillsResult_RoundTripsCursorAndCacheAttributes() } [Fact] - public void ListSkillsResult_WritesCacheScopeUsingSpecifiedWireValues() + public void ListSkillsResult_ToleratesUnknownCacheScopeOnRead() { - var result = new ListSkillsResult { CacheScope = CacheScope.Private }; + const string Json = """{ "skills": [], "ttlMs": 0, "cacheScope": "regional" }"""; - string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.ListSkillsResult); + var deserialized = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.ListSkillsResult); - Assert.Equal("private", JsonNode.Parse(json)!["cacheScope"]?.GetValue()); + Assert.NotNull(deserialized); + Assert.Null(deserialized.CacheScope); + Assert.Equal(TimeSpan.Zero, deserialized.TimeToLive); } [Fact] - public void ListSkillsResult_OmitsCacheAttributesWhenUnset() + public void ListSkillsResult_OmitsOptionalFieldsWhenUnset() { - var result = new ListSkillsResult { Skills = [] }; - - string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.ListSkillsResult); + 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] @@ -190,32 +245,21 @@ public void GetSkillResult_RoundTrips() }; string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.GetSkillResult); - var deserialized = JsonSerializer.Deserialize(json, 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 GetSkillResult_HasNoPaginationCursor() + public void RequestParams_RoundTrip() { - var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) }; - - string json = JsonSerializer.Serialize(result, McpSkillsJsonContext.Default.GetSkillResult); - - Assert.False(JsonNode.Parse(json)!.AsObject().ContainsKey("nextCursor")); - } + string listJson = JsonSerializer.Serialize(new ListSkillsRequestParams { Cursor = "abc" }, McpSkillsJsonContext.Default.ListSkillsRequestParams); + Assert.Equal("abc", JsonSerializer.Deserialize(listJson, McpSkillsJsonContext.Default.ListSkillsRequestParams)?.Cursor); - [Fact] - public void ListSkillsRequestParams_RoundTripsCursor() - { - var original = new ListSkillsRequestParams { Cursor = "abc" }; - - string json = JsonSerializer.Serialize(original, McpSkillsJsonContext.Default.ListSkillsRequestParams); - var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.ListSkillsRequestParams); - - Assert.NotNull(deserialized); - Assert.Equal("abc", deserialized.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 index d58d0759f..18b4caae0 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -1,34 +1,43 @@ using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; /// -/// Tests for , covering ordering, keyset pagination, lookup of -/// unlisted skills, and cursor validation. +/// Tests for : ordering, keyset pagination, lookup, cursor validation, and +/// the structural validation of entries against the specification. /// public class InMemoryMcpSkillCatalogTests { - private SkillEntry CreateSkill(string name) => new() + 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) { - Uri = $"skill://{name}/SKILL.md", - Frontmatter = new JsonObject + string uri = $"skill://{name}/SKILL.md"; + return new() { - ["name"] = name, - ["description"] = $"The {name} skill", - }, - Resources = SkillResources.Dynamic, - }; + Uri = uri, + Frontmatter = new JsonObject + { + ["name"] = name, + ["description"] = description, + }, + Resources = resources ?? SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 10 }]), + }; + } - private InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => - new([.. names.Select(CreateSkill)], pageSize); + 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); + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); Assert.Collection( page.Skills, @@ -36,6 +45,7 @@ public async Task ListAsync_ReturnsEntriesOrderedByUri() 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] @@ -45,17 +55,17 @@ public async Task ListAsync_PaginatesWithoutRepeatingOrSkippingEntries() var seen = new List(); string? cursor = null; - int iterations = 0; + 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; - iterations++; - Assert.True(iterations < 20, "Pagination did not terminate."); + 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); @@ -66,19 +76,32 @@ 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); Assert.Null(second.NextCursor); } + [Fact] + public async Task ListAsync_WithCursorForUnknownUri_ResumesAfterItsPosition() + { + var catalog = CreateCatalog(10, "a", "c"); + string cursor = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("skill://b/SKILL.md")); + + var page = await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken); + + Assert.Single(page.Skills); + Assert.Equal("skill://c/SKILL.md", page.Skills[0].Uri); + } + [Fact] public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage() { var catalog = CreateCatalog(10); - var page = await catalog.ListAsync(null, TestContext.Current.CancellationToken); + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); Assert.Empty(page.Skills); Assert.Null(page.NextCursor); @@ -90,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); } @@ -100,30 +123,63 @@ 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.Frontmatter["name"]?.GetValue()); + Assert.Equal("alpha", skill.Name); } - [Fact] - public async Task GetAsync_WithUnknownUri_ReturnsNull() + [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"); - var skill = await catalog.GetAsync("skill://missing/SKILL.md", 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"; - Assert.Null(skill); + var served = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + Assert.NotNull(served); + Assert.Equal("A skill", served.Description); + Assert.Equal(s_validDigest, served.Resources.Resources![0].Digest); } [Fact] - public async Task GetAsync_IsCaseSensitive() + public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResponses() { - var catalog = CreateCatalog(10, "alpha"); + var catalog = new InMemoryMcpSkillCatalog([CreateSkill("alpha")]); + + var first = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + first!.Frontmatter["description"] = "tampered"; + first.Resources.Resources![0].Digest = "sha256:" + new string('f', 64); - var skill = await catalog.GetAsync("SKILL://ALPHA/SKILL.md", TestContext.Current.CancellationToken); + var listed = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + listed.Skills[0].Frontmatter["description"] = "tampered again"; + + var again = await catalog.GetAsync("skill://alpha/SKILL.md", Context, TestContext.Current.CancellationToken); + Assert.Equal("A skill", again!.Description); + Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest); + } - Assert.Null(skill); + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("données-2024")] + public void Constructor_AcceptsUnicodeLowercaseNames(string name) + { + Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count); } [Fact] @@ -143,4 +199,183 @@ public void Constructor_WithNullSkills_Throws() => [InlineData(-1)] public void Constructor_WithNonPositivePageSize_Throws(int pageSize) => Assert.Throws(() => new InMemoryMcpSkillCatalog([], pageSize)); + + [Fact] + public void Constructor_AcceptsFullAgentSkillsFrontmatter() + { + var skill = CreateSkill("alpha"); + skill.Frontmatter["license"] = "MIT"; + skill.Frontmatter["compatibility"] = "Needs git"; + skill.Frontmatter["allowed-tools"] = "Bash(git:*) Read"; + skill.Frontmatter["metadata"] = new JsonObject { ["author"] = "acme", ["version"] = "2.1.0" }; + skill.Frontmatter["description"] = new string('d', 1024); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_PassesThroughFieldsHostsDoNotActOn() + { + // allowed-tools is experimental and hosts ignore it for MCP-origin skills; license is free-form; metadata + // values are whatever the author wrote. None of these should cost a skill its listing. + var skill = CreateSkill("alpha"); + skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + skill.Frontmatter["license"] = null; + skill.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_AcceptsEmptyAuthority() + { + var skill = CreateSkill("alpha"); + skill.Uri = "file:///alpha/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Fact] + public void Constructor_AcceptsDynamicSkill() + { + var catalog = new InMemoryMcpSkillCatalog([CreateSkill("generated", resources: SkillResources.Dynamic)]); + + Assert.Equal(1, catalog.Count); + } + + [Fact] + public void Constructor_AcceptsNestedSkillPath() + { + var skill = CreateSkill("refunds"); + skill.Uri = "skill://acme/billing/refunds/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + var catalog = new InMemoryMcpSkillCatalog([skill]); + + Assert.Equal(1, catalog.Count); + } + + public static IEnumerable InvalidSkills() + { + static object[] Case(string reason, Action mutate) + { + var skill = CreateSkill("alpha"); + mutate(skill); + return [reason, skill]; + } + + yield return Case("uri not ending in /SKILL.md", s => s.Uri = "skill://alpha/skill.md"); + yield return Case("syntactically invalid uri", s => + { + s.Uri = "1 bad://alpha/SKILL.md"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("metadata present but null", s => s.Frontmatter["metadata"] = null); + yield return Case("compatibility present but null", s => s.Frontmatter["compatibility"] = null); + yield return Case("relative uri", s => + { + s.Uri = "alpha/SKILL.md"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("uri with query", s => + { + s.Uri = "skill://alpha/SKILL.md?x=1"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("resource escapes the skill through '..'", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/../secret.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with empty segment", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha//x.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with fragment", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/x.md#frag", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("sizes that overflow when summed", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = long.MaxValue }, + new SkillResource { Uri = "skill://alpha/x.md", Digest = s_validDigest, Size = long.MaxValue }, + ])); + yield return Case("description over 1024 characters", s => s.Frontmatter["description"] = new string('d', 1025)); + yield return Case("compatibility over 500 characters", s => s.Frontmatter["compatibility"] = new string('c', 501)); + yield return Case("compatibility empty", s => s.Frontmatter["compatibility"] = ""); + yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x"); + yield return Case("name missing", s => s.Frontmatter.Remove("name")); + yield return Case("name not a string", s => s.Frontmatter["name"] = 1); + yield return Case("name does not match uri segment", s => s.Frontmatter["name"] = "beta"); + yield return Case("name with an uppercase letter", s => + { + s.Uri = "skill://Café/SKILL.md"; + s.Frontmatter["name"] = "Café"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("resource escapes the skill through an encoded '..'", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("resource with an encoded slash", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/a%2Fb.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("name violates naming rules", s => + { + s.Uri = "skill://Alpha/SKILL.md"; + s.Frontmatter["name"] = "Alpha"; + s.Resources = SkillResources.FromResources([new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }]); + }); + yield return Case("description missing", s => s.Frontmatter.Remove("description")); + yield return Case("description empty", s => s.Frontmatter["description"] = ""); + yield return Case("empty manifest", s => s.Resources = SkillResources.FromResources([])); + yield return Case("manifest omits SKILL.md", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = "skill://alpha/other.md", Digest = s_validDigest, Size = 1 }])); + yield return Case("manifest lists file outside the skill", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alphabet/x.md", Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("duplicate manifest entry", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + ])); + yield return Case("malformed digest (uppercase)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:" + new string('A', 64), Size = 1 }])); + yield return Case("malformed digest (wrong length)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha256:abc", Size = 1 }])); + yield return Case("malformed digest (wrong algorithm)", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = "sha512:" + new string('a', 64), Size = 1 }])); + yield return Case("negative size", s => s.Resources = SkillResources.FromResources( + [new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = -1 }])); + yield return Case("too many resources", s => s.Resources = SkillResources.FromResources( + Enumerable.Range(0, SkillsProtocol.MaxResourcesPerSkill + 1).Select(i => new SkillResource + { + Uri = i == 0 ? s.Uri : $"skill://alpha/f{i}.md", + Digest = s_validDigest, + Size = 1, + }))); + yield return Case("total size over limit", s => s.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = s.Uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = "skill://alpha/big.bin", Digest = s_validDigest, Size = SkillsProtocol.MaxTotalSizeBytes }, + ])); + } + + [Theory] + [MemberData(nameof(InvalidSkills))] + public void Constructor_RejectsInvalidSkill(string reason, Skill skill) + { + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("skill://", exception.Message); + Assert.NotEmpty(reason); + } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs new file mode 100644 index 000000000..5b49f8436 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -0,0 +1,523 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for : manifest computation, resource creation, path handling, validation, and +/// loading from a directory. +/// +public class McpServerSkillTests +{ + private const string SkillUri = "skill://git-workflow/SKILL.md"; + private const string SkillMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n\n# Git workflow\n"; + + private static JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new() + { + ["name"] = name, + ["description"] = description, + }; + + [Fact] + public void Create_ComputesManifestFromFileBytes() + { + byte[] guide = Encoding.UTF8.GetBytes("# Guide\n"); + + var frontmatter = Frontmatter(); + var skill = McpServerSkill.Create(SkillUri, frontmatter, + [ + new McpServerSkillFile { Path = "references/GUIDE.md", Content = guide }, + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + ]); + + var entry = skill.ProtocolSkill; + Assert.Equal(SkillUri, entry.Uri); + Assert.True(JsonNode.DeepEquals(frontmatter, entry.Frontmatter)); + Assert.NotSame(frontmatter, entry.Frontmatter); + Assert.False(entry.Resources.IsDynamic); + + var manifest = entry.Resources.Resources!; + Assert.Equal(2, manifest.Count); + + // SKILL.md is always first, regardless of input order. + Assert.Equal(SkillUri, manifest[0].Uri); + Assert.Equal(Encoding.UTF8.GetByteCount(SkillMarkdown), manifest[0].Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(SkillMarkdown)), manifest[0].Digest); + + Assert.Equal("skill://git-workflow/references/GUIDE.md", manifest[1].Uri); + Assert.Equal(guide.Length, manifest[1].Size); + Assert.Equal(SkillVerifier.ComputeDigest(guide), manifest[1].Digest); + } + + [Fact] + public void Create_ProducesOneResourcePerFileWithSpecMetadata() + { + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("scripts/run.py", "print('hi')\n"), + McpServerSkillFile.FromText("data/table.csv", "a,b\n", mimeType: "text/x-custom"), + ]); + + Assert.Equal(3, skill.Resources.Count); + + var skillFile = skill.Resources[0].ProtocolResource; + Assert.NotNull(skillFile); + Assert.Equal(SkillUri, skillFile.Uri); + Assert.Equal("git-workflow", skillFile.Name); + Assert.Equal("Git conventions", skillFile.Description); + Assert.Equal("text/markdown", skillFile.MimeType); + + var script = skill.Resources[2].ProtocolResource; + Assert.NotNull(script); + Assert.Equal("skill://git-workflow/scripts/run.py", script.Uri); + Assert.Equal("scripts/run.py", script.Name); + Assert.Equal("text/x-python", script.MimeType); + + var table = skill.Resources[1].ProtocolResource; + Assert.NotNull(table); + Assert.Equal("text/x-custom", table.MimeType); + } + + [Theory] + [InlineData("./SKILL.md", "SKILL.md")] + [InlineData("references\\GUIDE.md", "references/GUIDE.md")] + public void Create_NormalizesFilePaths(string input, string expectedRelative) + { + var files = new List { McpServerSkillFile.FromText(input, expectedRelative == "SKILL.md" ? SkillMarkdown : "x") }; + if (expectedRelative != "SKILL.md") + { + files.Add(McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)); + } + + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), files); + + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, r => r.Uri == "skill://git-workflow/" + expectedRelative); + } + + [Theory] + [InlineData("")] + [InlineData("/SKILL.md")] + [InlineData("references/")] + [InlineData("../SKILL.md")] + [InlineData("a/./b.md")] + [InlineData("a//b.md")] + public void Create_RejectsUnsafePaths(string path) + { + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText(path, "x"), + ])); + } + + [Fact] + public void Create_RejectsMissingSkillFile() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("README.md", "x")])); + + Assert.Contains("SKILL.md", exception.Message); + } + + [Fact] + public void Create_RejectsDuplicatePaths() + { + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("./SKILL.md", SkillMarkdown), + ])); + } + + [Fact] + public void Create_RejectsUriNotEndingInSkillFile() + { + Assert.Throws(() => + McpServerSkill.Create("skill://git-workflow", Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + } + + [Fact] + public void Create_RejectsFrontmatterNameMismatch() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(name: "git-flow"), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Equal("frontmatter", exception.ParamName); + Assert.Contains("git-flow", exception.Message); + } + + [Fact] + public void Create_RejectsMissingDescription() + { + Assert.Throws(() => + McpServerSkill.Create(SkillUri, Frontmatter(description: null), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + } + + [Fact] + public void Create_ReadsFrontmatterFromSkillFile() + { + var skill = McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + + Assert.True(JsonNode.DeepEquals(Frontmatter(), skill.ProtocolSkill.Frontmatter)); + } + + [Fact] + public void Create_DerivesUriFromFrontmatterName() + { + var skill = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), McpServerSkillFile.FromText("a.md", "x")]); + + Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); + Assert.Equal("skill://git-workflow/a.md", skill.ProtocolSkill.Resources.Resources![1].Uri); + } + + [Fact] + public void Create_RejectsUriWhoseNameDiffersFromTheFile() + { + var exception = Assert.Throws(() => + McpServerSkill.Create("skill://other-name/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Contains("git-workflow", exception.Message); + } + + [Fact] + public void Create_WithUnreadableFrontmatter_ExplainsAndPointsAtTheExplicitOverload() + { + var exception = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: &a git-workflow\ndescription: d\n---\n")])); + + Assert.Contains("anchors", exception.Message); + Assert.Contains("JsonObject", exception.Message); + + var noFrontmatter = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "# No frontmatter\n")])); + Assert.Contains("must begin", noFrontmatter.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_RejectsMismatchWithTheFile() + { + var mismatched = Frontmatter(); + mismatched["license"] = "MIT"; + + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, mismatched, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)])); + + Assert.Equal("frontmatter", exception.ParamName); + Assert.Contains("does not match", exception.Message); + Assert.Contains("\"license\"", exception.Message); + } + + [Fact] + public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() + { + // Matches the file: fine. + McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + + // Values are compared as typed JSON: a quoted "2" in the file is the string "2" in the object. + McpServerSkill.Create(SkillUri, new JsonObject { ["name"] = "git-workflow", ["description"] = "d", ["metadata"] = new JsonObject { ["major"] = "2" } }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: d\nmetadata:\n major: \"2\"\n---\n")]); + + // 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); + + // A quoted scalar that closes on a later line is valid YAML the reader does not support, so it is bypassable too. + var multiLine = McpServerSkill.Create(SkillUri, Frontmatter(), + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")]); + Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description); + + // So is a flow collection that closes on a later line. + var explicitTools = Frontmatter(); + explicitTools["allowed-tools"] = new JsonArray("Read", "Write"); + var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTools, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\nallowed-tools: [Read,\n Write]\n---\n")]); + Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["allowed-tools"]!.AsArray().Count); + } + + [Theory] + [InlineData("# no frontmatter block\n", "must begin")] + [InlineData("---\nname: git-workflow\n", "not closed")] + [InlineData("---\nname: a: b\n---\n", "cannot contain")] + [InlineData("---\n\tname: x\n---\n", "tabs")] + [InlineData("---\nname: git-workflow\ndescription: \"never closed\n---\n", "unterminated")] + [InlineData("---\nname: git-workflow\ndescription: d\nallowed-tools: [Read, Write\n---\n", "unterminated flow")] + [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")] + public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) + { + // 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() + { + Assert.Throws(() => McpServerSkill.Create(null!, Frontmatter(), [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, null!, [])); + Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), null!)); + Assert.Throws(() => McpServerSkill.Create((IEnumerable)null!)); + Assert.Throws(() => McpServerSkill.Create((string)null!, [])); + Assert.Throws(() => McpServerSkill.CreateFromDirectory((string)null!)); + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, (string)null!)); + } + + [Fact] + public void CreateFromDirectory_LoadsFilesRecursively() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(directory, "templates", "regional")); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(directory, "templates", "invoice.md"), "# Invoice\n"); + File.WriteAllBytes(Path.Combine(directory, "templates", "regional", "logo.png"), [0x89, 0x50, 0x4E, 0x47, 0xFF, 0xFE]); + + // All three overloads agree. + var skill = McpServerSkill.CreateFromDirectory(directory); + Assert.Equal(SkillUri, skill.ProtocolSkill.Uri); + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Uri); + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory).ProtocolSkill.Uri); + + var uris = skill.ProtocolSkill.Resources.Resources!.Select(r => r.Uri).ToList(); + Assert.Equal( + [ + "skill://git-workflow/SKILL.md", + "skill://git-workflow/templates/invoice.md", + "skill://git-workflow/templates/regional/logo.png", + ], uris); + + Assert.Equal("image/png", skill.Resources[2].ProtocolResource!.MimeType); + Assert.Equal(6, skill.ProtocolSkill.Resources.Resources![2].Size); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void Create_EscapesUriSyntaxCharactersInFilePaths() + { + var skill = McpServerSkill.Create(SkillUri, Frontmatter(), + [ + McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), + McpServerSkillFile.FromText("templates/{name} v2.md", "template"), + McpServerSkillFile.FromText("notes/a#b?c.md", "note"), + ]); + + var manifest = skill.ProtocolSkill.Resources.Resources!; + Assert.Equal("skill://git-workflow/templates/%7Bname%7D%20v2.md", manifest[2].Uri); + Assert.Equal("skill://git-workflow/notes/a%23b%3Fc.md", manifest[1].Uri); + + // Every file is a concrete resource, never a template, and its resource URI equals its manifest URI. + for (int i = 0; i < manifest.Count; i++) + { + Assert.False(skill.Resources[i].IsTemplated); + Assert.Equal(manifest[i].Uri, skill.Resources[i].ProtocolResource!.Uri); + } + + Assert.Equal("templates/{name} v2.md", skill.Resources[2].ProtocolResource!.Name); + } + + [Fact] + public void Create_KeepsItsOwnCopyOfTheEntry() + { + var frontmatter = Frontmatter(); + var skill = McpServerSkill.Create(SkillUri, frontmatter, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown)]); + var manifestEntry = skill.ProtocolSkill.Resources.Resources![0]; + + // The caller goes on to edit the frontmatter object it passed in, and an entry it was handed back. + frontmatter["description"] = "changed"; + var returned = skill.ProtocolSkill; + returned.Frontmatter["name"] = "changed"; + returned.Resources.Resources![0].Digest = "sha256:" + new string('0', 64); + returned.Resources = SkillResources.Dynamic; + + // None of that reaches the skill, so what WithSkills registers is what Create computed. (That the served + // bytes are likewise a copy of the caller's buffer is checked end to end in McpServerSkillsIntegrityTests.) + var fresh = skill.ProtocolSkill; + Assert.Equal("git-workflow", fresh.Name); + Assert.Equal("Git conventions", fresh.Description); + Assert.Equal(manifestEntry.Digest, fresh.Resources.Resources![0].Digest); + Assert.NotSame(returned, fresh); + } + + [Fact] + public void Create_RequiresAStringNameToDeriveTheUri() + { + var exception = Assert.Throws(() => + McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", "---\nname: 123\ndescription: d\n---\n")])); + + Assert.Contains("must declare a string 'name'", exception.Message); + } + + [Fact] + public void Create_RejectsNullFileEntries() + { + var exception = Assert.Throws(() => + McpServerSkill.Create(SkillUri, [McpServerSkillFile.FromText("SKILL.md", SkillMarkdown), null!])); + + Assert.Contains("null entries", exception.Message); + Assert.Equal("files", exception.ParamName); + } + +#if NET + [Fact] + public void CreateFromDirectory_RejectsSymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-link-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside.txt"), "private data"); + + try + { + File.CreateSymbolicLink(Path.Combine(skillDirectory, "linked.txt"), Path.Combine(root, "outside.txt")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); + Assert.Contains("linked.txt", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsASymbolicLinkAsTheRoot() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-rootlink-" + Guid.NewGuid().ToString("N")); + try + { + string target = Path.Combine(root, "target"); + Directory.CreateDirectory(target); + File.WriteAllText(Path.Combine(target, "SKILL.md"), SkillMarkdown); + + string link = Path.Combine(root, "link"); + try + { + Directory.CreateSymbolicLink(link, target); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(link)); + Assert.Equal("directoryPath", exception.ParamName); + + // The target itself is fine. + Assert.Equal(SkillUri, McpServerSkill.CreateFromDirectory(target).ProtocolSkill.Uri); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsDirectorySymbolicLinks() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skill-dirlink-" + Guid.NewGuid().ToString("N")); + try + { + string skillDirectory = Path.Combine(root, "skill"); + Directory.CreateDirectory(skillDirectory); + Directory.CreateDirectory(Path.Combine(root, "outside")); + File.WriteAllText(Path.Combine(skillDirectory, "SKILL.md"), SkillMarkdown); + File.WriteAllText(Path.Combine(root, "outside", "secret.txt"), "private data"); + + try + { + Directory.CreateSymbolicLink(Path.Combine(skillDirectory, "linked"), Path.Combine(root, "outside")); + } + catch (Exception e) when (e is UnauthorizedAccessException or IOException) + { + Assert.Skip($"Cannot create symbolic links here: {e.Message}"); + } + + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), skillDirectory)); + } + finally + { + Directory.Delete(root, recursive: true); + } + } +#endif + + [Fact] + public void CreateFromDirectory_RejectsTooManyFilesBeforeReadingThem() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-many-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + for (int i = 0; i < SkillsProtocol.MaxResourcesPerSkill; i++) + { + File.WriteAllText(Path.Combine(directory, $"f{i:D4}.txt"), "x"); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + Assert.Contains(SkillsProtocol.MaxResourcesPerSkill.ToString(), exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_RejectsOversizedContentBeforeReadingIt() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-big-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), SkillMarkdown); + using (var big = new FileStream(Path.Combine(directory, "big.bin"), FileMode.CreateNew)) + { + // A sparse file: the size check must trip without the bytes ever being read. + big.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); + } + + var exception = Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + Assert.Contains(SkillsProtocol.MaxTotalSizeBytes.ToString(), exception.Message); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CreateFromDirectory_WithMissingDirectory_Throws() + { + string directory = Path.Combine(Path.GetTempPath(), "mcp-skill-missing-" + Guid.NewGuid().ToString("N")); + + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, Frontmatter(), directory)); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs new file mode 100644 index 000000000..2d6d85acb --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -0,0 +1,296 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// End-to-end tests for skills served through a custom : pagination, skills that +/// are served but not listed, dynamic skills, the error contract, and the protocol-version gating of +/// resultType, ttlMs, and cacheScope (issue #1721). +/// +public class McpServerSkillsCatalogTests : ClientServerTestBase +{ + private const string TamperedUri = "skill://tampered/SKILL.md"; + private const string UnlistedUri = "skill://hidden/SKILL.md"; + private const string DynamicUri = "skill://generated/SKILL.md"; + private const string BrokenUri = "skill://broken/SKILL.md"; + private const string SwappedUri = "skill://swapped/SKILL.md"; + + public McpServerSkillsCatalogTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var listed = new[] { "alpha", "beta", "gamma" }.Select(name => CreateEntry($"skill://{name}/SKILL.md", name)).ToList(); + + // The manifest declares one content, the resource serves another. + var tampered = CreateEntry(TamperedUri, "tampered", Encoding.UTF8.GetBytes("the content the host approved")); + + // Served and answerable through skills/get, but withheld from skills/list. + var unlisted = CreateEntry(UnlistedUri, "hidden"); + + var dynamic = new Skill + { + Uri = DynamicUri, + Frontmatter = new JsonObject { ["name"] = "generated", ["description"] = "Assembled from live data" }, + Resources = SkillResources.Dynamic, + }; + + // A catalog bug: an entry whose manifest omits its own SKILL.md. The handler must not publish it. + var broken = new Skill + { + Uri = BrokenUri, + Frontmatter = new JsonObject { ["name"] = "broken", ["description"] = "d" }, + Resources = SkillResources.FromResources([new SkillResource { Uri = "skill://broken/other.md", Digest = "sha256:" + new string('a', 64), Size = 1 }]), + }; + + var catalog = new PartialCatalog( + listed: new InMemoryMcpSkillCatalog([.. listed, tampered, dynamic], pageSize: 2), + unlisted: unlisted, + broken: broken); + + mcpServerBuilder + .WithSkills(catalog, options => + { + options.TimeToLive = TimeSpan.FromMinutes(5); + options.CacheScope = CacheScope.Public; + }) + .WithResources([McpServerResource.Create(() => "the content the server actually serves", new() { UriTemplate = TamperedUri })]); + } + + private static Skill CreateEntry(string uri, string name, byte[]? content = null) + { + content ??= Encoding.UTF8.GetBytes($"---\nname: {name}\n---\n"); + return new Skill + { + Uri = uri, + Frontmatter = new JsonObject { ["name"] = name, ["description"] = $"The {name} skill" }, + Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = SkillVerifier.ComputeDigest(content), Size = content.Length }]), + }; + } + + [Fact] + public async Task ListSkillsAsync_FollowsPaginationToTheEnd() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(5, skills.Count); + Assert.Equal(skills.Select(s => s.Uri).OrderBy(u => u, StringComparer.Ordinal), skills.Select(s => s.Uri)); + Assert.DoesNotContain(skills, s => s.Uri == UnlistedUri); + } + + [Fact] + public async Task ListSkillsAsync_PerPage_ExposesCursorAndCacheHints() + { + await using McpClient client = await CreateMcpClientForServer(); + + var first = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Equal(2, first.Skills.Count); + Assert.NotNull(first.NextCursor); + Assert.Equal(TimeSpan.FromMinutes(5), first.TimeToLive); + Assert.Equal(CacheScope.Public, first.CacheScope); + + var second = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = first.NextCursor }, TestContext.Current.CancellationToken); + Assert.Equal(2, second.Skills.Count); + Assert.NotEqual(first.Skills[0].Uri, second.Skills[0].Uri); + } + + [Fact] + public async Task GetSkillAsync_AnswersForSkillAbsentFromListing() + { + await using McpClient client = await CreateMcpClientForServer(); + + var skill = await client.GetSkillAsync(UnlistedUri, TestContext.Current.CancellationToken); + + Assert.Equal("hidden", skill.Name); + } + + [Fact] + public async Task SkillsGet_WithCatalogAnsweringForADifferentUri_ReturnsInternalError() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SwappedUri, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Contains(SwappedUri, exception.Message); + } + + [Fact] + public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError() + { + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(BrokenUri, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, exception.ErrorCode); + Assert.Contains("invalid entry", exception.Message); + } + + [Fact] + public async Task 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, Skill broken) : IMcpSkillCatalog + { + 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, McpSkillRequestContext context, CancellationToken cancellationToken) + { + AssertContext(context, SkillsProtocol.MethodSkillsGet); + if (string.Equals(uri, unlisted.Uri, StringComparison.Ordinal)) + { + return unlisted; + } + + if (string.Equals(uri, broken.Uri, StringComparison.Ordinal)) + { + return broken; + } + + if (string.Equals(uri, SwappedUri, StringComparison.Ordinal)) + { + // A catalog bug: a valid entry, for the wrong skill. + return await listed.GetAsync("skill://alpha/SKILL.md", context, cancellationToken); + } + + return await listed.GetAsync(uri, context, cancellationToken); + } + + // The catalog receives the request it is answering, so a per-caller catalog can decide from it. + private static void AssertContext(McpSkillRequestContext context, string method) + { + Assert.Equal(method, context.JsonRpcRequest.Method); + Assert.Null(context.User); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs new file mode 100644 index 000000000..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/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs new file mode 100644 index 000000000..7919fe04f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -0,0 +1,200 @@ +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 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 does not help: the collection would register one resource whose contents carry the + // first URI, so a verified read of the alias would fail. Only the identical URI text may be shared. + var same = McpServerSkill.Create("skill://ACME/refunds/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: upper\n---\n")]); + Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkills([upper, same])); + + // The legitimate shared-file case: a nested skill's SKILL.md is also a file of the enclosing skill, under + // the identical URI text and with identical content. That registers once and is fine. + const string InnerMarkdown = "---\nname: inner\ndescription: nested\n---\n"; + var outer = McpServerSkill.Create("skill://acme/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: acme\ndescription: outer\n---\n"), + McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), + ]); + var inner = McpServerSkill.Create("skill://acme/inner/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)]); + new ServiceCollection().AddMcpServer().WithSkills([outer, inner]); + } + + [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); + } + } + + [Fact] + public void WithSkillsFromDirectory_RejectsAnInvalidSkillRatherThanSkippingIt() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-invalid-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(Path.Combine(root, "good")); + File.WriteAllText(Path.Combine(root, "good", "SKILL.md"), "---\nname: good\ndescription: d\n---\n"); + Directory.CreateDirectory(Path.Combine(root, "broken")); + File.WriteAllText(Path.Combine(root, "broken", "SKILL.md"), "---\nname: broken\n---\n"); + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); + Assert.Contains("description", exception.Message); + Assert.Equal("directoryPath", exception.ParamName); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + + [Fact] + public void WithSkillsFromDirectory_RejectsTwoDirectoriesDeclaringTheSameName() + { + string root = Path.Combine(Path.GetTempPath(), "mcp-skills-samename-" + Guid.NewGuid().ToString("N")); + try + { + // The URI is derived from the frontmatter name, not the directory name, so these collide. + foreach (string directory in new[] { "first", "second" }) + { + Directory.CreateDirectory(Path.Combine(root, directory)); + File.WriteAllText(Path.Combine(root, directory, "SKILL.md"), "---\nname: same\ndescription: d\n---\n"); + } + + var exception = Assert.Throws(() => new ServiceCollection().AddMcpServer().WithSkillsFromDirectory(root)); + Assert.Contains("skill://same/SKILL.md", exception.Message); + } + finally + { + Directory.Delete(root, recursive: true); + } + } + +#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(); + Directory.Delete(_root, recursive: true); + } +} 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); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs index d0f2c122d..864450afc 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsTests.cs @@ -3,20 +3,21 @@ using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.Text.Json; +using System.Text; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; /// -/// End-to-end tests for the SEP-2640 skills methods over the client-server transport: capability -/// declaration, skills/list enumeration and pagination, skills/get retrieval including -/// skills absent from the listing, and the error contract for unknown URIs. +/// 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 ListedSkillUri = "skill://git-workflow/SKILL.md"; - private const string NestedSkillUri = "skill://acme/billing/refunds/SKILL.md"; + 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) @@ -25,192 +26,139 @@ public McpServerSkillsTests(ITestOutputHelper testOutputHelper) protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { - var catalog = new InMemoryMcpSkillCatalog( - [ - CreateSkill(ListedSkillUri, "git-workflow", SkillResources.FromResources( + var gitWorkflow = McpServerSkill.Create( + GitWorkflowUri, + new JsonObject { ["name"] = "git-workflow", ["description"] = "Git conventions", ["license"] = "MIT" }, [ - new SkillResource - { - Uri = ListedSkillUri, - Digest = "sha256:" + new string('a', 64), - Size = 2314, - }, - ])), - CreateSkill(NestedSkillUri, "refunds", SkillResources.Dynamic), - ]); - - mcpServerBuilder.WithSkills(catalog, options => - { - options.TimeToLive = TimeSpan.FromMinutes(5); - options.CacheScope = CacheScope.Public; - }); - } + McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown), + McpServerSkillFile.FromText("references/GUIDE.md", "# Guide\n"), + new McpServerSkillFile { Path = "assets/logo.png", Content = s_binaryFile }, + ]); - private static SkillEntry CreateSkill(string uri, string name, SkillResources resources) => new() - { - Uri = uri, - Frontmatter = new JsonObject - { - ["name"] = name, - ["description"] = $"The {name} skill", - }, - Resources = resources, - }; - - private async Task SendAsync(McpClient client, string method, JsonObject? parameters) - { - var request = new JsonRpcRequest - { - Method = method, - Params = parameters, - }; - - var response = await client.SendRequestAsync(request, TestContext.Current.CancellationToken); - return response.Result; - } + var refunds = McpServerSkill.Create( + RefundsUri, + new JsonObject { ["name"] = "refunds", ["description"] = "Process refunds" }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: refunds\ndescription: Process refunds\n---\n")]); - [Fact] - public async Task Server_DeclaresSkillsExtensionCapability() - { - await using McpClient client = await CreateMcpClientForServer(); - - var extensions = client.ServerCapabilities.Extensions; - - Assert.NotNull(extensions); - Assert.True(extensions.ContainsKey(SkillsProtocol.ExtensionId)); + mcpServerBuilder.WithSkills([gitWorkflow, refunds]); } [Fact] - public async Task Server_DoesNotDeclareDirectoryRead_WhenNotSupported() + public async Task Server_DeclaresSkillsExtensionAndResourcesCapability() { await using McpClient client = await CreateMcpClientForServer(); - var settings = JsonSerializer.SerializeToNode( - client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId])?.AsObject(); + Assert.True(client.SupportsSkills()); + Assert.NotNull(client.ServerCapabilities.Resources); + var settings = System.Text.Json.JsonSerializer.SerializeToNode(client.ServerCapabilities.Extensions![SkillsProtocol.ExtensionId], McpJsonUtilities.DefaultOptions)?.AsObject(); Assert.NotNull(settings); - Assert.False(settings.ContainsKey(SkillsProtocol.DirectoryReadSetting)); - } - - [Fact] - public async Task SkillsList_ReturnsEveryPublishedSkill() - { - await using McpClient client = await CreateMcpClientForServer(); - - var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); - var listing = result.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult); - - Assert.NotNull(listing); - Assert.Equal(2, listing.Skills.Count); - Assert.Contains(listing.Skills, skill => skill.Uri == ListedSkillUri); - Assert.Contains(listing.Skills, skill => skill.Uri == NestedSkillUri); + Assert.Empty(settings); } [Fact] - public async Task SkillsList_MarksResultComplete() + public async Task ListSkillsAsync_ReturnsEveryRegisteredSkill() { await using McpClient client = await CreateMcpClientForServer(); - var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); - Assert.Equal("complete", result?["resultType"]?.GetValue()); + 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 SkillsList_SerializesEnumeratedManifestAsArray() + public async Task GetSkillAsync_ReturnsTheRequestedEntry() { await using McpClient client = await CreateMcpClientForServer(); - var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); - - var entry = result!["skills"]!.AsArray() - .Single(skill => skill!["uri"]!.GetValue() == ListedSkillUri)!; - var resources = entry["resources"]!.AsArray(); + var skill = await client.GetSkillAsync(RefundsUri, TestContext.Current.CancellationToken); - Assert.Single(resources); - Assert.Equal(2314, resources[0]!["size"]!.GetValue()); - Assert.StartsWith("sha256:", resources[0]!["digest"]!.GetValue()); + Assert.Equal(RefundsUri, skill.Uri); + Assert.Equal("refunds", skill.Name); + Assert.Single(skill.Resources.Resources!); } [Fact] - public async Task SkillsList_SerializesDynamicManifestAsTheDynamicString() + public async Task GetSkillAsync_WithUnknownUri_ReturnsInvalidParams() { await using McpClient client = await CreateMcpClientForServer(); - var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); - - var entry = result!["skills"]!.AsArray() - .Single(skill => skill!["uri"]!.GetValue() == NestedSkillUri)!; + var exception = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://missing/SKILL.md", TestContext.Current.CancellationToken)); - Assert.Equal("dynamic", entry["resources"]!.GetValue()); + Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); } [Fact] - public async Task SkillsList_PreservesFrontmatterVerbatim() + public async Task SkillFiles_AreListedAsResources() { await using McpClient client = await CreateMcpClientForServer(); - var result = await SendAsync(client, SkillsProtocol.MethodSkillsList, []); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); - var entry = result!["skills"]!.AsArray() - .Single(skill => skill!["uri"]!.GetValue() == ListedSkillUri)!; - var frontmatter = entry["frontmatter"]!.AsObject(); - - Assert.Equal("git-workflow", frontmatter["name"]!.GetValue()); - Assert.Equal("The git-workflow skill", frontmatter["description"]!.GetValue()); + 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 SkillsGet_ReturnsTheRequestedEntry() + public async Task ReadSkillResourceAsync_ReturnsVerifiedText() { await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); - var result = await SendAsync( - client, - SkillsProtocol.MethodSkillsGet, - new JsonObject { ["uri"] = ListedSkillUri }); - var skill = result.Deserialize(McpSkillsJsonContext.Default.GetSkillResult); + var result = await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken); - Assert.NotNull(skill); - Assert.Equal(ListedSkillUri, skill.Skill.Uri); - Assert.False(skill.Skill.Resources.IsDynamic); + var text = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(GitWorkflowMarkdown, text.Text); + Assert.Equal("text/markdown", text.MimeType); } [Fact] - public async Task SkillsGet_CarriesNoPaginationCursor() + public async Task ReadSkillResourceAsync_ReturnsVerifiedBlobForBinaryFile() { await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); - var result = await SendAsync( - client, - SkillsProtocol.MethodSkillsGet, - new JsonObject { ["uri"] = ListedSkillUri }); + var result = await client.ReadSkillResourceAsync(skill, "skill://git-workflow/assets/logo.png", TestContext.Current.CancellationToken); - Assert.False(result!.AsObject().ContainsKey("nextCursor")); + 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 SkillsGet_WithUnknownUri_ReturnsInvalidParams() + public async Task ReadSkillResourceAsync_RejectsUnlistedFileWithoutContactingServer() { await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); - var exception = await Assert.ThrowsAsync( - async () => await SendAsync( - client, - SkillsProtocol.MethodSkillsGet, - new JsonObject { ["uri"] = "skill://missing/SKILL.md" })); - - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, "skill://git-workflow/references/NEW.md", TestContext.Current.CancellationToken)); } [Fact] - public async Task SkillsGet_WithMissingUri_ReturnsInvalidParams() + public async Task ReadSkillResourceAsync_DetectsContentThatNoLongerMatchesTheHeldEntry() { await using McpClient client = await CreateMcpClientForServer(); + var skill = await client.GetSkillAsync(GitWorkflowUri, TestContext.Current.CancellationToken); - var exception = await Assert.ThrowsAsync( - async () => await SendAsync(client, SkillsProtocol.MethodSkillsGet, [])); + // 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); - Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); + await Assert.ThrowsAsync( + async () => await client.ReadSkillResourceAsync(skill, GitWorkflowUri, TestContext.Current.CancellationToken)); } } 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 new file mode 100644 index 000000000..85bf07427 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -0,0 +1,479 @@ +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("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")] + [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("\"\\U0001F600\"", "😀")] + [InlineData("\"\\uD83D\\uDE00\"", "😀")] + [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 + 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] + 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(">", "\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")] + [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"); + + 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 ResolvesPlainMappingKeysLikeReferenceParsers() + { + var frontmatter = Parse("TRUE: a\n0x10: b\n~: c\n1.5: d\nplain: e"); + + Assert.Equal(["true", "16", "", "1.5", "plain"], frontmatter.Select(p => p.Key)); + } + + [Fact] + public void PreservesNonBreakingSpacesInScalars() + { + var frontmatter = Parse("value: \u00A0padded\u00A0\nother: a\u00A0b"); + + Assert.Equal("\u00A0padded\u00A0", frontmatter["value"]?.GetValue()); + Assert.Equal("a\u00A0b", frontmatter["other"]?.GetValue()); + } + + [Fact] + public void FlowMappingHonorsSeparatorRules() + { + var frontmatter = Parse(""" + a: {version:1} + b: {version: 1} + c: {"version":1} + d: {k, m: 2} + e: {TRUE: x} + """); + + AssertJson("""{ "a": { "version:1": null }, "b": { "version": 1 }, "c": { "version": 1 }, "d": { "k": null, "m": 2 }, "e": { "true": "x" } }""", frontmatter); + } + + [Fact] + public void RejectsNestingBeyondTheDepthLimit() + { + string deep = string.Join("\n", Enumerable.Range(0, 40).Select(i => new string(' ', i) + $"k{i}:")) + " v"; + + var exception = Assert.Throws(() => Parse(deep)); + Assert.Contains("nested more than", exception.Message); + + string shallow = string.Join("\n", Enumerable.Range(0, 20).Select(i => new string(' ', i) + $"k{i}:")) + " v"; + Assert.NotNull(Parse(shallow)); + } + + [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("-0", "0")] + [InlineData("-00", "0")] + [InlineData("-0.0", "0")] + [InlineData("+0", "0")] + public void NegativeZeroIsZero(string yaml, string expectedJson) + { + // Reference parsers render -0 as 0 (JavaScript has no distinct -0 in JSON; Python's int has none at all). + Assert.Equal("{\"value\":" + expectedJson + "}", Parse("value: " + yaml).ToJsonString()); + Assert.Equal("{\"" + expectedJson + "\":1}", Parse(yaml + ": 1").ToJsonString()); + } + + [Fact] + public void TabsSeparateValuesButDoNotIndent() + { + var frontmatter = Parse("a:\tone\nb:\n\t\nc: {d:\t2, \"e\"\t: 3}\nitems:\n -\tx\nblock: |\n \tindented by a tab\n \t\ntail: 'a\tb'"); + + AssertJson("""{ "a": "one", "b": null, "c": { "d": 2, "e": 3 }, "items": ["x"], "block": "\tindented by a tab\n\t\n", "tail": "a\tb" }""", frontmatter); + } + + [Fact] + public void BlockScalarsKeepWhiteSpaceOnLinesWiderThanTheirIndentation() + { + var frontmatter = Parse("lit: |\n a\n \n b\n \nfold: >\n a\n \n b\n"); + + AssertJson("""{ "lit": "a\n \nb\n \n", "fold": "a\n \nb\n" }""", frontmatter); + } + + [Fact] + public void SequenceItemsMayBeMappingsWithQuotedKeys() + { + var frontmatter = Parse("items:\n - \"k\": v\n w: 1\n - 'q': r\n - \"plain\" # not a key\n"); + + AssertJson("""{ "items": [ { "k": "v", "w": 1 }, { "q": "r" }, "plain" ] }""", frontmatter); + } + + [Fact] + public void KeysMayStartWithIndicatorsNotFollowedByWhiteSpace() + { + var frontmatter = Parse("?a: 1\n-a: 2\n-: 3\n:a: 4"); + + AssertJson("""{ "?a": 1, "-a": 2, "-": 3, ":a": 4 }""", frontmatter); + } + + [Fact] + public void ACommentEndsAPlainScalarOnAnyOfItsLines() + { + var frontmatter = Parse("a: one\n two # note\nb: 2"); + + AssertJson("""{ "a": "one two", "b": 2 }""", frontmatter); + } + + [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---", "multi-line flow collections")] + [InlineData("---\nname: {a: 1,\n b: 2}\n---", "multi-line flow collections")] + [InlineData("---\nname: [a, b\nother: c\n---", "unterminated flow sequence")] + [InlineData("---\nname: {a: b\nother: c\n---", "unterminated flow mapping")] + [InlineData("---\nname: x\n\tother: y\n---", "tabs")] + [InlineData("---\nitems:\n -\tk: v\n---", "tabs")] + [InlineData("---\nvalue: |\n a\n\t\n b\n---", "tabs")] + [InlineData("---\nvalue: a\n b # comment\n c\n---", "a comment ended that value")] + [InlineData("---\nvalue: ]x\n---", "flow indicator")] + [InlineData("---\nvalue: }x\n---", "flow indicator")] + [InlineData("---\nvalue: ,x\n---", "flow indicator")] + [InlineData("---\nvalue: ?\n---", "block indicator")] + [InlineData("---\nvalue: ? a\n---", "block indicator")] + [InlineData("---\n&a: x\n---", "anchors")] + [InlineData("---\n*a: x\n---", "anchors")] + [InlineData("---\n!t: x\n---", "anchors")] + [InlineData("---\n@a: x\n---", "reserves")] + [InlineData("---\n`a: x\n---", "reserves")] + [InlineData("---\n%a: x\n---", "reserves")] + [InlineData("---\n]a: x\n---", "flow indicator")] + [InlineData("---\n,a: x\n---", "flow indicator")] + [InlineData("---\n|: x\n---", "block scalar header")] + [InlineData("---\n[a]: x\n---", "flow collections are not supported as mapping keys")] + [InlineData("---\n{a: 1}: x\n---", "flow collections are not supported as mapping keys")] + [InlineData("---\nvalue: [,]\n---", "unexpected ','")] + [InlineData("---\nvalue: [a,,b]\n---", "unexpected ','")] + [InlineData("---\nvalue: [,a]\n---", "unexpected ','")] + [InlineData("---\nvalue: {,}\n---", "unexpected ','")] + [InlineData("---\nvalue: {a: 1,,b: 2}\n---", "unexpected ','")] + [InlineData("---\nvalue: [- b]\n---", "block indicator")] + [InlineData("---\nvalue: [}]\n---", "flow indicator")] + [InlineData("---\nvalue: {]: 1}\n---", "flow indicator")] + [InlineData("---\nvalue: [\"a\": b]\n---", "compact mappings inside flow sequences")] + [InlineData("---\nvalue: |\n \n a\n---", "leading empty line")] + [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 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: \"\\uD800\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: \"\\uDE00x\"\n---", "unpaired surrogate")] + [InlineData("---\nvalue: [a: b]\n---", "compact mappings inside flow sequences")] + [InlineData("---\ndescription: first # note\n second\n---", "a comment ended that value")] + [InlineData("---\nitems:\n - first # note\n second\n---", "a comment ended that value")] + [InlineData("---\nname: \"never closed\ndescription: d\n---", "unterminated quoted scalar")] + [InlineData("---\nvalue: {a: b # c}\n---", "comment inside a flow collection")] + [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")] + [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) + { + // 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); + } + + [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 7a5b34eb7ec23e90fabb9fa374da313bf227e016 Mon Sep 17 00:00:00 2001 From: Girish Konda Date: Sat, 10 Oct 2026 16:11:47 -0700 Subject: [PATCH 3/5] Repair Skills cache hints and frontmatter parsing Add ICacheableResult and source-generated cache-hint serialization to skills/get. Apply the configured hints or zero/private defaults on 2026-07-28 requests, and keep all versioned fields off 2025-11-25 replies. Read scalar values starting on the next line, preserve folded line breaks around TAB-led content, and reject comments without whitespace after a quoted scalar or flow collection. Keep plain hashes, existing chomping rules and non-JSON number rejection intact. Add parser, authoring, wire and serialization regressions. Exercise the new cache fields and authoring cases in the native AOT app. Update the cache option docs and pin both sample links to their published source. --- docs/concepts/skills/skills.md | 79 ++++++-- samples/SkillsClient/Program.cs | 31 +++- samples/SkillsClient/README.md | 17 +- samples/SkillsServer/README.md | 7 + .../Client/McpSkillsClientExtensions.cs | 27 ++- .../Client/SkillVerificationException.cs | 4 +- .../Client/SkillVerifier.cs | 10 +- .../Protocol/GetSkillResult.cs | 12 +- .../Protocol/Skill.cs | 7 +- .../Server/IMcpSkillCatalog.cs | 6 + .../Server/InMemoryMcpSkillCatalog.cs | 5 +- .../Server/McpServerSkill.cs | 79 +++++--- .../Server/McpSkillsBuilderExtensions.cs | 17 +- .../Server/McpSkillsOptions.cs | 11 +- .../SkillFrontmatter.cs | 168 ++++++++++++++---- .../Program.cs | 19 +- .../Protocol/SkillSerializationTests.cs | 60 +++++++ .../McpServerSkillAuthoringBoundaryTests.cs | 74 ++++++++ .../Server/McpServerSkillTests.cs | 6 +- .../Server/McpServerSkillsCatalogTests.cs | 10 +- .../Server/McpServerSkillsDefaultsTests.cs | 40 ++++- .../Server/McpServerSkillsOwnershipTests.cs | 165 +++++++++++++++++ ...SkillFrontmatterNodeClassificationTests.cs | 98 ++++++++++ .../Server/SkillFrontmatterRegressionTests.cs | 142 +++++++++++++++ 24 files changed, 977 insertions(+), 117 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 3ceef88fd..48b95399a 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -31,8 +31,19 @@ A skill entry () is a complet 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. +one against the manifest. Before loading `SKILL.md`, it must also parse the fetched YAML and compare every +frontmatter field with the held entry. Digest and size verification alone does not establish that equality. + +### Responsibility boundaries + +The protocol carries structured metadata; it does not require a particular parser or provider. + +| Layer | Responsibility | +|---|---| +| Application / catalog | Supply frontmatter identical to the complete frontmatter in the file, and serve matching file bytes and manifests. Structural entry validation does not read or validate the YAML on a catalog's behalf. | +| Optional automatic authoring helper | `McpServerSkill.Create` / `CreateFromDirectory` without explicit metadata use the bounded `SkillFrontmatter` reader. Unsupported or malformed input is rejected, not replaced with guessed metadata. | +| Explicit-metadata authoring | Keep the helper's enforced checks. If the bounded reader cannot fully read unsupported YAML, the application must parse and validate the entire frontmatter with a suitable mechanism and supply the correct JSON. | +| Host | Independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before loading, regardless of server authoring path. Reject unsupported input or use a suitable full parser; server-provided JSON is not verification. | ### Serving skills @@ -75,7 +86,7 @@ var refunds = McpServerSkill.Create( builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]); ``` -All of these validate the skill against the specification and throw with a +All of these validate structural entry and file constraints and throw with a specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the Agent Skills limit of 1024 characters, a resource URI escapes the skill's directory, `SKILL.md` is missing, or the skill exceeds the per-skill limits of 512 files or 16 MiB. Frontmatter fields beyond `name`, `description`, @@ -88,20 +99,28 @@ names containing characters with URI syntax (such as `{`, `?`, or a space) are p #### Frontmatter reads the YAML frontmatter of a `SKILL.md` into a - without a YAML library. It accepts the subset of YAML that Agent Skills -frontmatter uses: block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block + without a YAML library. This is an optional bounded reader, not a +requirement for serving structured metadata through a catalog. It supports block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block 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 +floats, otherwise strings). YAML parsers can differ in their supported forms and resolution; identical semantics +across other SDKs or libraries are not assumed. That matters because a host verifies a skill by parsing the fetched `SKILL.md` itself and comparing field by field against the published entry; a value that one side types as a number and the other as a string is a verification failure. Quote values such as version numbers that are meant to be strings. -Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are -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. +Automatic authoring requires the bounded reader to succeed. Unsupported constructs and detected malformed input +raise an error; no fallback object is invented. Unsupported forms include anchors, aliases, tags, complex keys, +nested and multi-line flow collections, and multi-line quoted scalars. + +The explicit `JsonObject` overloads retain UTF-8 and frontmatter-boundary checks, entry/URI validation, and manifest +digest/size computation from the served bytes. They also reject format errors detected by the reader and reject +metadata that differs from a frontmatter block the reader can parse completely. + +**The explicit overloads do not guarantee whole-document YAML validation.** An unsupported construct can stop +parsing before later content is examined. The application must parse and validate the **entire frontmatter** +with a suitable mechanism and supply identical JSON before relying on that path. An unsupported-format error +is not proof that the rest of the document is valid, and acceptance of supplied metadata is not proof of equality. +This boundary does not relax the specification's requirement that published frontmatter match the file. #### Custom catalogs @@ -128,7 +147,9 @@ the caller's when the transport su 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 +`resources/read`. The application/catalog author must supply frontmatter identical to the file. The transport +validates entry structure, not full file contents, and does not require use of the optional authoring reader. +A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers for every skill the server serves to the caller. Throw with for a cursor the catalog cannot interpret. is the built-in implementation over a fixed @@ -139,14 +160,16 @@ set of entries and can be composed into a custom one. 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. +If a host accepts a dynamic skill, parsing and comparing its fetched frontmatter is still required. #### Protocol versions and caching hints -`skills/list` results carry the base protocol's `ttlMs` and `cacheScope` list-caching attributes, configured through +`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, configured through . Those attributes, and `resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in -list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. +list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use +the same configured hints. Only use `public` when both the listing and individual entries are identical for every caller. ### Consuming skills @@ -155,6 +178,8 @@ host's side of the protocol: ```csharp using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; if (!client.SupportsSkills()) { @@ -169,6 +194,19 @@ Skill skill = await client.GetSkillAsync("skill://git-workflow/SKILL.md"); // Read a file and verify it against the held entry's digest and size. ReadResourceResult contents = await client.ReadSkillResourceAsync(skill, skill.Uri); + +// This example loads text SKILL.md resources and rejects other content forms. +if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextResourceContents text) +{ + throw new SkillVerificationException("A text SKILL.md is required for this example."); +} + +// A parse failure, including unsupported YAML, rejects this load. Do not fall back to skill.Frontmatter. +var fetchedFrontmatter = SkillFrontmatter.Parse(text.Text); +if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) +{ + throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); +} ``` `ListSkillsAsync` and `GetSkillAsync` validate every entry the server returns against the specification's structural @@ -206,10 +244,13 @@ 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` automatically. A host can do so with +the fetched `SKILL.md` automatically. **The host must perform that independent check before loading**, regardless +of whether the server used a catalog, automatic authoring, or explicit metadata. For the supported subset it can use on the fetched text and -against the entry's frontmatter, treating a parse failure or a difference as a verification failure. +against the entry's frontmatter. Unsupported content must be rejected or parsed with a suitable full parser; +malformed input or a difference is a verification failure. Never use the server's JSON as a substitute for parsing +the fetched file. These examples do not implement the host's complete approval, origin-isolation or loading policy. ### Not implemented @@ -218,7 +259,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](https://github.com/modelcontextprotocol/csharp-sdk/tree/ecb11e621b0ae46685314729a74c0cb8fe283d44/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 +- [SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/ecb11e621b0ae46685314729a74c0cb8fe283d44/samples/SkillsClient): a client that connects to it and discovers, retrieves, and verifies them. diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs index 4f80b1829..464d58044 100644 --- a/samples/SkillsClient/Program.cs +++ b/samples/SkillsClient/Program.cs @@ -3,7 +3,7 @@ // 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. +// 4. Verify fetched bytes against the manifest, then independently parse and compare SKILL.md frontmatter. // 5. Show that a file the manifest does not list is refused before any request is sent. // // Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as @@ -12,6 +12,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; +using System.Text.Json.Nodes; var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001"); @@ -63,12 +64,32 @@ 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. +// 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter. +// The host must independently verify frontmatter before loading, regardless of the server's authoring path. Console.WriteLine("=== resources/read (verified) ==="); ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); -Console.WriteLine($" {SkillUri} verified:"); -Console.WriteLine(Indent(((TextResourceContents)skillFile.Contents[0]).Text)); +if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText) +{ + throw new SkillVerificationException("This sample requires text SKILL.md contents for frontmatter verification."); +} + +try +{ + var fetchedFrontmatter = SkillFrontmatter.Parse(skillText.Text); + if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) + { + throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry."); + } +} +catch (FormatException exception) +{ + // This sample rejects unsupported YAML. A full host may instead use a suitable full parser, + // but must never treat the server-provided JSON as verification of the fetched file. + throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception); +} + +Console.WriteLine($" {SkillUri} manifest and frontmatter verified:"); +Console.WriteLine(Indent(skillText.Text)); string supportingFileUri = skill.Resources.Resources.First(r => r.Uri != SkillUri).Uri; ReadResourceResult supportingFile = await client.ReadSkillResourceAsync(skill, supportingFileUri); diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md index a92591154..7c91b8621 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -10,8 +10,9 @@ doing what a host's skill-loading path does: 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)`). +4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size + (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, independently parses its + frontmatter and compares every field with the held entry. 5. Shows that a file the manifest does not list is refused before any request is sent. ## Run @@ -52,7 +53,7 @@ Expected output (abridged): ... === resources/read (verified) === - skill://git-workflow/SKILL.md verified: + skill://git-workflow/SKILL.md manifest and frontmatter verified: | --- | name: git-workflow ... @@ -65,5 +66,11 @@ 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 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. +- Digest and size verification are not frontmatter verification. Every host must independently parse the + fetched `SKILL.md` and compare all fields with the held entry before loading, regardless of how the server + authored the metadata. +- This sample accepts text `SKILL.md` resources and uses `SkillFrontmatter.Parse` for its supported subset. + It rejects mismatches, malformed input and unsupported YAML. A host needing other YAML forms must use a + suitable full parser, never the server-provided JSON as a verification fallback. +- This is not a complete host implementation: approval binding, origin isolation and permission decisions + remain application responsibilities. diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md index 6ec5bb535..4af29fd06 100644 --- a/samples/SkillsServer/README.md +++ b/samples/SkillsServer/README.md @@ -15,6 +15,11 @@ SHA-256 digest and size of every file, and registers the `skills/list` and `skil 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/`. +This is the optional automatic provider path. Its bounded `SkillFrontmatter` reader rejects unsupported or +malformed input rather than guessing metadata. Applications may instead supply structured metadata through a +catalog. When using explicit-metadata authoring for unsupported YAML, the application must parse and validate +the entire frontmatter and supply identical JSON; acceptance by the helper is not proof of full YAML validity. + ## Run ```bash @@ -39,5 +44,7 @@ Any MCP host that supports Streamable HTTP can also be pointed at `http://localh 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. +- Hosts must independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before + loading, regardless of the server's authoring path. Digest/size checks alone do not perform that comparison. - 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/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index f24319f29..fd53d0128 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -5,9 +5,16 @@ namespace ModelContextProtocol.Extensions.Skills; /// -/// Extension methods for to discover, retrieve, and verify skills served under the +/// Extension methods for to discover skills and verify their file manifests under the /// MCP Skills extension (SEP-2640). /// +/// +/// Listing and retrieval validate structured entries, not the contents of their files. +/// verifies manifest membership, URI, digest and size, not frontmatter. +/// Before loading a skill, the host must separately parse the fetched SKILL.md and compare every +/// frontmatter field with the held entry. Use only for its supported subset; +/// reject unsupported content or use a suitable full parser, never the server's JSON as a verification fallback. +/// public static class McpSkillsClientExtensions { /// @@ -77,7 +84,7 @@ 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, with every entry validated against the specification. + /// The page with every entry structurally validated, but not compared with the skill's files. /// or is . /// The server did not declare the MCP Skills extension. /// The server returned an entry that violates the specification. @@ -125,7 +132,7 @@ 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, validated against the specification. + /// The structurally validated skill entry, not a verification of the file's frontmatter. /// or is . /// The server did not declare the MCP Skills extension. /// @@ -158,7 +165,7 @@ 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, with the entry validated against the specification. + /// The result with its entry structurally validated, but not compared with the skill's files. /// or is . /// The server did not declare the MCP Skills extension. /// @@ -201,18 +208,18 @@ public static async ValueTask GetSkillAsync( } /// - /// Reads one of a skill's files through resources/read and verifies the content against the skill's manifest. + /// Reads one of a skill's files through resources/read and verifies its URI, digest and size against the manifest. /// /// The client. /// The skill entry being acted on. /// The URI of the file to read. It must be listed in 's manifest. /// The to monitor for cancellation requests. The default is . - /// The verified contents. + /// The manifest-verified contents. Frontmatter verification remains the host's responsibility. /// An argument is . /// /// 's manifest is . Such a skill offers no content /// integrity; read its files with - /// only if the host has decided to load unverifiable skills. + /// only if the host permits content without digest verification. Frontmatter verification is still required. /// /// /// is not listed in the manifest, the server's response does not contain contents for @@ -220,9 +227,15 @@ public static async ValueTask GetSkillAsync( /// /// The request failed or the server returned an error response. /// + /// /// An unlisted file is treated as a verification failure before any request is sent, because the manifest is /// complete and an unlisted file is a change to the skill. To read it, refresh the entry with /// and proceed from the new manifest. + /// + /// + /// For SKILL.md, the host must also parse the fetched frontmatter and compare it with + /// before loading. A digest match does not perform or replace that check. + /// /// public static async ValueTask ReadSkillResourceAsync( this McpClient client, diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs index 7f1c153d7..16653ce52 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerificationException.cs @@ -1,8 +1,8 @@ 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. +/// The exception thrown when a skill entry or its file content fails validation, including a manifest mismatch +/// or an unlisted file. Hosts can also use it for a failed frontmatter comparison. /// /// /// A mismatch means the content is not what the entry described. It may be corrupted, tampered with, or stale diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index a10aacf12..10d090f66 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -12,9 +12,11 @@ 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 performed automatically; a host can do it with -/// and . +/// helpers implement those manifest checks, not frontmatter verification. Before loading a skill, the host must +/// independently parse the fetched SKILL.md and compare every frontmatter field with the held entry, +/// regardless of the server's authoring path. It can use and +/// for the supported subset. Unsupported content must be +/// rejected or read with a suitable full parser; server-supplied JSON is never a verification fallback. /// /// /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest @@ -234,7 +236,7 @@ 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. + /// ) or dynamic, neither of which has a manifest that can be digest-verified. /// internal static void ThrowIfDynamic(Skill skill) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs index 2c9432fc3..805ac0a9f 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/GetSkillResult.cs @@ -16,11 +16,21 @@ namespace ModelContextProtocol.Extensions.Skills; /// for details. /// /// -public sealed class GetSkillResult : Result +public sealed class GetSkillResult : Result, ICacheableResult { /// /// Gets or sets the skill's entry, identical in shape and meaning to an entry of skills/list. /// [JsonPropertyName("skill")] public required Skill Skill { get; set; } + + /// + [JsonPropertyName("ttlMs")] + [JsonConverter(typeof(TimeSpanMillisecondsConverter))] + public TimeSpan? TimeToLive { get; set; } + + /// + [JsonPropertyName("cacheScope")] + [JsonConverter(typeof(CacheScopeConverter))] + public CacheScope? CacheScope { get; set; } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs index c604df30d..937b8b5ab 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs @@ -35,9 +35,10 @@ public sealed class Skill /// /// /// 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. + /// are always present. The application or catalog author must supply metadata identical to the file's + /// complete frontmatter. Transport-level structural validation does not read or validate that file. + /// Hosts must independently parse the fetched SKILL.md and compare its frontmatter against this object + /// field by field before loading, treating any discrepancy as a verification failure. /// [JsonPropertyName("frontmatter")] public required JsonObject Frontmatter { get; set; } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs index c6bcd6377..2fe5b8161 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -18,6 +18,12 @@ namespace ModelContextProtocol.Extensions.Skills; /// resources through resources/read, since that is how hosts fetch skill content. /// /// +/// The application must supply identical to the complete frontmatter in the +/// corresponding file. Skills transport handlers validate entry structure; they do not read files or perform +/// full YAML validation for a catalog. is an optional authoring helper, not a +/// requirement for implementing this interface. Hosts independently verify fetched frontmatter before loading. +/// +/// /// 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. diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs index 652026641..e8383358d 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -7,8 +7,9 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// -/// 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. +/// Every entry is validated against the specification's structural requirements when the catalog is constructed. +/// The catalog does not read skill files or compare their frontmatter. The application must supply identical +/// file metadata, and hosts independently parse and compare fetched frontmatter before loading a skill. /// /// /// The catalog keeps its own copy of every entry and hands out copies, so neither later changes to the objects diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 35b04303a..b37740df5 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -18,11 +18,16 @@ namespace ModelContextProtocol.Extensions.Skills; /// resources serve, so the two cannot disagree. /// /// -/// The skill's frontmatter is read from its SKILL.md by . Hosts re-parse the -/// fetched SKILL.md and compare it field by field against the published entry, treating any discrepancy as -/// a verification failure, so the two must agree. The overloads that accept an explicit -/// exist for frontmatter the reader cannot handle; when it can read the file, an explicit object that differs from -/// it is rejected. +/// Overloads without explicit metadata use the optional bounded reader and +/// reject unsupported or malformed input. Overloads accepting a compare it with the +/// file when the reader can read the complete frontmatter, and reject a mismatch or a detected format error. +/// When parsing stops at an unsupported construct, the application must have parsed and validated the entire +/// frontmatter with a suitable mechanism and supplied identical JSON. This is not a whole-document YAML validator. +/// +/// +/// Hosts must independently parse the fetched SKILL.md and compare its frontmatter field by field with +/// the held entry before loading the skill, regardless of the authoring overload. Manifest digest and size +/// verification does not perform that comparison. /// /// /// Register skills with , @@ -47,8 +52,8 @@ private McpServerSkill(Skill entry, IReadOnlyList resources) /// Gets a copy of the skill's entry, as returned by skills/list and skills/get. /// /// - /// Each access returns a new copy. The entry was computed from the same bytes serve, - /// and the skill keeps that entry itself, so changes made to a returned copy affect neither the skill nor + /// Each access returns a new copy. The manifest was computed from the same bytes serve; + /// frontmatter was read or supplied at creation. Changes to a returned copy affect neither the skill nor /// what WithSkills registers. /// public Skill ProtocolSkill => SkillValidation.Snapshot(_entry); @@ -70,6 +75,10 @@ private McpServerSkill(Skill entry, IReadOnlyList resources) /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (see /// ) or is missing a required field. /// + /// + /// The bounded reader must read the frontmatter successfully. Unsupported or malformed input is rejected, + /// not replaced with guessed metadata. Hosts still verify frontmatter independently when loading the skill. + /// public static McpServerSkill Create(IEnumerable files) { #if NET @@ -97,6 +106,9 @@ public static McpServerSkill Create(IEnumerable files) /// frontmatter cannot be read (see ), is missing a required field, or has a /// name that does not match . /// + /// + /// Uses the same bounded reader and rejection behavior as . + /// public static McpServerSkill Create(string uri, IEnumerable files) { #if NET @@ -126,15 +138,24 @@ public static McpServerSkill Create(string uri, IEnumerable /// An argument is . /// /// does not end in /SKILL.md; is missing a required - /// field, has a name that does not match , or differs from the frontmatter in - /// SKILL.md; or omits SKILL.md, contains a duplicate or unsafe path, or - /// exceeds the specification's per-skill limits. + /// field, has a name that does not match , or differs from frontmatter the reader can + /// read completely; or omits SKILL.md, contains a duplicate or unsafe path, + /// exceeds the specification's per-skill limits, or has invalid UTF-8, missing frontmatter boundaries, + /// or a format error detected by the reader. /// /// - /// Prefer , which reads the frontmatter from the - /// file. This overload 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. + /// + /// Prefer for the supported YAML subset. This + /// overload retains UTF-8 and frontmatter-boundary checks, structural entry and URI validation, and manifest + /// computation from the served bytes. Detected malformed input is rejected. When + /// reads the complete block, the supplied object must match it exactly. + /// + /// + /// When the bounded reader reports unsupported syntax, it may not examine later content. The application + /// must parse and validate the entire frontmatter with a suitable mechanism and supply identical JSON. + /// Acceptance of an explicit object is not proof that unsupported YAML is valid or matches the file. + /// Hosts still perform their own frontmatter verification before loading the skill. + /// /// public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) { @@ -169,6 +190,10 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera /// manifest or the served content. /// /// + /// This automatic overload uses the bounded reader. Unsupported or malformed + /// frontmatter is an error, not a reason to publish guessed metadata. + /// + /// /// 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 @@ -237,10 +262,17 @@ public static McpServerSkill CreateFromDirectory(string uri, string directoryPat /// 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. + /// + /// Prefer for supported YAML. This overload has the same + /// checks and application-owned full-frontmatter validation responsibility as + /// . An unsupported construct can + /// stop the reader before later content is examined; the application must validate the complete block and + /// provide matching JSON. When the reader can read it completely, mismatched metadata is rejected. + /// + /// + /// See for how files and links are handled. Hosts independently + /// verify fetched frontmatter regardless of this authoring path. + /// /// public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) { @@ -316,10 +348,10 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj contents[i] = normalized[i].File.Content.ToArray(); } - // Read the frontmatter from SKILL.md (always first after sorting). When the caller supplied frontmatter, - // the file is still read so that the two can be checked against each other. The caller's frontmatter - // stands on its own only when the file uses valid YAML the reader does not support; a file that is not - // UTF-8, has no frontmatter block, or is malformed cannot be reproduced by any host and is rejected. + // Read frontmatter from SKILL.md (always first after sorting). UTF-8, boundaries, detected format errors, + // and equality for completely parsed blocks are checked even when metadata was supplied. An unsupported + // construct can stop the reader before later content is examined. In that case the caller owns parsing + // and validating the entire frontmatter and supplying matching JSON; this is not full YAML validation. if (!TryDecodeUtf8(contents[0], out string? skillMarkdown)) { throw new ArgumentException($"{SkillsProtocol.SkillFileName} is not valid UTF-8.", filesParamName); @@ -339,7 +371,8 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj } catch (SkillFrontmatter.UnsupportedYamlException) { - // Explicit frontmatter covers this case. + // The application must already have validated the entire frontmatter and supplied matching JSON. + // Reaching an unsupported construct establishes neither whole-document validity nor equality. } catch (FormatException e) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index 1bfa446b9..d7b8dcc12 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -31,6 +31,10 @@ public static class McpSkillsBuilderExtensions /// are served through resources/list and resources/read. /// /// + /// Frontmatter is read or supplied when each is created. Registration does not + /// perform additional YAML parsing or establish that application-supplied metadata matches unsupported YAML. + /// + /// /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, /// provided every skill lists it with the same digest and the identical URI text. URIs that differ only in the /// case of their scheme or authority are rejected, because the server's resource collection treats them as one. @@ -132,8 +136,10 @@ public static IMcpServerBuilder WithSkills( /// /// /// 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. + /// digests are computed from the bytes served, and symbolic links are rejected. This optional provider uses + /// the bounded reader; unsupported or malformed frontmatter is an error. + /// Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one + /// of that skill's files. /// /// /// A subdirectory without a SKILL.md is not a skill and is ignored, so shared assets or documentation can @@ -235,6 +241,11 @@ public static IMcpServerBuilder WithSkillsFromDirectory( /// overload. /// /// + /// The application or catalog author must supply structured frontmatter identical to the file. This + /// overload validates entry structure but does not fetch the files or validate their YAML. Hosts must + /// independently parse and compare fetched frontmatter before loading any skill. + /// + /// /// 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 @@ -351,6 +362,8 @@ public void Configure(McpServerOptions options) if (IsJuly2026OrLaterProtocolRequest(request)) { result.ResultType = "complete"; + result.TimeToLive = skillsOptions.TimeToLive.GetValueOrDefault(); + result.CacheScope = skillsOptions.CacheScope.GetValueOrDefault(CacheScope.Private); } return JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult); diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs index c0f2ca926..c674e1a34 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsOptions.cs @@ -8,12 +8,13 @@ namespace ModelContextProtocol.Extensions.Skills; public sealed class McpSkillsOptions { /// - /// Gets or sets the freshness hint advertised on skills/list results. + /// Gets or sets the freshness hint advertised on skills/list and skills/get 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. + /// This is the base protocol's ttlMs caching attribute, with the same semantics as on + /// tools/list. It is a freshness hint about the returned listing or entry, not an integrity property + /// of the skills' content. /// /// /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that @@ -24,12 +25,12 @@ public sealed class McpSkillsOptions public TimeSpan? TimeToLive { get; set; } /// - /// Gets or sets the cache scope advertised on skills/list results. + /// Gets or sets the cache scope advertised on skills/list and skills/get results. /// /// /// /// Set this to only when the catalog is identical for every caller. - /// A listing that varies by principal must not be advertised as publicly cacheable. + /// A listing or entry that varies by principal must not be advertised as publicly cacheable. /// /// /// The attribute is defined from protocol revision 2026-07-28. On a request negotiated under that diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index 2f38cefe0..34c3a7667 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -6,22 +6,28 @@ 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. +/// Reads a supported subset of SKILL.md YAML frontmatter into a , without a YAML library. +/// This optional helper supports server authoring and host-side comparison for that subset. /// /// /// -/// 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, multi-line flow collections, and tab indentation are rejected. +/// This reader supports block mappings, nested mappings and sequences, plain, quoted and block scalars, +/// and comments within its bounded YAML subset. Detected malformed input and unsupported constructs throw +/// ; unsupported forms include anchors, aliases, tags, complex keys and multi-line +/// flow collections. Parsing can stop at the first unsupported construct, so an exception is not proof that +/// the rest of the frontmatter is valid. This helper is not a general-purpose YAML validator. /// /// /// 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. +/// otherwise strings). Other YAML parsers can differ in the forms and values they support; an application +/// supplying explicit metadata is responsible for parsing and validating the entire frontmatter with a +/// suitable mechanism and providing the correct JSON representation. +/// +/// +/// Before loading a skill, hosts must parse the fetched SKILL.md and compare every frontmatter field with +/// the held entry, regardless of how the server authored it. A host may use this reader for its supported subset. +/// On unsupported or malformed input, the host must reject the content or use a suitable full parser; it must +/// not substitute the server-provided JSON for verification. /// /// public static class SkillFrontmatter @@ -35,18 +41,18 @@ public static class SkillFrontmatter private const int MaxNestingDepth = 32; /// - /// 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. + /// Thrown when the reader encounters an unsupported construct. Parsing may stop before the remaining + /// frontmatter is examined; this exception does not establish whole-document validity or metadata equality. /// internal sealed class UnsupportedYamlException(string message) : FormatException(message); - /// Thrown when a quoted scalar has no closing quote on its line. Malformed unless a later line closes it. + /// Thrown when a quoted scalar has no closing quote on its line; a later delimiter may indicate an unsupported multi-line form. private sealed class UnterminatedQuoteException(string message, char quote) : FormatException(message) { public char Quote { get; } = quote; } - /// Thrown when a flow collection has no closing bracket on its line. Malformed unless a later line closes it. + /// Thrown when a flow collection has no closing bracket on its line; a later delimiter may indicate an unsupported multi-line form. private sealed class UnterminatedFlowException(string message, char closer) : FormatException(message) { public char Closer { get; } = closer; @@ -214,7 +220,7 @@ private void SkipBlank() } /// Parses the block node formed by the lines at . - private JsonNode? ParseBlock(int indent) + private JsonNode? ParseBlock(int indent, int parentIndent = -1) { SkipBlank(); var line = _lines[_pos]; @@ -226,7 +232,27 @@ private void SkipBlank() _depth++; try { - return IsSequenceEntry(line.Content) ? ParseSequence(indent) : ParseMapping(indent); + if (IsSequenceEntry(line.Content)) + { + return ParseSequence(indent); + } + + bool isExplicitMappingKey = line.Content[0] == '?' && + (line.Content.Length == 1 || line.Content[1] is ' ' or '\t'); + bool isScalarValue = parentIndent >= 0 && + line.Content[0] is not ('&' or '*' or '!') && + !isExplicitMappingKey && + !IsCompactMappingEntry(line.Content, line.Number) && + !IsFlowCollectionKey(line.Content, line.Number); + if (isScalarValue) + { + ThrowIfTabIndented(line); + var rest = PrepareValue(line.Content, out bool commentEndedValue); + _pos++; + return ParseValue(rest, commentEndedValue, parentIndent, line.Number, allowSameIndentSequence: false); + } + + return ParseMapping(indent); } finally { @@ -237,6 +263,84 @@ private void SkipBlank() private static bool IsSequenceEntry(string content) => content.Length > 0 && content[0] == '-' && (content.Length == 1 || content[1] is ' ' or '\t'); + private bool IsFlowCollectionKey(string text, int lineNumber) + { + if (text[0] is not ('[' or '{')) + { + return false; + } + + // Find the flow node's boundary without resolving its contents as JSON values. + var closers = new Stack(); + bool inPlainScalar = false; + for (int i = 0; i < text.Length; i++) + { + char c = text[i]; + switch (c) + { + case '[' or '{': + if (closers.Count >= MaxNestingDepth) + { + return false; + } + + closers.Push(c == '[' ? ']' : '}'); + inPlainScalar = false; + break; + + case ']' or '}': + if (closers.Count == 0 || + closers.Pop() != c) + { + return false; + } + + if (closers.Count == 0) + { + int colon = SkipSpaces(text, i + 1); + return colon < text.Length && + text[colon] == ':' && + (colon + 1 == text.Length || text[colon + 1] is ' ' or '\t'); + } + + inPlainScalar = false; + break; + + case '"' or '\'' when !inPlainScalar: + try + { + ParseQuotedScalar(text, i, lineNumber, out int end); + i = end - 1; + } + catch (FormatException) + { + return false; + } + + break; + + case ',': + case ':' when !inPlainScalar || + i + 1 == text.Length || + text[i + 1] is ' ' or '\t': + inPlainScalar = false; + break; + + case '#' when i == 0 || text[i - 1] is ' ' or '\t': + return false; + + case ' ' or '\t': + break; + + default: + inPlainScalar = true; + break; + } + } + + return false; + } + /// /// Whether a sequence item's text is a "key: value" line, with a plain or quoted key, that begins a nested /// block mapping. @@ -271,7 +375,7 @@ private static void ThrowIfTabIndented(Line line) /// /// Rejects a plain scalar that starts with a character YAML gives another meaning: an anchor, alias, or tag - /// (valid YAML this reader does not support), a reserved character, a flow indicator, a block scalar header, + /// (constructs this reader does not support), a reserved character, a flow indicator, a block scalar header, /// or a block indicator followed by white space. /// private static void ThrowIfReservedPlainStart(string text, int lineNumber) @@ -419,7 +523,7 @@ private JsonArray ParseSequence(int indent) { // "- " 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); + result.Add(next is { } n && n.Indent > indent ? ParseBlock(n.Indent, indent) : null); continue; } @@ -460,7 +564,7 @@ private JsonArray ParseSequence(int indent) { if (n.Indent > parentIndent) { - return ParseBlock(n.Indent); + return ParseBlock(n.Indent, parentIndent); } if (allowSameIndentSequence && n.Indent == parentIndent && IsSequenceEntry(n.Content)) @@ -487,8 +591,8 @@ private JsonArray ParseSequence(int indent) } catch (UnterminatedFlowException e) when (ClosesLater(e.Closer)) { - // Valid YAML (a flow collection spanning lines) that this reader does not support, as - // opposed to a collection that is never closed, which is malformed. + // A later delimiter may indicate an unsupported multi-line flow collection. This + // classification does not validate its contents or the rest of the frontmatter. throw new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter."); } @@ -501,12 +605,12 @@ private JsonArray ParseSequence(int indent) } catch (UnterminatedQuoteException e) when (ClosesLater(e.Quote)) { - // Valid YAML (a quoted scalar spanning lines) that this reader does not support, as opposed - // to a quote that is never closed, which is malformed and stays a plain FormatException. + // A later delimiter may indicate an unsupported multi-line quoted scalar. This + // classification does not validate its contents or the rest of the frontmatter. throw new UnsupportedYamlException($"Line {lineNumber}: multi-line quoted scalars are not supported in frontmatter."); } - if (StripComment(rest.Substring(consumed)).Trim(s_yamlWhitespace).Length != 0) + if (StripComment(rest.Substring(consumed), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) { throw new FormatException($"Line {lineNumber}: unexpected content after the quoted value."); } @@ -737,7 +841,7 @@ private string CollectPlainContinuation(string first, int parentIndent, int line continue; } - bool moreIndented = text[0] == ' '; + bool moreIndented = text[0] is ' ' or '\t'; if (builder.Length == 0) { // Leading empty lines are content: each becomes a line break. @@ -819,7 +923,7 @@ private static JsonArray ParseFlowSequence(string text, int lineNumber) } } - if (StripComment(text.Substring(pos)).Trim(s_yamlWhitespace).Length != 0) + if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) { throw new FormatException($"Line {lineNumber}: unexpected content after the flow sequence."); } @@ -923,7 +1027,7 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) } } - if (StripComment(text.Substring(pos)).Trim(s_yamlWhitespace).Length != 0) + if (StripComment(text.Substring(pos), allowLeadingComment: false).Trim(s_yamlWhitespace).Length != 0) { throw new FormatException($"Line {lineNumber}: unexpected content after the flow mapping."); } @@ -1282,14 +1386,16 @@ private static string PrepareValue(string rest, out bool commentEndedValue) } /// - /// 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. + /// Removes a trailing comment from plain text. A comment must follow whitespace unless the text already + /// starts at a separation boundary; quotes inside plain text never suppress a comment. /// - private static string StripComment(string text) + private static string StripComment(string text, bool allowLeadingComment = true) { for (int i = 0; i < text.Length; i++) { - if (text[i] == '#' && (i == 0 || text[i - 1] is ' ' or '\t')) + bool isSeparated = i == 0 ? allowLeadingComment : text[i - 1] is ' ' or '\t'; + if (text[i] == '#' && + isSeparated) { return text.Substring(0, i); } diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 80ae614c1..dd2adc998 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -18,8 +18,7 @@ [ 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")]), + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]), ]); await using var serviceProvider = services.BuildServiceProvider(); @@ -57,7 +56,21 @@ throw new Exception($"Unexpected skills listing: {skills.Count} entries."); } -var skill = await client.GetSkillAsync("skill://aot/SKILL.md"); +var skillResult = await client.GetSkillAsync(new GetSkillRequestParams { Uri = "skill://aot/SKILL.md" }); +if (skillResult.ResultType != "complete" || + skillResult.TimeToLive != TimeSpan.Zero || + skillResult.CacheScope != CacheScope.Private) +{ + throw new Exception("Unexpected skill cache hints."); +} + +var skill = skillResult.Skill; +if (skill.Description != "An AOT-published skill." || + skill.Frontmatter["metadata"]?["note"]?.GetValue() != "First\n\tCode\nLast\n") +{ + throw new Exception("Unexpected skill frontmatter."); +} + var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri); if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot")) { diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs index d0826dc71..bca8ae7ee 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -253,6 +253,66 @@ public void GetSkillResult_RoundTrips() Assert.Equal("complete", deserialized.ResultType); } + [Theory] + [InlineData(0, CacheScope.Private)] + [InlineData(300_000, CacheScope.Public)] + [InlineData(-1, CacheScope.Private)] + public void GetSkillResult_RoundTripsCacheAttributes(long milliseconds, CacheScope scope) + { + var original = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic), ResultType = "complete" }; + var cacheable = Assert.IsAssignableFrom(original); + cacheable.TimeToLive = TimeSpan.FromMilliseconds(milliseconds); + cacheable.CacheScope = scope; + + var json = JsonSerializer.SerializeToNode(original, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject(); + Assert.Equal(milliseconds, json["ttlMs"]?.GetValue()); + Assert.Equal(scope == CacheScope.Public ? "public" : "private", json["cacheScope"]?.GetValue()); + Assert.Equal("complete", json["resultType"]?.GetValue()); + Assert.False(json.ContainsKey("nextCursor")); + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + var deserializedCacheable = Assert.IsAssignableFrom(deserialized); + Assert.Equal(cacheable.TimeToLive, deserializedCacheable.TimeToLive); + Assert.Equal(scope, deserializedCacheable.CacheScope); + } + + [Theory] + [InlineData("\"regional\"")] + [InlineData("42")] + [InlineData("null")] + public void GetSkillResult_ToleratesUnknownCacheScopeOnRead(string cacheScope) + { + var json = $$""" + { + "skill": { + "uri": "skill://alpha/SKILL.md", + "frontmatter": { "name": "alpha", "description": "d" }, + "resources": "dynamic" + }, + "ttlMs": 0, + "cacheScope": {{cacheScope}} + } + """; + + var deserialized = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.GetSkillResult); + var cacheable = Assert.IsAssignableFrom(deserialized); + + Assert.Null(cacheable.CacheScope); + Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive); + } + + [Fact] + public void GetSkillResult_OmitsVersionedFieldsWhenUnset() + { + var result = new GetSkillResult { Skill = CreateEntry(SkillResources.Dynamic) }; + var json = JsonSerializer.SerializeToNode(result, McpSkillsJsonContext.Default.GetSkillResult)!.AsObject(); + + Assert.False(json.ContainsKey("ttlMs")); + Assert.False(json.ContainsKey("cacheScope")); + Assert.False(json.ContainsKey("resultType")); + Assert.False(json.ContainsKey("nextCursor")); + } + [Fact] public void RequestParams_RoundTrip() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs new file mode 100644 index 000000000..54cf43dab --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs @@ -0,0 +1,74 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillAuthoringBoundaryTests +{ + private const string SkillUri = "skill://owned/SKILL.md"; + + [Theory] + [InlineData("---\nname: &name owned\ndescription: d\n---\n")] + [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] + [InlineData("# No frontmatter\n")] + [InlineData("---\nname: owned\ndescription: d\n")] + public void AutomaticAuthoring_RejectsUnsupportedMalformedOrMissingFrontmatter(string markdown) + { + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create(SkillUri, files)); + + WithDirectory(markdown, directory => + { + Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory)); + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + }); + } + + [Fact] + public void ExplicitMetadata_DoesNotOverrideSupportedFrontmatterEquality() + { + const string Markdown = "---\nname: owned\ndescription: From the file\n---\n"; + var supplied = new JsonObject { ["name"] = "owned", ["description"] = "Not from the file" }; + + var filesException = Assert.Throws(() => McpServerSkill.Create( + SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", Markdown)])); + Assert.Contains("does not match", filesException.Message); + + WithDirectory(Markdown, directory => + { + var directoryException = Assert.Throws(() => + McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); + Assert.Contains("does not match", directoryException.Message); + }); + } + + [Theory] + [InlineData("# No frontmatter\n")] + [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] + public void ExplicitMetadata_DoesNotBypassDetectedBoundaryOrFormatErrors(string markdown) + { + var supplied = new JsonObject { ["name"] = "owned", ["description"] = "d" }; + Assert.Throws(() => McpServerSkill.Create( + SkillUri, supplied, [McpServerSkillFile.FromText("SKILL.md", markdown)])); + WithDirectory(markdown, directory => + { + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, supplied, directory)); + }); + } + + private void WithDirectory(string markdown, Action action) + { + var directory = Path.Combine(Path.GetTempPath(), "mcp-skill-ownership-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + try + { + File.WriteAllText(Path.Combine(directory, "SKILL.md"), markdown); + action(directory); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 5b49f8436..1bb510e26 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -219,12 +219,12 @@ 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 valid YAML the reader does not support: the explicit object stands on its own. + // The caller supplies the complete, prevalidated frontmatter for this unsupported YAML form. var escapeHatch = McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", "---\nname: &n git-workflow\ndescription: Git conventions\n---\n")]); Assert.Equal("git-workflow", escapeHatch.ProtocolSkill.Name); - // A quoted scalar that closes on a later line is valid YAML the reader does not support, so it is bypassable too. + // The caller has also validated this complete multi-line quoted value. var multiLine = McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: \"Git\n conventions\"\n---\n")]); Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description); @@ -247,7 +247,7 @@ public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() [InlineData("---\nname: git-workflow\ndescription: d\n extra # comment\n more\n---\n", "a comment ended that value")] public void Create_WithExplicitFrontmatter_StillRejectsMalformedSkillFile(string markdown, string messageFragment) { - // Only valid-but-unsupported YAML may be bypassed. A file no host can parse must not be published. + // These errors are detected by the bounded reader; explicit metadata does not bypass them. var exception = Assert.Throws(() => McpServerSkill.Create(SkillUri, Frontmatter(), [McpServerSkillFile.FromText("SKILL.md", markdown)])); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index 2d6d85acb..f3ae48a55 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -228,7 +228,13 @@ public async Task SkillsMethods_On2026_07_28Session_IncludeResultTypeAndCacheHin TestContext.Current.CancellationToken)).Result!.AsObject(); Assert.Equal("complete", get["resultType"]?.GetValue()); Assert.False(get.ContainsKey("nextCursor")); - Assert.False(get.ContainsKey("ttlMs")); + Assert.Equal(300_000, get["ttlMs"]?.GetValue()); + Assert.Equal("public", get["cacheScope"]?.GetValue()); + + var typed = await client.GetSkillAsync(new GetSkillRequestParams { Uri = UnlistedUri }, TestContext.Current.CancellationToken); + var cacheable = Assert.IsAssignableFrom(typed); + Assert.Equal(TimeSpan.FromMinutes(5), cacheable.TimeToLive); + Assert.Equal(CacheScope.Public, cacheable.CacheScope); } [Fact] @@ -250,6 +256,8 @@ public async Task SkillsMethods_On2025_11_25Session_OmitResultTypeAndCacheHints( new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsGet, Params = new JsonObject { ["uri"] = UnlistedUri } }, TestContext.Current.CancellationToken)).Result!.AsObject(); Assert.False(get.ContainsKey("resultType"), "resultType must be absent on a 2025-11-25 skills/get result."); + Assert.False(get.ContainsKey("ttlMs"), "ttlMs must be absent on a 2025-11-25 skills/get result."); + Assert.False(get.ContainsKey("cacheScope"), "cacheScope must be absent on a 2025-11-25 skills/get result."); Assert.Equal(UnlistedUri, get["skill"]!["uri"]!.GetValue()); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs index a5956df96..22211794c 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsDefaultsTests.cs @@ -9,7 +9,7 @@ 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 +/// requirement that skills/list and skills/get carry ttlMs and cacheScope, using the same conservative /// defaults the SDK applies to the built-in list methods. /// public class McpServerSkillsDefaultsTests : ClientServerTestBase @@ -49,6 +49,44 @@ public async Task SkillsList_WithDefaultOptions_On2026_07_28_CarriesConservative Assert.Equal(CacheScope.Private, typed.CacheScope); } + [Theory] + [InlineData(McpProtocolVersions.July2026ProtocolVersion, true)] + [InlineData(McpProtocolVersions.November2025ProtocolVersion, false)] + public async Task SkillsGet_WithDefaultOptions_GatesCacheHintsByProtocolVersion(string protocolVersion, bool includesCacheHints) + { + await using var client = await CreateMcpClientForServer(new McpClientOptions { ProtocolVersion = protocolVersion }); + var result = (await client.SendRequestAsync( + new JsonRpcRequest + { + Method = SkillsProtocol.MethodSkillsGet, + Params = new JsonObject { ["uri"] = "skill://alpha/SKILL.md" }, + }, + TestContext.Current.CancellationToken)).Result!.AsObject(); + var typed = await client.GetSkillAsync( + new GetSkillRequestParams { Uri = "skill://alpha/SKILL.md" }, TestContext.Current.CancellationToken); + var cacheable = Assert.IsAssignableFrom(typed); + + Assert.Equal("skill://alpha/SKILL.md", result["skill"]!["uri"]!.GetValue()); + Assert.False(result.ContainsKey("nextCursor")); + if (includesCacheHints) + { + Assert.Equal("complete", result["resultType"]?.GetValue()); + Assert.Equal(0, result["ttlMs"]?.GetValue()); + Assert.Equal("private", result["cacheScope"]?.GetValue()); + Assert.Equal(TimeSpan.Zero, cacheable.TimeToLive); + Assert.Equal(CacheScope.Private, cacheable.CacheScope); + } + else + { + Assert.False(result.ContainsKey("resultType")); + Assert.False(result.ContainsKey("ttlMs")); + Assert.False(result.ContainsKey("cacheScope")); + Assert.Null(typed.ResultType); + Assert.Null(cacheable.TimeToLive); + Assert.Null(cacheable.CacheScope); + } + } + [Fact] public async Task ClientExtensions_ThrowWhenServerDoesNotDeclareSkills() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs new file mode 100644 index 000000000..847f22bc8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsOwnershipTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerSkillsOwnershipTests : ClientServerTestBase +{ + private const string CatalogUri = "skill://catalog/SKILL.md"; + private const string CatalogMarkdown = "---\nname: catalog\ndescription: From the file\n---\n# Instructions\n"; + private int _catalogReadCount; + + public McpServerSkillsOwnershipTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var files = CreatePrevalidatedSkill("provided-files", fromDirectory: false); + var directory = CreatePrevalidatedSkill("provided-directory", fromDirectory: true); + var catalog = new Skill + { + Uri = CatalogUri, + Frontmatter = new JsonObject { ["name"] = "catalog", ["description"] = "Advertised metadata" }, + Resources = SkillResources.FromResources( + [ + new SkillResource + { + Uri = CatalogUri, + Digest = SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(CatalogMarkdown)), + Size = Encoding.UTF8.GetByteCount(CatalogMarkdown), + }, + ]), + }; + + mcpServerBuilder + .WithSkills(new InMemoryMcpSkillCatalog([files.ProtocolSkill, directory.ProtocolSkill, catalog])) + .WithResources(files.Resources) + .WithResources(directory.Resources) + .WithResources( + [ + McpServerResource.Create(() => + { + Interlocked.Increment(ref _catalogReadCount); + return CatalogMarkdown; + }, new McpServerResourceCreateOptions { UriTemplate = CatalogUri, MimeType = "text/markdown" }), + ]); + } + + [Fact] + public async Task CatalogListAndGet_TransportStructuredMetadataWithoutReadingFiles() + { + await using var client = await CreateMcpClientForServer(); + + var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var retrieved = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); + + Assert.Equal("Advertised metadata", Assert.Single(listed, skill => skill.Uri == CatalogUri).Description); + Assert.Equal("Advertised metadata", retrieved.Description); + Assert.Equal(0, _catalogReadCount); + } + + [Theory] + [InlineData("provided-files")] + [InlineData("provided-directory")] + public async Task ExplicitAuthoring_PreservesPrevalidatedUnsupportedMetadataAndBytes(string name) + { + await using var client = await CreateMcpClientForServer(); + var uri = $"skill://{name}/SKILL.md"; + var entry = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var contents = await client.ReadSkillResourceAsync(entry, uri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(contents.Contents)).Text; + + Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter(name), entry.Frontmatter)); + Assert.Equal(UnsupportedMarkdown(name), text); + Assert.Equal(Encoding.UTF8.GetByteCount(text), Assert.Single(entry.Resources.Resources!).Size); + Assert.Equal(SkillVerifier.ComputeDigest(Encoding.UTF8.GetBytes(text)), Assert.Single(entry.Resources.Resources!).Digest); + } + + [Fact] + public async Task DigestVerifiedRead_DoesNotEstablishFrontmatterEquality() + { + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync(CatalogUri, TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, CatalogUri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(result.Contents)).Text; + + Assert.Equal(CatalogMarkdown, text); + Assert.Equal(1, _catalogReadCount); + Assert.Throws(() => VerifyHostFrontmatter(entry, text)); + } + + [Fact] + public async Task HostRejectsUnsupportedFetchedYaml_InsteadOfTrustingServerJson() + { + await using var client = await CreateMcpClientForServer(); + var entry = await client.GetSkillAsync("skill://provided-files/SKILL.md", TestContext.Current.CancellationToken); + var result = await client.ReadSkillResourceAsync(entry, entry.Uri, TestContext.Current.CancellationToken); + var text = Assert.IsType(Assert.Single(result.Contents)).Text; + + Assert.True(JsonNode.DeepEquals(PrevalidatedFrontmatter("provided-files"), entry.Frontmatter)); + var exception = Assert.Throws(() => VerifyHostFrontmatter(entry, text)); + Assert.IsAssignableFrom(exception.InnerException); + } + + private McpServerSkill CreatePrevalidatedSkill(string name, bool fromDirectory) + { + var markdown = UnsupportedMarkdown(name); + var bytes = Encoding.UTF8.GetBytes(markdown); + var supplied = PrevalidatedFrontmatter(name); + var uri = $"skill://{name}/SKILL.md"; + if (fromDirectory) + { + var path = Path.Combine(Path.GetTempPath(), "mcp-skill-prevalidated-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(path); + try + { + File.WriteAllBytes(Path.Combine(path, "SKILL.md"), bytes); + var skill = McpServerSkill.CreateFromDirectory(uri, supplied, path); + File.WriteAllText(Path.Combine(path, "SKILL.md"), "changed after creation"); + supplied["description"] = "changed after creation"; + return skill; + } + finally + { + Directory.Delete(path, recursive: true); + } + } + + var result = McpServerSkill.Create(uri, supplied, [new McpServerSkillFile { Path = "SKILL.md", Content = bytes }]); + bytes[bytes.Length - 2] = (byte)'X'; + supplied["description"] = "changed after creation"; + return result; + } + + private string UnsupportedMarkdown(string name) => + $"---\nname: &name {name}\ndescription: Provided\nmetadata:\n owner: &owner team\n reviewer: *owner\n---\n# Instructions\n"; + + private JsonObject PrevalidatedFrontmatter(string name) => new() + { + ["name"] = name, + ["description"] = "Provided", + ["metadata"] = new JsonObject { ["owner"] = "team", ["reviewer"] = "team" }, + }; + + private void VerifyHostFrontmatter(Skill entry, string markdown) + { + try + { + var parsed = SkillFrontmatter.Parse(markdown); + if (!JsonNode.DeepEquals(parsed, entry.Frontmatter)) + { + throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); + } + } + catch (FormatException exception) + { + throw new SkillVerificationException("The fetched frontmatter could not be verified.", exception); + } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs new file mode 100644 index 000000000..608af911d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs @@ -0,0 +1,98 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillFrontmatterNodeClassificationTests +{ + [Theory] + [InlineData("extra:\n [x]: value", """{"[ x ]":"value"}""")] + [InlineData("extra:\n {a: b}: value", """{"{ a: b }":"value"}""")] + [InlineData("extra:\n -\n [x]: value", """[{"[ x ]":"value"}]""")] + [InlineData("extra:\n [.inf]: value", """{"[ .inf ]":"value"}""")] + [InlineData("extra:\n [team's]: value", """{"[ team's ]":"value"}""")] + [InlineData("extra:\n [\"a]: b\"]: value", """{"[ \"a]: b\" ]":"value"}""")] + public void FlowCollectionKeys_KeepTheExplicitFrontmatterFallback(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + McpServerSkill? skill = null; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.Contains("flow collections are not supported as mapping keys", parseException!.Message); + Assert.IsType(automaticException); + Assert.Null(explicitException); + Assert.NotNull(skill); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("metadata:\n {version: 1}", """{"metadata":{"version":1}}""")] + [InlineData("tags:\n [read]", """{"tags":["read"]}""")] + [InlineData("metadata:\n \"note\": value", """{"metadata":{"note":"value"}}""")] + [InlineData("extra:\n [\"a]: b\"]", """{"extra":["a]: b"]}""")] + [InlineData("metadata:\n {note: \"}: not a key\"}", """{"metadata":{"note":"}: not a key"}}""")] + [InlineData("extra:\n [team's]", """{"extra":["team's"]}""")] + [InlineData("extra:\n -\n [read]", """{"extra":[["read"]]}""")] + [InlineData("note:\n Key:value#hash", """{"note":"Key:value#hash"}""")] + [InlineData("note:\n |2\n text", """{"note":" text\n"}""")] + public void NextLineValuesAndQuotedKeys_RemainSupported(string body, string expectedFields) + { + var markdown = CreateMarkdown(body); + var expected = JsonNode.Parse(expectedFields)!.AsObject(); + expected["name"] = "alpha"; + expected["description"] = "d"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, + McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("extra:\n &anchor value\nbroken: [never", "\"value\"")] + [InlineData("extra:\n -\n &anchor value\nbroken: [never", "[\"value\"]")] + [InlineData("extra:\n &anchor value", "\"value\"")] + [InlineData("extra:\n !!str value\nbroken: [never", "\"value\"")] + [InlineData("extra:\n *undefined\nbroken: [never", "\"value\"")] + public void UnsupportedNextLineNodeProperties_KeepTheImportedRejection(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + [Theory] + [InlineData("extra:\n [x]#comment", "[\"x\"]")] + [InlineData("extra:\n {version: 1}#comment", """{"version":1}""")] + [InlineData("extra:\n [x]:value", "[\"x\"]")] + public void MalformedFlowValueSuffixes_DoNotBecomeUnsupportedKeys(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var supplied = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + private string CreateMarkdown(string body) => "---\nname: alpha\ndescription: d\n" + body + "\n---\n"; + + private JsonObject CreateFrontmatter(string extraJson) => new() + { + ["name"] = "alpha", + ["description"] = "d", + ["extra"] = JsonNode.Parse(extraJson), + }; +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs new file mode 100644 index 000000000..b3a0fbe72 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs @@ -0,0 +1,142 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class SkillFrontmatterRegressionTests +{ + [Theory] + [InlineData("description:\n Builds reusable components with isolated tables.\n Use for new components.", + "Builds reusable components with isolated tables. Use for new components.")] + [InlineData("description:\n One line on the next line.", "One line on the next line.")] + [InlineData("description:\n \"Quoted on the next line\"", "Quoted on the next line")] + [InlineData("description:\n 'Quoted on the next line'", "Quoted on the next line")] + [InlineData("description: # note\n Text on the next line", "Text on the next line")] + [InlineData("description:\n # note\n\n Text on the next line", "Text on the next line")] + [InlineData("description:\n First line\n continued with less indentation", "First line continued with less indentation")] + public void NextLineScalar_IsParsedAndPublishedWithoutChangingItsValue(string body, string expectedDescription) + { + var markdown = "---\nname: alpha\n" + body + "\n---\n"; + var expected = new JsonObject { ["name"] = "alpha", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("tags:\n -\n text on next line", """{"tags":["text on next line"]}""")] + [InlineData("tags:\n - # note\n \"text on next line\"", """{"tags":["text on next line"]}""")] + [InlineData("metadata:\n note:\n first\n second", """{"metadata":{"note":"first second"}}""")] + public void NestedNextLineScalar_IsParsedAndPublished(string body, string expectedFields) + { + var markdown = "---\nname: alpha\ndescription: d\n" + body + "\n---\n\n# Body\n"; + var expected = JsonNode.Parse(expectedFields)!.AsObject(); + expected["name"] = "alpha"; + expected["description"] = "d"; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData(">", " First\n \tCode\n Last\n", "First\n\tCode\nLast\n")] + [InlineData(">-", " First\n \tCode\n Last\n", "First\n\tCode\nLast")] + [InlineData(">+", " First\n \tCode\n Last\n\n", "First\n\tCode\nLast\n\n")] + [InlineData(">", " First\n\n \tCode\n\n Last\n", "First\n\n\tCode\n\nLast\n")] + [InlineData(">2", " First\n \tCode\n Last\n", "First\n\tCode\nLast\n")] + public void FoldedScalar_PreservesLineBreaksAroundTabLedContent(string header, string content, string expectedDescription) + { + var markdown = "---\nname: comparison\ndescription: " + header + "\n" + content + "---\n"; + var expected = new JsonObject { ["name"] = "comparison", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("|+", "Kept\n\n")] + [InlineData("|", "Kept\n")] + [InlineData("|-", "Kept")] + public void BlockScalarAtClosingFence_PreservesAuthoredLineBreaks(string header, string expectedDescription) + { + var markdown = "---\nname: alpha\ndescription: " + header + "\n Kept\n\n---\n"; + var expected = new JsonObject { ["name"] = "alpha", ["description"] = expectedDescription }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData("\"Useful\"#comment", "{version: 1}", "[read]")] + [InlineData("'Useful'#comment", "{version: 1}", "[read]")] + [InlineData("Useful", "{version: 1}#comment", "[read]")] + [InlineData("Useful", "{version: 1}", "[read]#comment")] + public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloads( + string description, string metadata, string tags) + { + var markdown = $"---\nname: comparison\ndescription: {description}\nmetadata: {metadata}\ntags: {tags}\n---\n"; + var expected = new JsonObject + { + ["name"] = "comparison", + ["description"] = "Useful", + ["metadata"] = new JsonObject { ["version"] = 1 }, + ["tags"] = new JsonArray("read"), + }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => McpServerSkill.Create("skill://comparison/SKILL.md", expected, files)); + + Assert.IsType(parseException); + Assert.IsType(automaticException); + Assert.IsType(explicitException); + } + + [Theory] + [InlineData("Useful#hash", "{version: 1}", "[read]", "Useful#hash")] + [InlineData("\"Useful\" #comment", "{version: 1}", "[read]", "Useful")] + [InlineData("'Useful'\t#comment", "{version: 1}", "[read]", "Useful")] + [InlineData("Useful", "{version: 1} #comment", "[read]", "Useful")] + [InlineData("Useful", "{version: 1}\t#comment", "[read]", "Useful")] + [InlineData("Useful", "{version: 1}", "[read] #comment", "Useful")] + [InlineData("Useful", "{version: 1}", "[read]\t#comment", "Useful")] + public void SeparatedCommentsAndPlainHashes_RemainValid( + string description, string metadata, string tags, string expectedDescription) + { + var markdown = $"---\nname: comparison\ndescription: {description}\nmetadata: {metadata}\ntags: {tags}\n---\n"; + var expected = new JsonObject + { + ["name"] = "comparison", + ["description"] = expectedDescription, + ["metadata"] = new JsonObject { ["version"] = 1 }, + ["tags"] = new JsonArray("read"), + }; + + AssertParsedAndPublished(markdown, expected); + } + + [Theory] + [InlineData(".inf")] + [InlineData("-.Inf")] + [InlineData(".nan")] + public void NonFiniteScalar_CannotBeReplacedWithJsonNullThroughExplicitFrontmatter(string value) + { + var markdown = $"---\nname: alpha\ndescription: d\nextra: {value}\n---\n"; + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["extra"] = null }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Contains("cannot be represented in JSON", exception.Message); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + private void AssertParsedAndPublished(string markdown, JsonObject expected) + { + var parsed = SkillFrontmatter.Parse(markdown); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var automatic = McpServerSkill.Create(files); + var explicitMetadata = McpServerSkill.Create(automatic.ProtocolSkill.Uri, expected, files); + + Assert.True(JsonNode.DeepEquals(expected, parsed), $"Expected {expected}, parsed {parsed}."); + Assert.True(JsonNode.DeepEquals(expected, automatic.ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, explicitMetadata.ProtocolSkill.Frontmatter)); + } +} From 641f070ef5472ffc6dfe85e0c5258914565a434c Mon Sep 17 00:00:00 2001 From: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:29:05 +0200 Subject: [PATCH 4/5] docs: consolidate Skills verification guidance and sample links Consolidate frontmatter verification responsibilities and use main-branch sample links. Include both commits from PederHP's documentation pull request, without our follow-up changes. --- docs/concepts/skills/skills.md | 95 +++++++++---------- samples/SkillsClient/README.md | 13 +-- samples/SkillsServer/README.md | 7 -- .../Client/McpSkillsClientExtensions.cs | 7 +- .../Client/SkillVerifier.cs | 13 ++- .../Protocol/Skill.cs | 7 +- .../Server/IMcpSkillCatalog.cs | 7 +- .../Server/InMemoryMcpSkillCatalog.cs | 3 +- .../Server/McpServerSkill.cs | 55 +++-------- .../Server/McpSkillsBuilderExtensions.cs | 14 +-- .../SkillFrontmatter.cs | 11 +-- 11 files changed, 86 insertions(+), 146 deletions(-) diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 48b95399a..001761393 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -31,19 +31,17 @@ A skill entry () is a complet that cannot be digested. A host builds its registry from entries alone, then fetches files lazily with `resources/read` and verifies each -one against the manifest. Before loading `SKILL.md`, it must also parse the fetched YAML and compare every -frontmatter field with the held entry. Digest and size verification alone does not establish that equality. +one against the manifest. Before loading a skill, it also parses the fetched `SKILL.md` and compares its +frontmatter with the entry's; digest verification does not cover that. That verification, and the rule that an +unlisted file is a change to the skill, is what lets a user's approval bind to specific content. -### Responsibility boundaries +Keeping the entry and the file in agreement is the server's job, and checking that they agree is the host's: -The protocol carries structured metadata; it does not require a particular parser or provider. - -| Layer | Responsibility | +| Who | Responsibility | |---|---| -| Application / catalog | Supply frontmatter identical to the complete frontmatter in the file, and serve matching file bytes and manifests. Structural entry validation does not read or validate the YAML on a catalog's behalf. | -| Optional automatic authoring helper | `McpServerSkill.Create` / `CreateFromDirectory` without explicit metadata use the bounded `SkillFrontmatter` reader. Unsupported or malformed input is rejected, not replaced with guessed metadata. | -| Explicit-metadata authoring | Keep the helper's enforced checks. If the bounded reader cannot fully read unsupported YAML, the application must parse and validate the entire frontmatter with a suitable mechanism and supply the correct JSON. | -| Host | Independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before loading, regardless of server authoring path. Reject unsupported input or use a suitable full parser; server-provided JSON is not verification. | +| Server, using `McpServerSkill` | Frontmatter is read from `SKILL.md` and the manifest computed from the served bytes. With explicit frontmatter for YAML the reader does not support, the application must supply JSON identical to the file. | +| Server, using a custom catalog | The application supplies entries whose frontmatter and manifest match the files it serves. The SDK checks entry structure only. | +| Host | Verifies each file against the manifest, then parses the fetched `SKILL.md` and compares its frontmatter with the entry before loading. | ### Serving skills @@ -99,28 +97,23 @@ names containing characters with URI syntax (such as `{`, `?`, or a space) are p #### Frontmatter reads the YAML frontmatter of a `SKILL.md` into a - without a YAML library. This is an optional bounded reader, not a -requirement for serving structured metadata through a catalog. It supports block mappings (nested up to 32 levels), block and flow sequences, plain, quoted, and block -scalars, and comments. Unquoted scalars are resolved per the YAML 1.2 core schema (`null`, booleans, integers, finite -floats, otherwise strings). YAML parsers can differ in their supported forms and resolution; identical semantics -across other SDKs or libraries are not assumed. 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. - -Automatic authoring requires the bounded reader to succeed. Unsupported constructs and detected malformed input -raise an error; no fallback object is invented. Unsupported forms include anchors, aliases, tags, complex keys, -nested and multi-line flow collections, and multi-line quoted scalars. - -The explicit `JsonObject` overloads retain UTF-8 and frontmatter-boundary checks, entry/URI validation, and manifest -digest/size computation from the served bytes. They also reject format errors detected by the reader and reject -metadata that differs from a frontmatter block the reader can parse completely. - -**The explicit overloads do not guarantee whole-document YAML validation.** An unsupported construct can stop -parsing before later content is examined. The application must parse and validate the **entire frontmatter** -with a suitable mechanism and supply identical JSON before relying on that path. An unsupported-format error -is not proof that the rest of the document is valid, and acceptance of supplied metadata is not proof of equality. -This boundary does not relax the specification's requirement that published frontmatter match the file. + without a YAML library. It supports block mappings (nested up to 32 +levels), block and flow sequences, plain, quoted, and block scalars, and comments. Unquoted scalars are resolved per +the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml` +package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by +parsing the fetched `SKILL.md` itself and comparing field by field against the published entry; a value that one +side types as a number and the other as a string is a verification failure. Quote values such as version numbers +that are meant to be strings. + +Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are not +supported; the reader rejects them with a naming the construct rather than guess. +For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the +frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader +detects, and when the reader can parse the whole block, the supplied object must match it exactly. + +The reader stops at the first unsupported construct, though, so for such a file it cannot check the rest of the +block or compare it with the supplied object. The application must parse the entire frontmatter with a full YAML +parser and supply the identical JSON; the SDK accepting it is not proof that it matches. #### Custom catalogs @@ -147,29 +140,28 @@ the caller's when the transport su 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`. The application/catalog author must supply frontmatter identical to the file. The transport -validates entry structure, not full file contents, and does not require use of the optional authoring reader. -A catalog may list only part of what it serves, or nothing at all, as long as `GetAsync` answers -for every skill the server serves to the caller. Throw with - for a cursor the catalog cannot interpret. - is the built-in implementation over a fixed -set of entries and can be composed into a custom one. +`resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not +read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as +`GetAsync` answers for every skill the server serves to the caller. Throw + with for a +cursor the catalog cannot interpret. is the +built-in implementation over a fixed set of entries and can be composed into a custom one. #### Dynamic skills A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to and serve it through a catalog. Such a skill -offers no content integrity, and hosts may decline to load it. -If a host accepts a dynamic skill, parsing and comparing its fetched frontmatter is still required. +offers no content integrity, and hosts may decline to load it. A host that does load one still verifies its +frontmatter. #### Protocol versions and caching hints -`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, configured through -. Those attributes, and `resultType`, are defined -from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, +`skills/list` and `skills/get` results carry the base protocol's `ttlMs` and `cacheScope` caching attributes, +configured through . Those attributes, and +`resultType`, are defined from protocol revision `2026-07-28`. The SDK emits them only on requests negotiated under that revision or later, where they are required and default to `0` and `private` when unset, the same conservative defaults the built-in list methods use. On earlier revisions, which reject them as unrecognized keys, they are omitted. Both methods use -the same configured hints. Only use `public` when both the listing and individual entries are identical for every caller. +the same configured hints, so use `public` only when the listing and every entry are identical for every caller. ### Consuming skills @@ -244,13 +236,12 @@ 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` automatically. **The host must perform that independent check before loading**, regardless -of whether the server used a catalog, automatic authoring, or explicit metadata. For the supported subset it can use +the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows: on the fetched text and -against the entry's frontmatter. Unsupported content must be rejected or parsed with a suitable full parser; -malformed input or a difference is a verification failure. Never use the server's JSON as a substitute for parsing -the fetched file. These examples do not implement the host's complete approval, origin-isolation or loading policy. +against the entry's frontmatter, treating a parse failure or a difference as a verification failure. A host that +must accept YAML the reader does not support can parse the file with a full YAML library instead, but never uses +the entry's frontmatter in place of parsing the file. ### Not implemented @@ -259,7 +250,7 @@ Servers built with this package do not declare `directoryRead`, and hosts must n ### Samples -- [SkillsServer](https://github.com/modelcontextprotocol/csharp-sdk/tree/ecb11e621b0ae46685314729a74c0cb8fe283d44/samples/SkillsServer): a Streamable +- [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/ecb11e621b0ae46685314729a74c0cb8fe283d44/samples/SkillsClient): a client that +- [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/samples/SkillsClient/README.md b/samples/SkillsClient/README.md index 7c91b8621..fafdf6252 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -66,11 +66,8 @@ 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. -- Digest and size verification are not frontmatter verification. Every host must independently parse the - fetched `SKILL.md` and compare all fields with the held entry before loading, regardless of how the server - authored the metadata. -- This sample accepts text `SKILL.md` resources and uses `SkillFrontmatter.Parse` for its supported subset. - It rejects mismatches, malformed input and unsupported YAML. A host needing other YAML forms must use a - suitable full parser, never the server-provided JSON as a verification fallback. -- This is not a complete host implementation: approval binding, origin isolation and permission decisions - remain application responsibilities. +- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` with + `SkillFrontmatter.Parse` and compares it to the entry, rejecting the skill on a difference or on YAML the reader + does not support. A host that must accept such YAML can use a full YAML parser instead, but never the entry's + own frontmatter. +- This is not a complete host: approval binding, origin isolation, and permission decisions are left out. diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md index 4af29fd06..6ec5bb535 100644 --- a/samples/SkillsServer/README.md +++ b/samples/SkillsServer/README.md @@ -15,11 +15,6 @@ SHA-256 digest and size of every file, and registers the `skills/list` and `skil 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/`. -This is the optional automatic provider path. Its bounded `SkillFrontmatter` reader rejects unsupported or -malformed input rather than guessing metadata. Applications may instead supply structured metadata through a -catalog. When using explicit-metadata authoring for unsupported YAML, the application must parse and validate -the entire frontmatter and supply identical JSON; acceptance by the helper is not proof of full YAML validity. - ## Run ```bash @@ -44,7 +39,5 @@ Any MCP host that supports Streamable HTTP can also be pointed at `http://localh 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. -- Hosts must independently parse fetched `SKILL.md` and compare its frontmatter with the held entry before - loading, regardless of the server's authoring path. Digest/size checks alone do not perform that comparison. - 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/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index fd53d0128..fb4a77d0a 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -9,11 +9,8 @@ namespace ModelContextProtocol.Extensions.Skills; /// MCP Skills extension (SEP-2640). /// /// -/// Listing and retrieval validate structured entries, not the contents of their files. -/// verifies manifest membership, URI, digest and size, not frontmatter. -/// Before loading a skill, the host must separately parse the fetched SKILL.md and compare every -/// frontmatter field with the held entry. Use only for its supported subset; -/// reject unsupported content or use a suitable full parser, never the server's JSON as a verification fallback. +/// These methods validate entries and verify file content against manifests. They do not verify a skill's +/// frontmatter, which a host must still do before loading it; see . /// public static class McpSkillsClientExtensions { diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 10d090f66..927c44e71 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -12,11 +12,14 @@ 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 manifest checks, not frontmatter verification. Before loading a skill, the host must -/// independently parse the fetched SKILL.md and compare every frontmatter field with the held entry, -/// regardless of the server's authoring path. It can use and -/// for the supported subset. Unsupported content must be -/// rejected or read with a suitable full parser; server-supplied JSON is never a verification fallback. +/// helpers implement those checks. +/// +/// +/// They do not verify frontmatter. Before loading a skill, the host must also parse the fetched SKILL.md +/// itself and compare its frontmatter field by field with the held entry, treating any difference as a +/// verification failure. and +/// do this for the YAML the reader supports. For anything else, the host rejects the skill or uses a full YAML +/// parser; the entry's own frontmatter is never a substitute for parsing the file. /// /// /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs index 937b8b5ab..d3716da3c 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs @@ -35,10 +35,9 @@ public sealed class Skill /// /// /// Every field the author wrote is passed through, not a curated subset. name and description - /// are always present. The application or catalog author must supply metadata identical to the file's - /// complete frontmatter. Transport-level structural validation does not read or validate that file. - /// Hosts must independently parse the fetched SKILL.md and compare its frontmatter against this object - /// field by field before loading, treating any discrepancy as a verification failure. + /// are always present. Hosts re-parse the fetched SKILL.md and compare its frontmatter against this + /// object field by field, treating any discrepancy as a verification failure, so this must reproduce the + /// authored frontmatter exactly. Structural validation of the entry does not read the file to check that. /// [JsonPropertyName("frontmatter")] public required JsonObject Frontmatter { get; set; } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs index 2fe5b8161..1afb87aad 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -18,10 +18,9 @@ namespace ModelContextProtocol.Extensions.Skills; /// resources through resources/read, since that is how hosts fetch skill content. /// /// -/// The application must supply identical to the complete frontmatter in the -/// corresponding file. Skills transport handlers validate entry structure; they do not read files or perform -/// full YAML validation for a catalog. is an optional authoring helper, not a -/// requirement for implementing this interface. Hosts independently verify fetched frontmatter before loading. +/// Each entry's must be identical to the frontmatter of the SKILL.md it +/// describes. The server validates an entry's structure but does not read the files to check this; building +/// entries with is one way to keep the two in agreement. /// /// /// The skills methods do not pass through the request filters that guard the built-in resource methods, so a diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs index e8383358d..c9b97762c 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -8,8 +8,7 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// Every entry is validated against the specification's structural requirements when the catalog is constructed. -/// The catalog does not read skill files or compare their frontmatter. The application must supply identical -/// file metadata, and hosts independently parse and compare fetched frontmatter before loading a skill. +/// It does not read the skills' files, so each entry's frontmatter must already match its SKILL.md. /// /// /// The catalog keeps its own copy of every entry and hands out copies, so neither later changes to the objects diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index b37740df5..72b506421 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -18,16 +18,11 @@ namespace ModelContextProtocol.Extensions.Skills; /// resources serve, so the two cannot disagree. /// /// -/// Overloads without explicit metadata use the optional bounded reader and -/// reject unsupported or malformed input. Overloads accepting a compare it with the -/// file when the reader can read the complete frontmatter, and reject a mismatch or a detected format error. -/// When parsing stops at an unsupported construct, the application must have parsed and validated the entire -/// frontmatter with a suitable mechanism and supplied identical JSON. This is not a whole-document YAML validator. -/// -/// -/// Hosts must independently parse the fetched SKILL.md and compare its frontmatter field by field with -/// the held entry before loading the skill, regardless of the authoring overload. Manifest digest and size -/// verification does not perform that comparison. +/// Overloads without explicit metadata read the frontmatter with and reject +/// unsupported or malformed input rather than publish guessed metadata. Overloads accepting a +/// exist for YAML the reader does not support; see +/// for what they check and what remains +/// the caller's responsibility. /// /// /// Register skills with , @@ -75,10 +70,6 @@ private McpServerSkill(Skill entry, IReadOnlyList resources) /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (see /// ) or is missing a required field. /// - /// - /// The bounded reader must read the frontmatter successfully. Unsupported or malformed input is rejected, - /// not replaced with guessed metadata. Hosts still verify frontmatter independently when loading the skill. - /// public static McpServerSkill Create(IEnumerable files) { #if NET @@ -106,9 +97,6 @@ public static McpServerSkill Create(IEnumerable files) /// frontmatter cannot be read (see ), is missing a required field, or has a /// name that does not match . /// - /// - /// Uses the same bounded reader and rejection behavior as . - /// public static McpServerSkill Create(string uri, IEnumerable files) { #if NET @@ -145,16 +133,15 @@ public static McpServerSkill Create(string uri, IEnumerable /// /// /// - /// Prefer for the supported YAML subset. This - /// overload retains UTF-8 and frontmatter-boundary checks, structural entry and URI validation, and manifest - /// computation from the served bytes. Detected malformed input is rejected. When - /// reads the complete block, the supplied object must match it exactly. + /// Prefer , which reads the frontmatter from the + /// file. This overload still checks UTF-8, the frontmatter boundaries, and the entry's structure, computes the + /// manifest from the served bytes, and rejects malformed YAML the reader detects. When + /// reads the complete block, the supplied object must match it exactly. /// /// - /// When the bounded reader reports unsupported syntax, it may not examine later content. The application - /// must parse and validate the entire frontmatter with a suitable mechanism and supply identical JSON. - /// Acceptance of an explicit object is not proof that unsupported YAML is valid or matches the file. - /// Hosts still perform their own frontmatter verification before loading the skill. + /// When the reader stops at an unsupported construct, it does not examine the rest of the block, so acceptance + /// here proves neither that the YAML is valid nor that it matches . The caller + /// must have parsed the entire frontmatter with a full YAML parser and supplied the identical JSON. /// /// public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumerable files) @@ -190,10 +177,6 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera /// manifest or the served content. /// /// - /// This automatic overload uses the bounded reader. Unsupported or malformed - /// frontmatter is an error, not a reason to publish guessed metadata. - /// - /// /// 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 @@ -262,17 +245,9 @@ public static McpServerSkill CreateFromDirectory(string uri, string directoryPat /// or the directory contains a symbolic link or other reparse point. /// /// - /// - /// Prefer for supported YAML. This overload has the same - /// checks and application-owned full-frontmatter validation responsibility as - /// . An unsupported construct can - /// stop the reader before later content is examined; the application must validate the complete block and - /// provide matching JSON. When the reader can read it completely, mismatched metadata is rejected. - /// - /// - /// See for how files and links are handled. Hosts independently - /// verify fetched frontmatter regardless of this authoring path. - /// + /// Prefer . The supplied frontmatter is checked, and remains + /// the caller's responsibility, as described for . + /// See for how files and links are handled. /// public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs index d7b8dcc12..533c55388 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpSkillsBuilderExtensions.cs @@ -31,10 +31,6 @@ public static class McpSkillsBuilderExtensions /// are served through resources/list and resources/read. /// /// - /// Frontmatter is read or supplied when each is created. Registration does not - /// perform additional YAML parsing or establish that application-supplied metadata matches unsupported YAML. - /// - /// /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, /// provided every skill lists it with the same digest and the identical URI text. URIs that differ only in the /// case of their scheme or authority are rejected, because the server's resource collection treats them as one. @@ -136,9 +132,8 @@ public static IMcpServerBuilder WithSkills( /// /// /// Each skill is built with : files are read once, - /// digests are computed from the bytes served, and symbolic links are rejected. This optional provider uses - /// the bounded reader; unsupported or malformed frontmatter is an error. - /// Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one + /// digests are computed from the bytes served, symbolic links are rejected, and frontmatter + /// cannot read is an error. Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one /// of that skill's files. /// /// @@ -241,11 +236,6 @@ public static IMcpServerBuilder WithSkillsFromDirectory( /// overload. /// /// - /// The application or catalog author must supply structured frontmatter identical to the file. This - /// overload validates entry structure but does not fetch the files or validate their YAML. Hosts must - /// independently parse and compare fetched frontmatter before loading any skill. - /// - /// /// 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 diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index 34c3a7667..c6fabd895 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -19,15 +19,12 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, -/// otherwise strings). Other YAML parsers can differ in the forms and values they support; an application -/// supplying explicit metadata is responsible for parsing and validating the entire frontmatter with a -/// suitable mechanism and providing the correct JSON representation. +/// otherwise strings). Other YAML parsers can differ in the forms and values they support, so values meant to +/// be strings, such as version numbers, are best quoted. /// /// -/// Before loading a skill, hosts must parse the fetched SKILL.md and compare every frontmatter field with -/// the held entry, regardless of how the server authored it. A host may use this reader for its supported subset. -/// On unsupported or malformed input, the host must reject the content or use a suitable full parser; it must -/// not substitute the server-provided JSON for verification. +/// Hosts can use this reader for the frontmatter verification described on . A +/// from it is a failed verification unless the host parses the file another way. /// /// public static class SkillFrontmatter From 8398c300912b98c1adf5df0bcacbc93e01821c23 Mon Sep 17 00:00:00 2001 From: Girish Konda Date: Tue, 13 Oct 2026 12:00:00 -0700 Subject: [PATCH 5/5] Harden Skills Address Skills review feedback with strict entry validation, safe directory selection, bounded aggregation, explicit diagnostic pages, and validated catalog snapshots. Exercise Skills conformance with strict process exits. Count Unicode scalars consistently, retain entry metadata in owned snapshots, and authorize catalog entries before pagination. Support encoded names and native absolute resource URIs without rewriting resource identity. Type-check every frontmatter field the Agent Skills specification defines: reject a license or allowed-tools value that is not a string, and read the license, compatibility, and allowed-tools fields through typed Skill properties. Unknown fields still pass through. Reject a second Skills registration for the same services, and fail when another resource is registered at a Skills file URI. A registration that fails leaves no file resources behind. Treat a received string that is not valid Unicode text, such as an escaped unpaired surrogate, as malformed JSON: the diagnostic listing rejects the entry, and the strict list and get methods throw JsonException. Reject unsupported flow mappings instead of publishing incorrect frontmatter, and report valid YAML the reader does not support as unsupported rather than malformed. Read indented root mappings and single-line root flow mappings, reject any other root as not a mapping, and report multi-line flow collections, comments included, as unsupported only when they are valid YAML. Resolve hexadecimal and octal integers within 64 bits and report wider literals as unsupported. Reject a raw character outside YAML's printable set, such as a NUL, and a lone CR, NEL, LS or PS, on any frontmatter line before parsing; escapes such as \0 and \L stay valid. Report null read contents as verification failures. Strengthen conformance summary assertions and cover nested Unicode and native URI resource reads. Keep the frontmatter reader internal to server authoring; the client sample verifies fetched frontmatter with YamlDotNet, reading its parsing events within limits on nesting depth and alias expansion and rejecting alias cycles. The sample keeps the line break before the closing delimiter, so a final kept block scalar reads as it does on the server; a test compiles the sample's reader and compares it with the server. The sample also rejects the same raw characters as the server, strips one leading byte order mark as the server does, and rejects a tag on a collection that the conversion would change, such as !!str on a list. Document that a shared skill catalog must not capture scoped services. Test that injected parameters stay out of wire schemas when parameters are re-enumerated. Connect the Skills authorization and conformance fixture tests to their controlled test servers with TestMcpClient.CreateAsync, as the contributing guide asks. Use owned conformance-host readiness, bounded startup and cleanup, and deterministic cancellation-observation tests. Wait for the conformance runner's exit and the end of both output streams within one deadline. Kill a runner that is still running at the deadline together with its child processes. Read the runner's output streams on dedicated threads, so reads held open by a descendant do not tie up thread-pool threads. Validate repository links against the proposed checkout. --- .github/scripts/prepare_markdown_links.py | 41 ++ .../scripts/test_prepare_markdown_links.py | 47 ++ .github/workflows/markdown-link-check.yml | 18 +- Directory.Packages.props | 5 + docs/concepts/skills/skills.md | 166 ++++- package-lock.json | 20 +- package.json | 2 +- samples/SkillsClient/Program.cs | 76 +- samples/SkillsClient/README.md | 25 +- samples/SkillsClient/SkillsClient.csproj | 4 + samples/SkillsClient/YamlFrontmatterReader.cs | 390 ++++++++++ samples/SkillsServer/README.md | 2 + .../Client/McpSkillsClientExtensions.cs | 199 ++++- .../Client/SkillEntryRejection.cs | 23 + .../Client/SkillListingResult.cs | 23 + .../Client/SkillVerifier.cs | 16 +- .../Protocol/ListSkillsResult.cs | 1 + .../Protocol/Skill.cs | 43 +- .../Server/IMcpSkillCatalog.cs | 4 + .../Server/InMemoryMcpSkillCatalog.cs | 13 +- .../Server/McpServerSkill.cs | 119 ++- .../Server/McpSkillsBuilderExtensions.cs | 170 ++++- .../SkillFrontmatter.cs | 691 ++++++++++++++++-- .../SkillValidation.cs | 122 +++- tests/Common/Utils/NodeHelpers.cs | 232 ++++-- .../Program.cs | 28 +- .../ConformanceServerFixture.cs | 170 +++-- .../ConformanceServerFixtureTests.cs | 351 +++++++++ .../ServerConformanceTests.cs | 78 +- .../SkillsAuthorizationTests.cs | 192 +++++ .../Program.cs | 8 +- .../Skills/ConformanceSkills.cs | 4 +- .../McpSkillsClientUnreadableTextTests.cs | 139 ++++ .../Client/McpSkillsClientValidationTests.cs | 347 ++++++++- .../Client/McpSkillsEnumerationTests.cs | 190 +++++ .../Client/SkillVerifierTests.cs | 20 +- .../ModelContextProtocol.Tests.csproj | 7 + .../Protocol/SkillSerializationTests.cs | 115 ++- .../SkillsClientFrontmatterReaderTests.cs | 190 +++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 280 ++++++- .../Server/InjectedParameterSchemaTests.cs | 191 +++++ .../Server/McpServerNativeUriSkillTests.cs | 46 ++ .../McpServerNestedUnicodeSkillTests.cs | 58 ++ .../McpServerSkillAuthoringBoundaryTests.cs | 42 ++ .../Server/McpServerSkillTests.cs | 32 +- .../Server/McpServerSkillsCatalogTests.cs | 21 +- .../McpServerSkillsFromDirectoryTests.cs | 76 ++ .../McpServerSkillsRegistrationTests.cs | 232 ++++++ .../Server/McpSkillDirectorySelectionTests.cs | 107 +++ ...SkillFrontmatterNodeClassificationTests.cs | 322 +++++++- .../Server/SkillFrontmatterRegressionTests.cs | 390 +++++++++- .../Server/SkillFrontmatterTests.cs | 31 +- .../Utils/ConformanceProcessTests.cs | 140 ++++ 53 files changed, 5865 insertions(+), 394 deletions(-) create mode 100644 .github/scripts/prepare_markdown_links.py create mode 100644 .github/scripts/test_prepare_markdown_links.py create mode 100644 samples/SkillsClient/YamlFrontmatterReader.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs create mode 100644 src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs create mode 100644 tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs diff --git a/.github/scripts/prepare_markdown_links.py b/.github/scripts/prepare_markdown_links.py new file mode 100644 index 000000000..6bda0c192 --- /dev/null +++ b/.github/scripts/prepare_markdown_links.py @@ -0,0 +1,41 @@ +import argparse +from pathlib import Path + + +class RepositoryLinks: + def __init__(self, root): + self.root = Path(root).resolve() + + def prepare(self): + docs = self.root / "docs" + if not docs.is_dir(): + raise ValueError(f"Missing documentation directory: {docs}") + if docs.is_symlink(): + raise ValueError(f"Refusing to rewrite a documentation symlink: {docs}") + + changed = 0 + prefixes = ( + "https://github.com/modelcontextprotocol/csharp-sdk/tree/main/", + "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/", + ) + for path in docs.rglob("*.md"): + if path.is_symlink(): + raise ValueError(f"Refusing to rewrite a documentation symlink: {path}") + text = path.read_text(encoding="utf-8") + updated = text + for prefix in prefixes: + updated = updated.replace(prefix, "/") + if updated != text: + path.write_text(updated, encoding="utf-8") + changed += 1 + return changed + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Resolve this repository's main-branch links against the proposed checkout for mlc." + ) + parser.add_argument("root") + arguments = parser.parse_args() + count = RepositoryLinks(arguments.root).prepare() + print(f"Prepared repository-local links in {count} Markdown files; mlc still validates each target.") diff --git a/.github/scripts/test_prepare_markdown_links.py b/.github/scripts/test_prepare_markdown_links.py new file mode 100644 index 000000000..edfc1ee33 --- /dev/null +++ b/.github/scripts/test_prepare_markdown_links.py @@ -0,0 +1,47 @@ +from pathlib import Path +import tempfile +import unittest + +from prepare_markdown_links import RepositoryLinks + + +class RepositoryLinksTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + (self.root / "docs").mkdir() + + def test_main_links_become_checkout_paths_without_ignoring_missing_targets(self): + path = self.root / "docs" / "example.md" + path.write_text( + "[sample](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsServer)\n" + "[source](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/src/missing.cs)\n", + encoding="utf-8", + ) + + self.assertEqual(RepositoryLinks(self.root).prepare(), 1) + self.assertEqual( + path.read_text(encoding="utf-8"), + "[sample](/samples/SkillsServer)\n[source](/src/missing.cs)\n", + ) + + def test_other_repositories_and_pinned_revisions_remain_web_links(self): + path = self.root / "docs" / "example.md" + original = ( + "[other](https://github.com/other/project/tree/main/sample)\n" + "[pinned](https://github.com/modelcontextprotocol/csharp-sdk/tree/1234567/sample)\n" + "[relative](other.md)\n" + ) + path.write_text(original, encoding="utf-8") + + self.assertEqual(RepositoryLinks(self.root).prepare(), 0) + self.assertEqual(path.read_text(encoding="utf-8"), original) + + def test_missing_documentation_is_not_a_success(self): + with self.assertRaises(ValueError): + RepositoryLinks(self.root / "absent").prepare() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/markdown-link-check.yml b/.github/workflows/markdown-link-check.yml index 1dbf83b0e..73e01738d 100644 --- a/.github/workflows/markdown-link-check.yml +++ b/.github/workflows/markdown-link-check.yml @@ -3,10 +3,18 @@ name: Check Markdown links on: push: branches: [ "main" ] - paths: "**.md" + paths: + - "**.md" + - ".github/scripts/prepare_markdown_links.py" + - ".github/scripts/test_prepare_markdown_links.py" + - ".github/workflows/markdown-link-check.yml" pull_request: branches: [ "main" ] - paths: "**.md" + paths: + - "**.md" + - ".github/scripts/prepare_markdown_links.py" + - ".github/scripts/test_prepare_markdown_links.py" + - ".github/workflows/markdown-link-check.yml" permissions: contents: read @@ -18,6 +26,11 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Resolve main-branch links against the proposed checkout + run: | + python3 -m unittest discover -s .github/scripts -p test_prepare_markdown_links.py + python3 .github/scripts/prepare_markdown_links.py . + - name: Markup Link Checker (mlc) uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0 with: @@ -26,6 +39,7 @@ jobs: # Do not warn for learn.microsoft.com locale redirects. # Do not warn for modelcontextprotocol.io homepage/spec redirects. args: >- + --root-dir . --ignore-links "xref:*" --ignore-links "*samples/*?name=snippet_*" --do-not-warn-for-redirect-to "https://learn.microsoft.com/en-us/*" diff --git a/Directory.Packages.props b/Directory.Packages.props index 3b67c7093..c8f4191be 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -97,4 +97,9 @@ + + + + + diff --git a/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 001761393..a2e66b872 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -25,7 +25,11 @@ A skill entry () is a complet - `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. + are always present; every other authored field passes through. The + , , + , , + and properties read the string fields the Agent Skills + specification defines. - `resources`: either a complete manifest of the skill's files, each with a SHA-256 digest and size (), or the string `"dynamic"` for generated content that cannot be digested. @@ -62,10 +66,21 @@ builder.Services }); ``` -Every immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each +Every included immediate subdirectory containing a `SKILL.md` becomes a skill. The SDK reads the frontmatter from each `SKILL.md`, computes every digest and size from the same bytes the resources serve, registers the file resources, and publishes each skill at `skill://{name}/SKILL.md`, where `name` comes from the frontmatter. Pass a `uriPrefix` such as `skill://acme/billing/` to place the skills under an organizational path. +Names used as URI path segments are percent-encoded. The SDK decodes the final name segment once +when comparing it with frontmatter; the supplied URI text remains the skill's identifier. Nested skills +sharing a file must use the identical URI text, including its encoding, so verified reads name the same resource. +Native absolute resource URIs such as `repo:/skills/refunds/SKILL.md` and `repo:skills/refunds/SKILL.md` +are also supported; an absolute resource URI does not need an authority introduced by `://`. + +Dot-prefixed, hidden, and system files and directories are excluded by default, before reading their contents +or counting them toward the published manifest's limits. The `includeHiddenAndSystemFiles: true` overloads of +`WithSkillsFromDirectory` and `McpServerSkill.CreateFromDirectory` provide an explicit opt-in. For a narrower +selection, use `McpServerSkill.Create` with explicit files; that API keeps the files the caller selected. +Included symbolic links remain errors rather than being followed. For finer control, build skills individually with and register them with `WithSkills`: @@ -84,36 +99,62 @@ var refunds = McpServerSkill.Create( builder.Services.AddMcpServer().WithHttpTransport().WithSkills([gitWorkflow, refunds]); ``` +Register skills once per server. `WithSkills` and `WithSkillsFromDirectory` install the `skills/list` and `skills/get` +handlers, so a second call for the same service collection throws before it +registers anything. To serve skills from several directories or sources, pass every `McpServerSkill` to one +`WithSkills` call, as above, or implement one `IMcpSkillCatalog` that combines them. + +Each skill file must be the only resource at its URI. If another resource is registered at a URI that the server's +resource collection treats as the same (scheme and authority are compared without case), building the server's +options throws naming the URI, whether that resource was registered before or +after the skills. Without this check, one of the two would be served in place of the other. + All of these validate structural entry and file constraints and throw with a specific message when, for example, the frontmatter `name` does not match the URI, `description` exceeds the -Agent Skills limit of 1024 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 +Agent Skills limit of 1024 Unicode scalar values, `compatibility` exceeds 500 Unicode scalar values, +`license` or `allowed-tools` is not a string, a resource URI escapes the skill's directory, `SKILL.md` is missing, or +the skill exceeds the per-skill limits of 512 files or 16 MiB. Every field the Agent Skills specification defines is +type-checked: `name`, `description`, `license`, `compatibility`, and `allowed-tools` must be strings, and `metadata` a +string-to-string map. Other frontmatter fields pass through verbatim, as the specification requires; hosts compare +them against the file. Hosts still ignore `allowed-tools` for MCP-origin skills unless the user has approved that grant. File contents are copied when the skill is created, so later changes to a caller's buffer or to files on disk do not affect what is served. `CreateFromDirectory` does not follow symbolic links, since a link can point outside the skill directory; it throws if it encounters one. File names containing characters with URI syntax (such as `{`, `?`, or a space) are percent-encoded in the resource URIs. +The optional property carries entry-level `_meta`. +It is separate from the frontmatter's string-to-string `metadata` map and may contain arbitrary JSON values. +Catalog snapshots preserve it without sharing mutable objects with callers. + #### Frontmatter - reads the YAML frontmatter of a `SKILL.md` into a - without a YAML library. It supports block mappings (nested up to 32 -levels), block and flow sequences, plain, quoted, and block scalars, and comments. Unquoted scalars are resolved per +The `Create` and `CreateFromDirectory` overloads that do not take a `JsonObject` read the YAML frontmatter of +`SKILL.md` with the SDK's built-in reader, which needs no YAML library. It reads a bounded subset of YAML: block +mappings (nested up to 32 levels), block sequences, single-line flow collections, plain, quoted, and block scalars, +and comments. The root must be a mapping. Unquoted scalars are resolved per the YAML 1.2 core schema (`null`, booleans, integers, finite floats, otherwise strings), the resolution the `yaml` package and `yaml.v3` use; other parsers can differ at the edges. That matters because a host verifies a skill by -parsing the fetched `SKILL.md` itself and comparing field by field against the published entry; a value that one -side types as a number and the other as a string is a verification failure. Quote values such as version numbers -that are meant to be strings. - -Anchors, aliases, tags, complex keys, nested and multi-line flow collections, and multi-line quoted scalars are not -supported; the reader rejects them with a naming the construct rather than guess. -For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the +parsing the fetched `SKILL.md` itself with a full YAML parser and comparing field by field against the published +entry; a value that one side types as a number and the other as a string is a verification failure. Quote values +such as version numbers that are meant to be strings. Hexadecimal (`0x`) and octal (`0o`) integers keep their exact +value up to 64 bits, leading zeros included. Wider ones are reported as unsupported, because parsers disagree on +them: `yaml.v3` reads them as strings and the `yaml` package as rounded floating-point numbers. + +Anchors, aliases, tags, complex (`? `) and empty mapping keys, nested and multi-line flow collections (including +`{items:[one]}`, and any flow collection with a comment inside, since a comment runs to the end of its line), +multi-line quoted scalars, nesting deeper than 32 levels, and hexadecimal or octal integers wider than 64 bits are +not supported; these overloads reject them with an naming the construct rather than +guess. For such a file, the `Create` and `CreateFromDirectory` overloads that take an explicit `JsonObject` supply the frontmatter directly. They still reject invalid UTF-8, a missing frontmatter block, and malformed YAML the reader -detects, and when the reader can parse the whole block, the supplied object must match it exactly. +detects, and when the reader can parse the whole block, the supplied object must match it exactly. Every overload +also rejects, anywhere in the frontmatter, comments and quoted strings included, a raw character outside YAML's +printable set, such as a NUL, and a raw carriage return without a line feed, NEL, LS or PS. YAML 1.2.2 allows some of +the first kind in quoted strings, but libyaml, PyYAML and Go's `yaml.v3` reject them anywhere, and YAML parsers +disagree on whether the second kind ends a line. Write such a character as an escape in a double-quoted string, such +as `\0` or `\L`. -The reader stops at the first unsupported construct, though, so for such a file it cannot check the rest of the -block or compare it with the supplied object. The application must parse the entire frontmatter with a full YAML -parser and supply the identical JSON; the SDK accepting it is not proof that it matches. +The reader can stop at the first unsupported construct, though, so for such a file it may not check the rest of the +block, and it cannot compare the block with the supplied object. The application must parse the entire frontmatter +with a full YAML parser and supply the identical JSON; the SDK accepting it is not proof that it matches. #### Custom catalogs @@ -139,6 +180,10 @@ Both methods receive an when the transport supplies one (the ASP.NET Core transport does), and any items that incoming-message filters attached to the request. +The catalog passed to `WithSkills` is one instance shared by every request, so it must not capture scoped services, +such as a database context, directly. A catalog that needs scoped services can create its own scope for each call +with `IServiceScopeFactory`; such a scope is independent of the request's scope and does not share its instances. + A catalog supplies entries only; the skills' files must still be served as resources, since hosts read them with `resources/read`. Each entry's frontmatter must match its `SKILL.md`; the SDK validates entry structure but does not read the files to check this. A catalog may list only part of what it serves, or nothing at all, as long as @@ -147,6 +192,32 @@ read the files to check this. A catalog may list only part of what it serves, or cursor the catalog cannot interpret. is the built-in implementation over a fixed set of entries and can be composed into a custom one. +To show different callers different skills, filter the entries first and paginate only the filtered set. Every +page and continuation cursor then covers only skills the caller may read. Filtering a finished page while passing +on its cursor is not enough, because the cursor can still lead to hidden skills. Building an +`InMemoryMcpSkillCatalog` validates and copies its entries, so keep one filtered view per role or user instead of +building a new one on every request when that is expensive: + +```csharp +public sealed class RoleSkillCatalog(IReadOnlyList skills, SkillAccessPolicy policy) : IMcpSkillCatalog +{ + private readonly ConcurrentDictionary _viewsByRole = new(); + + public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) => + GetView(context.User).ListAsync(cursor, context, cancellationToken); + + public ValueTask GetAsync(string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + GetView(context.User).GetAsync(uri, context, cancellationToken); + + // Authorize before paginating: the view holds only the skills this role may read. + private InMemoryMcpSkillCatalog GetView(ClaimsPrincipal? user) => + _viewsByRole.GetOrAdd(policy.GetRole(user), role => + new InMemoryMcpSkillCatalog(skills.Where(skill => policy.CanRead(role, skill.Uri)))); +} +``` + +Guard `resources/read` for the skills' files with the same policy, for example with a read-resource request filter. + #### Dynamic skills A skill whose content is generated on demand cannot publish stable digests. Set its entry's `Resources` to @@ -193,8 +264,9 @@ if (contents.Contents.Single(content => content.Uri == skill.Uri) is not TextRes throw new SkillVerificationException("A text SKILL.md is required for this example."); } -// A parse failure, including unsupported YAML, rejects this load. Do not fall back to skill.Frontmatter. -var fetchedFrontmatter = SkillFrontmatter.Parse(text.Text); +// Parse the fetched frontmatter into JSON with a full YAML parser of your choice; the SkillsClient sample uses +// YamlDotNet. A parse failure rejects this load. Do not fall back to skill.Frontmatter. +JsonObject fetchedFrontmatter = ParseFrontmatter(text.Text); if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) { throw new SkillVerificationException("The fetched frontmatter differs from the held entry."); @@ -214,6 +286,37 @@ return one page or the raw result and expose the caching hints. exposes the verification and digest helpers for hosts that read resources through other means. +#### Listing limits and rejected entries + +The aggregating `ListSkillsAsync()` overload stops at 100 pages or 1,000 entries by default. Repeated cursors +and exceeded limits throw `McpException`; no silently truncated list is returned. Empty cursors remain opaque +values, not an end marker. Use explicit positive limits when appropriate: + +```csharp +var skills = await client.ListSkillsAsync(maxPages: 200, maxSkills: 5000); +``` + +Use the paged overload for application-controlled iteration. Strict listing still rejects a page containing +an invalid entry. To retain valid entries while reporting the others, opt into +`ListSkillsWithDiagnosticsAsync`: + +```csharp +var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams()); +foreach (var rejected in listing.RejectedEntries) +{ + Console.Error.WriteLine($"Skill entry {rejected.Index} was rejected: {rejected.Error.Message}"); +} +foreach (var listedSkill in listing.Page.Skills) +{ + Console.WriteLine(listedSkill.Uri); +} +``` + +`Page` preserves the original cursor and cache hints. `RejectedEntries` identifies each invalid entry by its +position in the original page and retains the parsing/validation error. An empty `Page.Skills` can mean every +entry was rejected, so callers must inspect the errors. A malformed page envelope or missing `skills` array +still fails the request. This API does not load rejected entries or replace file/frontmatter verification. + ### Security considerations Skill content is instructional text delivered to a model and is therefore a prompt-injection surface. The @@ -224,7 +327,11 @@ specification places most of the burden on hosts. In particular: 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. + in-memory catalog serves the same entries to every caller. Use one shared application policy for both + metadata and file access, and filter before paginating as shown in [Custom catalogs](#custom-catalogs), so + continuation cursors cannot disclose hidden skill URIs. Filtering a completed page while forwarding its + unfiltered cursor is not sufficient. Do not derive authorization from `resources/list` membership: + listings may intentionally be partial while an authorized direct read remains valid. - Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters the model's context. - Digests are unsigned and come from the same server as the content. A match proves consistency between the entry @@ -236,12 +343,13 @@ 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` automatically, and a host must do so before loading a skill, as the example above shows: - on the fetched text and - -against the entry's frontmatter, treating a parse failure or a difference as a verification failure. A host that -must accept YAML the reader does not support can parse the file with a full YAML library instead, but never uses -the entry's frontmatter in place of parsing the file. +the fetched `SKILL.md` automatically, and a host must do so before loading a skill, as the example above shows: parse +the fetched text with a full YAML parser of its choice, convert the frontmatter to JSON, and compare it with the +entry's frontmatter using +, +treating a parse failure or a difference as a verification failure. The +[SkillsClient](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/SkillsClient) sample shows one way, +with YamlDotNet. A host never uses the entry's frontmatter in place of parsing the file. ### Not implemented diff --git a/package-lock.json b/package-lock.json index 774becc10..bf045e389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } @@ -23,13 +23,15 @@ } }, "node_modules/@modelcontextprotocol/conformance": { - "version": "0.2.0-alpha.9", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.9.tgz", - "integrity": "sha512-Bi5P5TQlOQGPJxCT7UAHbpG7wsR7sNZskHGtCoZBo6vDu416D2FXPgM4wKbg91teIgj4HjGkhnzlvP7U2dszfQ==", + "version": "0.2.0-alpha.12", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/conformance/-/conformance-0.2.0-alpha.12.tgz", + "integrity": "sha512-eXtZFgmtZiU5SsvXty+RGXGnyIqEVm3GLU/HZmg1wMjtKOocPZZgD9HQKd30oJhio5UB0wFJmsc6r8u48Q+EYA==", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "@octokit/rest": "^22.0.0", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", "commander": "^14.0.2", "eventsource-parser": "^3.0.8", "express": "^5.1.0", @@ -134,6 +136,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", @@ -303,9 +306,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -628,6 +631,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -848,6 +852,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.0.tgz", "integrity": "sha512-jhunvfHWxd7J5EFfSgH4xsYJzSe/lfqbUCxiyyeaQasUsXeEHXtzVid+7EOGByc5JnFa23SSFL3Y2RV/z1T+eQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -1537,6 +1542,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 577d3e1f8..abe1fc863 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "private": true, "description": "Pinned npm dependencies for MCP C# SDK integration and conformance tests", "dependencies": { - "@modelcontextprotocol/conformance": "0.2.0-alpha.9", + "@modelcontextprotocol/conformance": "0.2.0-alpha.12", "@modelcontextprotocol/server-everything": "2026.1.26", "@modelcontextprotocol/server-memory": "2026.1.26" } diff --git a/samples/SkillsClient/Program.cs b/samples/SkillsClient/Program.cs index 464d58044..f5703065d 100644 --- a/samples/SkillsClient/Program.cs +++ b/samples/SkillsClient/Program.cs @@ -1,18 +1,20 @@ // 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). +// 2. Enumerate the skills with skills/list, page by page, showing each page's caching hints with its entries. // 3. Retrieve a single skill by URI with skills/get, as a host does for a URI mentioned in server instructions. -// 4. Verify fetched bytes against the manifest, then independently parse and compare SKILL.md frontmatter. +// 4. Verify fetched bytes against the manifest, then parse SKILL.md frontmatter with YamlDotNet and compare it. // 5. Show that a file the manifest does not list is refused before any request is sent. // // Start the SkillsServer sample first (dotnet run --project samples/SkillsServer). Pass a different endpoint as // the first argument to run against another server. +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using System.Text.Json.Nodes; +using YamlDotNet.Core; var endpoint = new Uri(args.Length > 0 ? args[0] : "http://localhost:3001"); @@ -22,7 +24,9 @@ Endpoint = endpoint, })); -Console.WriteLine($"Connected to {client.ServerInfo.Name} {client.ServerInfo.Version} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); +// A server using server/discover may omit its optional identity, in which case ServerInfo is null. +string serverName = client.ServerInfo is { } serverInfo ? $"{serverInfo.Name} {serverInfo.Version}" : "the server"; +Console.WriteLine($"Connected to {serverName} at {endpoint} (protocol {client.NegotiatedProtocolVersion})"); Console.WriteLine(); // 1. Capability check. Clients issue skills/list and skills/get only after observing the declaration. @@ -33,20 +37,45 @@ } // 2. Enumerate. A listing may be empty or partial; a skill can always be fetched by URI. +// The per-page overload returns each page's caching hints with its entries. Only a missing cursor ends the +// listing, so bound the number of pages and refuse a repeated cursor, as client.ListSkillsAsync() does when +// the hints are not needed. Console.WriteLine("=== skills/list ==="); -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) +const int MaxPages = 100; +var requestParams = new ListSkillsRequestParams(); +var seenCursors = new HashSet(StringComparer.Ordinal); +for (int pageNumber = 1; ; pageNumber++) { - string manifest = listed.Resources.IsDynamic - ? "dynamic (no digests)" - : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes"; - Console.WriteLine($" {listed.Uri}"); - Console.WriteLine($" name: {listed.Name}"); - Console.WriteLine($" description: {listed.Description}"); - Console.WriteLine($" manifest: {manifest}"); + if (pageNumber > MaxPages) + { + throw new McpException($"The skill listing exceeded the limit of {MaxPages} pages."); + } + + ListSkillsResult page = await client.ListSkillsAsync(requestParams); + Console.WriteLine($" page {pageNumber} caching hints: ttlMs={(page.TimeToLive is { } ttl ? ttl.TotalMilliseconds.ToString() : "(none)")} cacheScope={page.CacheScope?.ToString() ?? "(none)"}"); + + foreach (Skill listed in page.Skills) + { + string manifest = listed.Resources.IsDynamic + ? "dynamic (no digests)" + : $"{listed.Resources.Resources!.Count} file(s), {listed.Resources.Resources.Sum(r => r.Size)} bytes"; + Console.WriteLine($" {listed.Uri}"); + Console.WriteLine($" name: {listed.Name}"); + Console.WriteLine($" description: {listed.Description}"); + Console.WriteLine($" manifest: {manifest}"); + } + + if (page.NextCursor is null) + { + break; + } + + if (!seenCursors.Add(page.NextCursor)) + { + throw new McpException("The server repeated a skills/list pagination cursor."); + } + + requestParams = new ListSkillsRequestParams { Cursor = page.NextCursor }; } Console.WriteLine(); @@ -66,6 +95,7 @@ // 4. ReadSkillResourceAsync checks manifest membership, URI, size and digest, not frontmatter. // The host must independently verify frontmatter before loading, regardless of the server's authoring path. +// This sample parses it with YamlDotNet, a full YAML parser (see YamlFrontmatterReader.cs). Console.WriteLine("=== resources/read (verified) ==="); ReadResourceResult skillFile = await client.ReadSkillResourceAsync(skill, SkillUri); if (skillFile.Contents.Single(content => content.Uri == SkillUri) is not TextResourceContents skillText) @@ -75,16 +105,16 @@ try { - var fetchedFrontmatter = SkillFrontmatter.Parse(skillText.Text); + var fetchedFrontmatter = new YamlFrontmatterReader().Read(skillText.Text); if (!JsonNode.DeepEquals(fetchedFrontmatter, skill.Frontmatter)) { throw new SkillVerificationException("The fetched SKILL.md frontmatter differs from the held entry."); } } -catch (FormatException exception) +catch (Exception exception) when (exception is YamlException or FormatException) { - // This sample rejects unsupported YAML. A full host may instead use a suitable full parser, - // but must never treat the server-provided JSON as verification of the fetched file. + // Invalid YAML, or frontmatter without a JSON form, rejects this load. The server-provided JSON is never a + // substitute for parsing the fetched file. throw new SkillVerificationException("The fetched SKILL.md frontmatter could not be verified.", exception); } @@ -93,7 +123,13 @@ 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)"); +string supportingSize = supportingFile.Contents.Single(content => content.Uri == supportingFileUri) switch +{ + TextResourceContents text => $"{text.Text.Length} chars", + BlobResourceContents blob => $"{blob.DecodedData.Length} bytes", + _ => "content of an unknown kind", +}; +Console.WriteLine($" {supportingFileUri} verified ({supportingSize})"); Console.WriteLine(); // 5. An unlisted file is a change to the skill. While acting on the held entry, the host must not read it. diff --git a/samples/SkillsClient/README.md b/samples/SkillsClient/README.md index fafdf6252..08e716b72 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -6,13 +6,15 @@ 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). +2. Pages through the catalog with `skills/list` using the per-page overload (`client.ListSkillsAsync(requestParams)`), + so each page's `ttlMs` and `cacheScope` hints are shown with that page's entries. Only a missing cursor ends the + listing; the sample stops after 100 pages and refuses a repeated cursor. When the hints are not needed, + `client.ListSkillsAsync()` follows the pages itself, within 100 pages and 1,000 entries by default. 3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it found in server instructions. 4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size - (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, independently parses its - frontmatter and compares every field with the held entry. + (`client.ReadSkillResourceAsync(skill, uri)`). Before displaying `SKILL.md`, parses its frontmatter with + YamlDotNet and compares every field with the held entry. 5. Shows that a file the manifest does not list is refused before any request is sent. ## Run @@ -39,7 +41,7 @@ Expected output (abridged): ``` === skills/list === - caching hints: ttlMs=300000 cacheScope=Public + page 1 caching hints: ttlMs=300000 cacheScope=Public skill://git-workflow/SKILL.md name: git-workflow manifest: 3 file(s), 1523 bytes @@ -66,8 +68,13 @@ 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. -- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` with - `SkillFrontmatter.Parse` and compares it to the entry, rejecting the skill on a difference or on YAML the reader - does not support. A host that must accept such YAML can use a full YAML parser instead, but never the entry's - own frontmatter. +- Digest verification is not frontmatter verification. The sample also parses the fetched `SKILL.md` frontmatter + with YamlDotNet, a full YAML parser (`YamlFrontmatterReader.cs`), converts it to JSON with YAML 1.2 core-schema + typing, and compares it to the entry. It rejects the skill on a difference, on invalid YAML, on a raw character the + server also rejects (one outside YAML's printable set, or a CR without a line feed, NEL, LS or PS, which YamlDotNet + would accept, drop or read as a line break), on a tag it does not convert, on frontmatter JSON cannot represent, or + on frontmatter past its limits on nesting depth and alias expansion, including an alias cycle. A host can use any + full YAML parser, but never the entry's own frontmatter. - This is not a complete host: approval binding, origin isolation, and permission decisions are left out. +- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative + for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable. diff --git a/samples/SkillsClient/SkillsClient.csproj b/samples/SkillsClient/SkillsClient.csproj index 01f44dcb0..7428e384c 100644 --- a/samples/SkillsClient/SkillsClient.csproj +++ b/samples/SkillsClient/SkillsClient.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/samples/SkillsClient/YamlFrontmatterReader.cs b/samples/SkillsClient/YamlFrontmatterReader.cs new file mode 100644 index 000000000..9a25da9fc --- /dev/null +++ b/samples/SkillsClient/YamlFrontmatterReader.cs @@ -0,0 +1,390 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using YamlDotNet.Core; +using YamlDotNet.Core.Events; + +/// +/// Reads the YAML frontmatter of a fetched SKILL.md into JSON with YamlDotNet, a full YAML parser, so that the +/// host can compare it with the frontmatter of the entry it holds. +/// +/// +/// +/// Plain scalars are typed per the YAML 1.2 core schema (null, booleans, integers, finite floats, otherwise +/// strings), the typing the server used for the entry. Frontmatter that JSON cannot represent, such as .inf or +/// a key that is a collection, is rejected, and so is a tag this sample does not convert, on a scalar or a collection. +/// +/// +/// The same server supplies the file and its manifest, so the content is untrusted. Before parsing, the reader rejects +/// the raw characters the server's reader rejects: one outside YAML's printable set, and a lone carriage return, NEL, +/// LS or PS. It works from YamlDotNet's parsing events rather than its recursive document model, and rejects +/// frontmatter nested more than 32 levels deep, frontmatter that aliases expand to more than 10,000 nodes, and an +/// alias inside the node it refers to. +/// +/// +internal sealed class YamlFrontmatterReader +{ + private const string StringTag = "tag:yaml.org,2002:str"; + private const string SequenceTag = "tag:yaml.org,2002:seq"; + private const string MappingTag = "tag:yaml.org,2002:map"; + private const string NotAMappingMessage = "The frontmatter must be a YAML mapping of keys to values."; + + /// The deepest nesting of collections read, the same limit as the SDK's frontmatter reader. + private const int MaxDepth = 32; + + /// The most nodes read, counting each alias as the nodes it expands to. + private const int MaxNodes = 10_000; + + private readonly Regex _decimalInteger = new("^[-+]?[0-9]+$", RegexOptions.CultureInvariant); + private readonly Regex _hexInteger = new("^0x[0-9a-fA-F]+$", RegexOptions.CultureInvariant); + private readonly Regex _octalInteger = new("^0o[0-7]+$", RegexOptions.CultureInvariant); + private readonly Regex _float = new(@"^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$", RegexOptions.CultureInvariant); + private readonly Regex _infinityOrNaN = new(@"^([-+]?\.(inf|Inf|INF)|\.(nan|NaN|NAN))$", RegexOptions.CultureInvariant); + + /// The latest node defined for each anchor name; an alias refers to it. + private readonly Dictionary _anchors = new(StringComparer.Ordinal); + + private int _nodeCount; + + /// Reads the frontmatter at the start of . + /// The frontmatter is not valid YAML. + /// + /// The frontmatter is missing, has a raw character the server's reader also rejects, is not a mapping, has no JSON + /// form, is beyond this reader's limits, or cannot be read by YamlDotNet. + /// + public JsonObject Read(string skillMarkdown) + { + _anchors.Clear(); + _nodeCount = 0; + + string frontmatter = ExtractFrontmatter(skillMarkdown); + ThrowIfDisallowedCharacter(frontmatter); + try + { + return ReadDocument(new Parser(new StringReader(frontmatter))); + } + catch (InvalidOperationException e) + { + // YamlDotNet's scanner reports some input this way rather than with a YamlException, including some valid + // YAML, such as a comment inside a multi-line flow collection. + throw new FormatException("YamlDotNet could not read the frontmatter.", e); + } + } + + private JsonObject ReadDocument(IParser parser) + { + parser.Consume(); + if (!parser.TryConsume(out _) || + ReadNode(parser, depth: 0).Node is not JsonObject root) + { + throw new FormatException(NotAMappingMessage); + } + + parser.Consume(); + parser.Consume(); + return root; + } + + /// + /// Returns the lines between the opening --- line and the next --- or ... line, each with its + /// line break. Keeping the last one matters: a final block scalar that keeps its trailing line breaks (|+) + /// reads the same as on the server. + /// + private string ExtractFrontmatter(string skillMarkdown) + { + // Like the server, drop one byte order mark; a second one is content, so the '---' check below fails. + string text = skillMarkdown.StartsWith('\uFEFF') ? skillMarkdown.Substring(1) : skillMarkdown; + string[] lines = text.Replace("\r\n", "\n").Split('\n'); + if (lines[0].TrimEnd(' ', '\t') != "---") + { + throw new FormatException("SKILL.md must begin with a '---' line that opens the YAML frontmatter."); + } + + for (int i = 1; i < lines.Length; i++) + { + if (lines[i].TrimEnd(' ', '\t') is "---" or "...") + { + return string.Join("\n", lines, 1, i - 1) + "\n"; + } + } + + throw new FormatException("The YAML frontmatter of SKILL.md is not closed by a '---' line."); + } + + /// + /// Rejects the raw characters the server's reader rejects: one outside YAML's printable set (YAML 1.2.2 + /// c-printable), and a lone carriage return, NEL, LS or PS, which YamlDotNet reads as a line break and the + /// server would not. Line feeds after carriage returns are already normalized, so any carriage return left is a lone + /// one. YamlDotNet accepts most of the others and can skip a NUL, so the check runs before parsing. + /// + private void ThrowIfDisallowedCharacter(string frontmatter) + { + for (int i = 0; i < frontmatter.Length; i++) + { + char c = frontmatter[i]; + if (char.IsHighSurrogate(c) && + i + 1 < frontmatter.Length && + char.IsLowSurrogate(frontmatter[i + 1])) + { + i++; + } + else if (c is '\r' or '\u0085' or '\u2028' or '\u2029') + { + throw new FormatException($"The frontmatter contains U+{(int)c:X4}, which some YAML parsers read as a line break and others as text."); + } + else if (!IsPrintable(c)) + { + throw new FormatException($"The frontmatter contains U+{(int)c:X4}, which is outside YAML's printable character set."); + } + } + } + + /// Whether YAML's printable set contains , a character that is not a surrogate. + private bool IsPrintable(char c) => + c is '\t' or '\n' or '\r' or (>= ' ' and <= '~') or '\u0085' or (>= '\u00A0' and <= '\uD7FF') or (>= '\uE000' and <= '\uFFFD'); + + /// + /// Reads the node at the parser's position, inside collections. Returns it with the + /// number of nodes it holds and how many collection levels it spans. + /// + private ReadResult ReadNode(IParser parser, int depth) + { + if (parser.TryConsume(out var alias)) + { + return ExpandAlias(alias.Value.Value, depth); + } + + if (parser.TryConsume(out var scalar)) + { + CountNodes(1); + var value = new ReadResult(ToJsonScalar(scalar), Count: 1, Height: 0); + if (!scalar.Anchor.IsEmpty) + { + _anchors[scalar.Anchor.Value] = new AnchoredNode { Value = value }; + } + + return value; + } + + if (depth == MaxDepth) + { + throw new FormatException($"The frontmatter is nested more than {MaxDepth} levels deep, which this sample does not read."); + } + + if (parser.TryConsume(out var sequenceStart)) + { + ThrowIfTagChangesCollection(sequenceStart.Tag, SequenceTag); + var anchored = Define(sequenceStart.Anchor); + CountNodes(1); + var array = new JsonArray(); + int count = 1; + int height = 1; + while (!parser.TryConsume(out _)) + { + var item = ReadNode(parser, depth + 1); + array.Add(item.Node); + count += item.Count; + height = Math.Max(height, item.Height + 1); + } + + return Complete(anchored, new ReadResult(array, count, height)); + } + + if (parser.TryConsume(out var mappingStart)) + { + ThrowIfTagChangesCollection(mappingStart.Tag, MappingTag); + var anchored = Define(mappingStart.Anchor); + CountNodes(1); + var mapping = new JsonObject(); + int count = 1; + int height = 1; + while (!parser.TryConsume(out _)) + { + var key = ReadNode(parser, depth + 1); + string name = ToJsonKey(key.Node); + if (mapping.ContainsKey(name)) + { + throw new FormatException($"The frontmatter key '{name}' appears more than once."); + } + + var value = ReadNode(parser, depth + 1); + mapping[name] = value.Node; + count += key.Count + value.Count; + height = Math.Max(height, value.Height + 1); + } + + return Complete(anchored, new ReadResult(mapping, count, height)); + } + + throw new FormatException("The frontmatter contains a YAML node this sample does not convert."); + } + + /// + /// Rejects a tag on a collection unless converting the collection keeps its meaning: the non-specific ! or + /// the tag for that kind of collection. !!str [one], for example, is not a list of strings. + /// + private void ThrowIfTagChangesCollection(TagName tag, string collectionTag) + { + if (tag.IsEmpty || + tag.IsNonSpecific || + tag.Value == collectionTag) + { + return; + } + + throw new FormatException($"A frontmatter collection has the tag '{tag.Value}', which this sample does not convert."); + } + + /// Copies the node an alias refers to, within the depth and node limits. + private ReadResult ExpandAlias(string anchor, int depth) + { + if (!_anchors.TryGetValue(anchor, out var anchored)) + { + throw new FormatException($"The frontmatter alias '*{anchor}' has no anchor before it."); + } + + // The node is still being read, so the alias is inside it and the frontmatter would refer to itself. + var target = anchored.Value ?? + throw new FormatException($"The frontmatter alias '*{anchor}' is inside the node it refers to."); + if (depth + target.Height > MaxDepth) + { + throw new FormatException($"The frontmatter is nested more than {MaxDepth} levels deep, which this sample does not read."); + } + + CountNodes(target.Count); + return target with { Node = target.Node?.DeepClone() }; + } + + /// Registers an anchor when its collection starts, so an alias inside the collection is recognized. + private AnchoredNode? Define(AnchorName anchor) + { + if (anchor.IsEmpty) + { + return null; + } + + var anchored = new AnchoredNode(); + _anchors[anchor.Value] = anchored; + return anchored; + } + + private ReadResult Complete(AnchoredNode? anchored, ReadResult result) + { + if (anchored is not null) + { + anchored.Value = result; + } + + return result; + } + + private void CountNodes(int count) + { + _nodeCount += count; + if (_nodeCount > MaxNodes) + { + throw new FormatException($"The frontmatter expands to more than {MaxNodes} nodes, which this sample does not read."); + } + } + + /// JSON keys are strings: null becomes the empty string, and booleans and numbers their JSON text. + private string ToJsonKey(JsonNode? key) => key switch + { + null => string.Empty, + JsonValue value when value.TryGetValue(out string? text) => text, + JsonValue value => value.ToJsonString(), + _ => throw new FormatException("A frontmatter key is a collection, which JSON cannot represent."), + }; + + private JsonNode? ToJsonScalar(Scalar scalar) + { + string text = scalar.Value; + if (!scalar.Tag.IsEmpty) + { + // '!' and '!!str' make the value a string; other tags are left to a host that knows them. + if (scalar.Tag.IsNonSpecific || + scalar.Tag.Value == StringTag) + { + return JsonValue.Create(text); + } + + throw new FormatException($"The frontmatter value '{text}' has the tag '{scalar.Tag.Value}', which this sample does not convert."); + } + + return scalar.Style == ScalarStyle.Plain ? ResolvePlainScalar(text) : JsonValue.Create(text); + } + + /// Types an unquoted scalar per the YAML 1.2 core schema. + private JsonNode? ResolvePlainScalar(string text) + { + switch (text) + { + case "" or "~" or "null" or "Null" or "NULL": + return null; + case "true" or "True" or "TRUE": + return JsonValue.Create(true); + case "false" or "False" or "FALSE": + return JsonValue.Create(false); + } + + if (_infinityOrNaN.IsMatch(text)) + { + throw new FormatException($"The frontmatter value '{text}' cannot be represented in JSON."); + } + + if (_decimalInteger.IsMatch(text)) + { + // Exact at any size, as JSON allows; "-0" is 0. + bool negative = text[0] == '-'; + string digits = text.TrimStart('-', '+').TrimStart('0'); + return JsonNode.Parse(digits.Length == 0 ? "0" : negative ? "-" + digits : digits); + } + + if (_hexInteger.IsMatch(text) || + _octalInteger.IsMatch(text)) + { + return JsonNode.Parse(ParseUnsigned(text).ToString(CultureInfo.InvariantCulture)); + } + + if (_float.IsMatch(text)) + { + double value = double.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); + if (double.IsInfinity(value)) + { + throw new FormatException($"The frontmatter value '{text}' cannot be represented as a JSON number."); + } + + // JSON has no negative zero worth keeping. + return JsonValue.Create(value == 0 ? 0d : value); + } + + return JsonValue.Create(text); + } + + /// Reads a 0x or 0o integer exactly, up to 64 bits. + private ulong ParseUnsigned(string text) + { + int bitsPerDigit = text[1] == 'x' ? 4 : 3; + ulong value = 0; + foreach (char c in text.Substring(2)) + { + if (value >> (64 - bitsPerDigit) != 0) + { + throw new FormatException($"The frontmatter value '{text}' is wider than 64 bits, which this sample does not convert."); + } + + value = (value << bitsPerDigit) + (ulong)Convert.ToInt32(c.ToString(), 16); + } + + return value; + } + + /// A node read from the frontmatter, with the number of nodes it holds and the collection levels it spans. + private readonly record struct ReadResult(JsonNode? Node, int Count, int Height); + + /// The node defined for an anchor; is null while its collection is still being read. + private sealed class AnchoredNode + { + public ReadResult? Value { get; set; } + } +} diff --git a/samples/SkillsServer/README.md b/samples/SkillsServer/README.md index 6ec5bb535..48e247d9f 100644 --- a/samples/SkillsServer/README.md +++ b/samples/SkillsServer/README.md @@ -14,6 +14,8 @@ Each skill is a directory under [`Skills/`](Skills) with a `SKILL.md` and suppor SHA-256 digest and size of every file, and registers the `skills/list` and `skills/get` methods together with the resources that serve the files, in one call. Each skill's URI is `skill://{name}/SKILL.md`, where `name` comes from its frontmatter; pass a `uriPrefix` to place skills under an organizational path such as `skill://acme/billing/`. +Directory discovery excludes dot-prefixed, hidden and system entries by default. The explicit +`includeHiddenAndSystemFiles: true` overload opts in; included links are still rejected. ## Run diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index fb4a77d0a..2bd3fc792 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -1,6 +1,8 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Extensions.Skills; @@ -36,7 +38,7 @@ public static bool SupportsSkills(this McpClient client) } /// - /// Retrieves the entries of every skill the server lists, following pagination to the end. + /// Retrieves the listed skills, following at most 100 pages and collecting at most 1,000 entries. /// /// The client. /// The to monitor for cancellation requests. The default is . @@ -47,7 +49,9 @@ public static bool SupportsSkills(this McpClient client) /// The server returned an entry that violates the specification (for example, a name that does not match its /// URI, a malformed digest, or a manifest over the per-skill limits). Hosts must not load such entries. /// - /// The request failed or the server returned an error response. + /// + /// The request failed, a pagination cursor repeated, or the listing exceeded the page or entry limit. + /// /// /// /// The listing may be empty or partial: a server whose catalog is large or generated may list fewer skills than @@ -57,22 +61,74 @@ public static bool SupportsSkills(this McpClient client) /// /// This overload aggregates every page and does not surface the per-result caching hints. To read those, use /// , which returns one page at a time. + /// Use to choose different limits. + /// Exceeding a limit fails the operation rather than returning a silently truncated listing. /// /// - public static async ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) + public static ValueTask> ListSkillsAsync(this McpClient client, CancellationToken cancellationToken = default) => + ListSkillsAsync(client, maxPages: 100, maxSkills: 1000, cancellationToken); + + /// + /// Retrieves the listed skills with explicit page and entry limits. + /// + /// The client. + /// The maximum number of pages to request. Must be positive. + /// The maximum number of entries to collect. Must be positive. + /// The token used to cancel the operation. + /// The complete listing within the supplied limits. + /// is . + /// A limit is not positive. + /// The server did not declare the Skills extension. + /// An entry is invalid. + /// The request failed, a cursor repeated, or a limit was exceeded. + /// + /// Cursors are opaque, including an empty string. Only a null cursor ends the listing. + /// Use the paged overload when the application needs to stream a larger listing. + /// + public static async ValueTask> ListSkillsAsync( + this McpClient client, + int maxPages, + int maxSkills, + CancellationToken cancellationToken = default) { - List? skills = null; - ListSkillsRequestParams requestParams = new(); - do +#if NET + ArgumentNullException.ThrowIfNull(client); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); +#endif + if (maxPages <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxPages), "The page limit must be positive."); + } + if (maxSkills <= 0) { + throw new ArgumentOutOfRangeException(nameof(maxSkills), "The skill limit must be positive."); + } + + var skills = new List(); + var requestParams = new ListSkillsRequestParams(); + var cursors = new HashSet(StringComparer.Ordinal); + for (int pageNumber = 0; pageNumber < maxPages; pageNumber++) + { + cancellationToken.ThrowIfCancellationRequested(); var page = await ListSkillsAsync(client, requestParams, cancellationToken).ConfigureAwait(false); - skills ??= new(page.Skills.Count); + if (page.Skills.Count > maxSkills - skills.Count) + { + throw new McpException($"The skill listing exceeded the limit of {maxSkills} entries. Use the paged overload or a larger explicit limit."); + } skills.AddRange(page.Skills); + if (page.NextCursor is null) + { + return skills; + } + if (!cursors.Add(page.NextCursor)) + { + throw new McpException("The server repeated a skills/list pagination cursor."); + } requestParams.Cursor = page.NextCursor; } - while (requestParams.Cursor is not null); - return skills; + throw new McpException($"The skill listing exceeded the limit of {maxPages} pages. Use the paged overload or a larger explicit limit."); } /// @@ -108,7 +164,7 @@ public static async ValueTask ListSkillsAsync( }; JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ?? throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); if (result.Skills is null) { @@ -123,6 +179,74 @@ public static async ValueTask ListSkillsAsync( return result; } + /// + /// Retrieves a page's valid entries and reports each rejected entry without discarding the rest of the page. + /// + /// The client. + /// The request parameters, including the page cursor. + /// The token used to cancel the operation. + /// The valid entries, original pagination/cache hints, and explicit per-entry failures. + /// An argument is . + /// The server did not declare the Skills extension. + /// The page envelope is malformed or lacks a skills array. + /// The request failed or the server returned an error. + /// + /// This is an explicit partial-results API. The ordinary list overloads remain strict. + /// Callers must inspect ; an empty valid-entry list can + /// mean that every entry was rejected. Valid entries still require normal file/frontmatter verification. + /// + public static async ValueTask ListSkillsWithDiagnosticsAsync( + this McpClient client, + ListSkillsRequestParams requestParams, + CancellationToken cancellationToken = default) + { +#if NET + ArgumentNullException.ThrowIfNull(client); + ArgumentNullException.ThrowIfNull(requestParams); +#else + if (client is null) throw new ArgumentNullException(nameof(client)); + if (requestParams is null) throw new ArgumentNullException(nameof(requestParams)); +#endif + ThrowIfSkillsNotSupported(client, nameof(ListSkillsWithDiagnosticsAsync)); + + var request = new JsonRpcRequest + { + Method = SkillsProtocol.MethodSkillsList, + Params = JsonSerializer.SerializeToNode(requestParams, McpSkillsJsonContext.Default.ListSkillsRequestParams), + }; + var response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); + if (response.Result is not JsonObject rawResult || + rawResult["skills"] is not JsonArray entries) + { + throw new JsonException($"The response to '{SkillsProtocol.MethodSkillsList}' has no 'skills' array."); + } + + var envelope = new JsonObject(); + foreach (var property in rawResult) + { + envelope[property.Key] = property.Key == "skills" ? new JsonArray() : property.Value?.DeepClone(); + } + var page = DeserializeReceived(envelope, McpSkillsJsonContext.Default.ListSkillsResult, SkillsProtocol.MethodSkillsList) ?? + throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsList}'."); + var rejected = new List(); + for (int index = 0; index < entries.Count; index++) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var skill = DeserializeReceived(entries[index], McpSkillsJsonContext.Default.Skill, SkillsProtocol.MethodSkillsList); + SkillValidation.ValidateReceived(skill!, SkillsProtocol.MethodSkillsList); + page.Skills.Add(skill!); + } + catch (Exception error) when (error is JsonException or SkillVerificationException) + { + rejected.Add(new SkillEntryRejection(index, error)); + } + } + + return new SkillListingResult(page, rejected.AsReadOnly()); + } + /// /// Retrieves the entry for a single skill by the URI of its SKILL.md. /// @@ -191,7 +315,7 @@ public static async ValueTask GetSkillAsync( }; JsonRpcResponse response = await client.SendRequestAsync(request, cancellationToken).ConfigureAwait(false); - var result = response.Result?.Deserialize(McpSkillsJsonContext.Default.GetSkillResult) ?? + var result = DeserializeReceived(response.Result, McpSkillsJsonContext.Default.GetSkillResult, SkillsProtocol.MethodSkillsGet) ?? throw new JsonException($"Unexpected JSON result in the response to '{SkillsProtocol.MethodSkillsGet}'."); SkillValidation.ValidateReceived(result.Skill, SkillsProtocol.MethodSkillsGet); @@ -271,4 +395,57 @@ private static void ThrowIfSkillsNotSupported(McpClient client, string operation $"'{operationName}' requires the server to declare the '{SkillsProtocol.ExtensionId}' extension in its capabilities, and it did not."); } } + + /// + /// Deserializes part of a response. An escaped unpaired surrogate such as "\uD800" is valid JSON syntax but + /// not text. System.Text.Json throws a for it when it reads a string from JSON text, but + /// an when it deserializes a , so that case is turned + /// into a here and fails like any other malformed entry. Other exceptions propagate + /// unchanged. + /// + private static T? DeserializeReceived(JsonNode? node, JsonTypeInfo typeInfo, string method) + { + try + { + return node is null ? default : node.Deserialize(typeInfo); + } + catch (InvalidOperationException error) when (ContainsUnreadableText(node)) + { + throw new JsonException( + $"The response to '{method}' contains a string that is not valid Unicode text, such as an unpaired surrogate.", + error); + } + } + + /// Gets whether a property name or string value in cannot be read as text. + private static bool ContainsUnreadableText(JsonNode? node) + { + try + { + switch (node) + { + case JsonObject jsonObject: + return jsonObject.Any(property => ContainsUnreadableText(property.Value)); + + case JsonArray jsonArray: + return jsonArray.Any(ContainsUnreadableText); + + case JsonValue jsonValue: + if (jsonValue.TryGetValue(out JsonElement element) && + element.ValueKind == JsonValueKind.String) + { + _ = element.GetString(); + } + + return false; + + default: + return false; + } + } + catch (InvalidOperationException) + { + return true; + } + } } diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs new file mode 100644 index 000000000..2598282ad --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillEntryRejection.cs @@ -0,0 +1,23 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// Describes one entry rejected while reading a diagnostic listing page. +/// +public sealed class SkillEntryRejection +{ + internal SkillEntryRejection(int index, Exception error) + { + Index = index; + Error = error; + } + + /// + /// Gets the zero-based position in the server's original page. + /// + public int Index { get; } + + /// + /// Gets the parsing or structural-validation failure. No rejected entry is returned as a valid skill. + /// + public Exception Error { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs new file mode 100644 index 000000000..7aeb969dc --- /dev/null +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillListingResult.cs @@ -0,0 +1,23 @@ +namespace ModelContextProtocol.Extensions.Skills; + +/// +/// A client-side listing page with valid entries and explicit per-entry failures. +/// +public sealed class SkillListingResult +{ + internal SkillListingResult(ListSkillsResult page, IReadOnlyList rejectedEntries) + { + Page = page; + RejectedEntries = rejectedEntries; + } + + /// + /// Gets the valid entries and the server's pagination and cache hints. + /// + public ListSkillsResult Page { get; } + + /// + /// Gets the rejected entries. Callers must inspect these even when contains no skills. + /// + public IReadOnlyList RejectedEntries { get; } +} diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs index 927c44e71..3286d1937 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/SkillVerifier.cs @@ -16,10 +16,9 @@ namespace ModelContextProtocol.Extensions.Skills; /// /// /// They do not verify frontmatter. Before loading a skill, the host must also parse the fetched SKILL.md -/// itself and compare its frontmatter field by field with the held entry, treating any difference as a -/// verification failure. and -/// do this for the YAML the reader supports. For anything else, the host rejects the skill or uses a full YAML -/// parser; the entry's own frontmatter is never a substitute for parsing the file. +/// itself with a full YAML parser and compare its frontmatter field by field with the held entry, for example with +/// , treating any difference or any parse failure as a +/// verification failure. The entry's own frontmatter is never a substitute for parsing the file. /// /// /// Digests are unsigned and supplied by the same server that supplies the content. A match proves the manifest @@ -153,8 +152,8 @@ public static void Verify(SkillResource expected, ResourceContents contents) /// 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. + /// The result is empty, contains a null entry or contents for a URI the manifest does not list, + /// or contains contents whose size or digest does not match the manifest. /// public static void Verify(Skill skill, ReadResourceResult result) { @@ -175,6 +174,11 @@ public static void Verify(Skill skill, ReadResourceResult result) foreach (var contents in result.Contents) { + if (contents is null) + { + throw new SkillVerificationException($"The read returned a null content entry for skill '{skill.Uri}'."); + } + var expected = FindResource(skill, contents.Uri) ?? throw new SkillVerificationException( $"'{contents.Uri}' is not listed in the manifest of skill '{skill.Uri}'. An unlisted file is a change to the skill; " + diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs index bc18bbcb9..5ed0973da 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/ListSkillsResult.cs @@ -26,6 +26,7 @@ public sealed class ListSkillsResult : PaginatedResult, ICacheableResult /// Gets or sets the skill entries. /// [JsonPropertyName("skills")] + [JsonRequired] public IList Skills { get; set; } = []; /// diff --git a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs index d3716da3c..c21e886a0 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Protocol/Skill.cs @@ -24,7 +24,7 @@ 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 , + /// The decoded path segment preceding /SKILL.md must equal the name field of , /// so that a skill's name is recoverable from its URI alone. /// [JsonPropertyName("uri")] @@ -49,6 +49,16 @@ public sealed class Skill [JsonPropertyName("resources")] public required SkillResources Resources { get; set; } + /// + /// Gets or sets metadata reserved by the protocol for clients and servers to associate with this skill. + /// + /// + /// This metadata is separate from the metadata field in . + /// Implementations must not make assumptions about its contents. + /// + [JsonPropertyName("_meta")] + public JsonObject? Meta { get; set; } + /// /// Gets the skill's name from , or if it is absent or not a string. /// @@ -65,6 +75,37 @@ public sealed class Skill [JsonIgnore] public string? Description => GetFrontmatterString("description"); + /// + /// Gets the skill's license from , or if it is absent or not a string. + /// + /// + /// The Agent Skills specification defines it as a license name or a reference to a license file bundled with the skill. + /// + [JsonIgnore] + public string? License => GetFrontmatterString("license"); + + /// + /// Gets the skill's compatibility from , or if it is absent or not a string. + /// + /// + /// The Agent Skills specification uses it to state environment requirements, such as the intended product, + /// system packages, or network access. + /// + [JsonIgnore] + public string? Compatibility => GetFrontmatterString("compatibility"); + + /// + /// Gets the skill's allowed-tools value from , a space-separated string of tools, + /// or if it is absent or not a string. + /// + /// + /// The Agent Skills specification marks this field as experimental. A server that sets it is asking for elevated + /// access on the host, so the Skills extension specification requires hosts to ignore it for skills that arrive + /// over MCP unless the user has explicitly approved that grant for that skill. + /// + [JsonIgnore] + public string? AllowedTools => GetFrontmatterString("allowed-tools"); + private string? GetFrontmatterString(string key) => Frontmatter.TryGetPropertyValue(key, out var node) && node is JsonValue value && diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs index 1afb87aad..8abfb96e1 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/IMcpSkillCatalog.cs @@ -27,6 +27,10 @@ namespace ModelContextProtocol.Extensions.Skills; /// catalog whose skills are not visible to every caller must decide what to return from the /// it receives, and the corresponding resources must be guarded the same way. /// +/// +/// One catalog instance serves every request, so it must not capture scoped services directly. A catalog that +/// needs them can create its own scope for each call; such a scope is independent of the request's scope. +/// /// public interface IMcpSkillCatalog { diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs index c9b97762c..ceb5387a1 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/InMemoryMcpSkillCatalog.cs @@ -22,6 +22,7 @@ namespace ModelContextProtocol.Extensions.Skills; /// public sealed class InMemoryMcpSkillCatalog : IMcpSkillCatalog { + private static readonly UTF8Encoding s_strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private readonly Skill[] _ordered; private readonly string[] _orderedUris; private readonly Dictionary _byUri; @@ -53,13 +54,15 @@ public InMemoryMcpSkillCatalog(IEnumerable skills, int pageSize = 50) foreach (var skill in skills) { SkillValidation.Validate(skill, nameof(skills)); + var snapshot = SkillValidation.Snapshot(skill); + SkillValidation.Validate(snapshot, nameof(skills)); - if (_byUri.ContainsKey(skill.Uri)) + if (_byUri.ContainsKey(snapshot.Uri)) { - throw new ArgumentException($"Duplicate skill URI '{skill.Uri}'.", nameof(skills)); + throw new ArgumentException($"Duplicate skill URI '{snapshot.Uri}'.", nameof(skills)); } - _byUri.Add(skill.Uri, SkillValidation.Snapshot(skill)); + _byUri.Add(snapshot.Uri, snapshot); } _ordered = [.. _byUri.Values]; @@ -129,9 +132,9 @@ public ValueTask ListAsync(string? cursor, McpSkillRequestContext try { - return Encoding.UTF8.GetString(Convert.FromBase64String(cursor!)); + return s_strictUtf8.GetString(Convert.FromBase64String(cursor!)); } - catch (FormatException) + catch (Exception exception) when (exception is FormatException or DecoderFallbackException) { throw new McpProtocolException($"Invalid cursor '{cursor}'.", McpErrorCode.InvalidParams); } diff --git a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs index 72b506421..653886eb2 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -18,9 +18,10 @@ namespace ModelContextProtocol.Extensions.Skills; /// resources serve, so the two cannot disagree. /// /// -/// Overloads without explicit metadata read the frontmatter with and reject -/// unsupported or malformed input rather than publish guessed metadata. Overloads accepting a -/// exist for YAML the reader does not support; see +/// Overloads without explicit metadata read the frontmatter with the SDK's built-in reader and reject unsupported +/// or malformed input rather than publish guessed metadata. The reader handles a bounded subset of YAML: block +/// mappings and sequences, plain, quoted and block scalars, single-line flow collections, and comments. Overloads +/// accepting a exist for YAML the reader does not support; see /// for what they check and what remains /// the caller's responsibility. /// @@ -67,8 +68,8 @@ private McpServerSkill(Skill entry, IReadOnlyList resources) /// 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. + /// specification's per-skill limits; or the SKILL.md frontmatter cannot be read (it is malformed or uses + /// YAML the reader does not support) or is missing a required field. /// public static McpServerSkill Create(IEnumerable files) { @@ -86,7 +87,7 @@ public static McpServerSkill Create(IEnumerable 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. + /// segment preceding /SKILL.md, decoded once, must equal the skill's name. /// /// The skill's files. Exactly one must have the path SKILL.md. /// The skill. @@ -94,8 +95,8 @@ public static McpServerSkill Create(IEnumerable files) /// /// 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 . + /// frontmatter cannot be read (it is malformed or uses YAML the reader does not support), is missing a required + /// field, or has a name that does not match . /// public static McpServerSkill Create(string uri, IEnumerable files) { @@ -115,7 +116,7 @@ public static McpServerSkill Create(string uri, IEnumerable /// /// /// 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. + /// segment preceding /SKILL.md, decoded once, must equal the skill's name. /// /// /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and @@ -135,8 +136,8 @@ public static McpServerSkill Create(string uri, IEnumerable /// /// Prefer , which reads the frontmatter from the /// file. This overload still checks UTF-8, the frontmatter boundaries, and the entry's structure, computes the - /// manifest from the served bytes, and rejects malformed YAML the reader detects. When - /// reads the complete block, the supplied object must match it exactly. + /// manifest from the served bytes, and rejects malformed YAML the reader detects. When the reader reads the + /// complete block, the supplied object must match it exactly. /// /// /// When the reader stops at an unsupported construct, it does not examine the rest of the block, so acceptance @@ -160,7 +161,7 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera } /// - /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md + /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md /// and deriving the skill's URI from the frontmatter's name as skill://{name}/SKILL.md. /// /// The skill's root directory. It must contain a SKILL.md. @@ -177,13 +178,28 @@ public static McpServerSkill Create(string uri, JsonObject frontmatter, IEnumera /// manifest or the served content. /// /// + /// Dot-prefixed, hidden, and system files and directories are excluded before reading or descending into them. + /// Use to include them explicitly, or + /// with an explicit file selection. + /// + /// /// Links are not followed. A symbolic link inside the directory could point outside it and publish a file /// under a URI that appears to belong to the skill, so encountering one is an error. Replace the link with a /// regular file or directory, or build the skill with and /// explicit files. /// /// - public static McpServerSkill CreateFromDirectory(string directoryPath) + public static McpServerSkill CreateFromDirectory(string directoryPath) => + CreateFromDirectory(directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill from a directory with an explicit hidden/system-file policy. + /// + /// The skill's root directory, containing SKILL.md. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill with its manifest computed from the selected files. + /// Included symbolic links remain errors. See . + public static McpServerSkill CreateFromDirectory(string directoryPath, bool includeHiddenAndSystemFiles) { #if NET ArgumentNullException.ThrowIfNull(directoryPath); @@ -191,15 +207,16 @@ public static McpServerSkill CreateFromDirectory(string directoryPath) if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); #endif - return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + return CreateCore(uri: null, uriPrefix: DefaultUriPrefix, frontmatter: null, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); } /// - /// Creates a skill from every file in a directory, recursively, reading the frontmatter from its SKILL.md. + /// Creates a skill from the visible, non-system files in a directory, recursively, reading the frontmatter from its SKILL.md. /// /// /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path - /// segment preceding /SKILL.md must equal the skill's name. + /// segment preceding /SKILL.md, decoded once, must equal the skill's name. /// /// The skill's root directory. It must contain a SKILL.md. /// The skill. @@ -212,7 +229,18 @@ public static McpServerSkill CreateFromDirectory(string directoryPath) /// /// See for how files and links are handled. /// - public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath) => + CreateFromDirectory(uri, directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill with an explicit URI and hidden/system-file policy. + /// + /// The resource URI of the skill's SKILL.md. + /// The skill's root directory. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill. + /// Included symbolic links remain errors. See . + public static McpServerSkill CreateFromDirectory(string uri, string directoryPath, bool includeHiddenAndSystemFiles) { #if NET ArgumentNullException.ThrowIfNull(uri); @@ -222,15 +250,16 @@ public static McpServerSkill CreateFromDirectory(string uri, string directoryPat if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); #endif - return CreateCore(uri, uriPrefix: null, frontmatter: null, ReadDirectory(directoryPath), nameof(directoryPath)); + return CreateCore(uri, uriPrefix: null, frontmatter: null, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); } /// - /// Creates a skill from every file in a directory, recursively, with explicitly supplied frontmatter. + /// Creates a skill from the visible, non-system files in a directory, recursively, with explicitly supplied frontmatter. /// /// /// The resource URI of the skill's SKILL.md, for example skill://git-workflow/SKILL.md. The path - /// segment preceding /SKILL.md must equal the skill's name. + /// segment preceding /SKILL.md, decoded once, must equal the skill's name. /// /// /// The SKILL.md YAML frontmatter rendered as a JSON object. It must contain string name and @@ -249,7 +278,23 @@ public static McpServerSkill CreateFromDirectory(string uri, string directoryPat /// the caller's responsibility, as described for . /// See for how files and links are handled. /// - public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) + public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmatter, string directoryPath) => + CreateFromDirectory(uri, frontmatter, directoryPath, includeHiddenAndSystemFiles: false); + + /// + /// Creates a skill with explicit frontmatter and a hidden/system-file policy. + /// + /// The resource URI of the skill's SKILL.md. + /// The fully validated JSON frontmatter, matching the file. + /// The skill's root directory. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The skill. + /// + /// See for caller responsibilities. + /// Included symbolic links remain errors. + /// + public static McpServerSkill CreateFromDirectory( + string uri, JsonObject frontmatter, string directoryPath, bool includeHiddenAndSystemFiles) { #if NET ArgumentNullException.ThrowIfNull(uri); @@ -261,7 +306,8 @@ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmat if (directoryPath is null) throw new ArgumentNullException(nameof(directoryPath)); #endif - return CreateCore(uri, uriPrefix: null, frontmatter, ReadDirectory(directoryPath), nameof(directoryPath)); + return CreateCore(uri, uriPrefix: null, frontmatter, + ReadDirectory(directoryPath, includeHiddenAndSystemFiles), nameof(directoryPath)); } private const string DefaultUriPrefix = "skill://"; @@ -270,8 +316,9 @@ public static McpServerSkill CreateFromDirectory(string uri, JsonObject frontmat /// 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); + internal static McpServerSkill CreateFromDirectory( + string directoryPath, string uriPrefix, string paramName, bool includeHiddenAndSystemFiles = false) => + CreateCore(uri: null, uriPrefix, frontmatter: null, ReadDirectory(directoryPath, includeHiddenAndSystemFiles), paramName); private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObject? frontmatter, IEnumerable files, string filesParamName) { @@ -340,7 +387,7 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj 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} " + + $"The frontmatter of {SkillsProtocol.SkillFileName} uses YAML that the frontmatter reader does not support: {e.Message} " + "Supply the frontmatter explicitly with the overload that takes a JsonObject.", filesParamName); } @@ -377,7 +424,8 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj filesParamName); } - uri = uriPrefix + name + "/" + SkillsProtocol.SkillFileName; + var uriName = uriPrefix!.EndsWith("://", StringComparison.Ordinal) ? name : Uri.EscapeDataString(name); + uri = uriPrefix + uriName + "/" + SkillsProtocol.SkillFileName; } string root = SkillValidation.GetSkillRoot(uri, nameof(uri)); @@ -420,7 +468,7 @@ private static McpServerSkill CreateCore(string? uri, string? uriPrefix, JsonObj return new McpServerSkill(skill, resources); } - private static List ReadDirectory(string directoryPath) + private static List ReadDirectory(string directoryPath, bool includeHiddenAndSystemFiles) { string fullDirectory = Path.GetFullPath(directoryPath); if (!Directory.Exists(fullDirectory)) @@ -443,7 +491,7 @@ private static List ReadDirectory(string directoryPath) var files = new List(); long totalSize = 0; - CollectFiles(fullDirectory, fullDirectory, files, ref totalSize); + CollectFiles(fullDirectory, fullDirectory, files, ref totalSize, includeHiddenAndSystemFiles); return files; } @@ -452,11 +500,22 @@ 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, ref long totalSize) + private static void CollectFiles( + string root, string directory, List files, ref long totalSize, bool includeHiddenAndSystemFiles) { foreach (string entry in Directory.EnumerateFileSystemEntries(directory)) { + if (!includeHiddenAndSystemFiles && + Path.GetFileName(entry).StartsWith(".", StringComparison.Ordinal)) + { + continue; + } FileAttributes attributes = File.GetAttributes(entry); + if (!includeHiddenAndSystemFiles && + (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) + { + continue; + } if ((attributes & FileAttributes.ReparsePoint) != 0) { throw new ArgumentException( @@ -468,7 +527,7 @@ private static void CollectFiles(string root, string directory, List /// Two skills share a URI, or two skills list the same file URI with different content. /// + /// Skills are already registered for this server. /// /// /// This registers skills/list and skills/get backed by an @@ -31,11 +32,24 @@ public static class McpSkillsBuilderExtensions /// are served through resources/list and resources/read. /// /// + /// A server has one Skills registration. Pass every skill to a single call; a second call to this method, + /// , or + /// for the same service + /// collection throws before it registers anything. A call that throws for any other reason registers nothing + /// either, so a corrected call can follow it. + /// + /// /// Nested skills may legitimately list the same file. A file URI shared by several skills is registered once, /// provided every skill lists it with the same digest and the identical URI text. URIs that differ only in the /// case of their scheme or authority are rejected, because the server's resource collection treats them as one. /// /// + /// Each file must be the only resource at its URI. When another resource is registered at a URI the server's + /// resource collection treats as the same, whether before or after this call, building the server's options + /// throws an that names the URI, instead of serving one resource in + /// place of the other. + /// + /// /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core /// authorization filters. When some callers must not see some skills, implement @@ -56,12 +70,22 @@ public static IMcpServerBuilder WithSkills( if (skills is null) throw new ArgumentNullException(nameof(skills)); #endif + // Before registering any file resources, so a second registration leaves the service collection unchanged. + ThrowIfSkillsRegistered(builder); + var entries = new List(); // The server's resource collection keys concrete resources by System.Uri equality, under which scheme and // authority are case-insensitive, and it registers silently. Detect shared files under those same // semantics, so two URIs the collection would merge are caught here rather than served as one another. var registeredFiles = new Dictionary(); + + // The registered file resources, keyed exactly as the server's resource collection keys them. + var skillFiles = new McpServerResourceCollection(); + + // Added to the services only after every skill, the catalog, and the configuration are accepted, so a call + // that throws leaves no resources behind for a corrected retry to conflict with. + var stagedFiles = new List(); foreach (var skill in skills) { if (skill is null) @@ -100,26 +124,38 @@ public static IMcpServerBuilder WithSkills( } registeredFiles.Add(key, (entry.Uri, entry.Digest)); - builder.Services.AddSingleton(skill.Resources[i]); + stagedFiles.Add(skill.Resources[i]); + skillFiles.TryAdd(skill.Resources[i]); } } - return WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); + WithSkills(builder, new InMemoryMcpSkillCatalog(entries), configure); + foreach (var file in stagedFiles) + { + builder.Services.AddSingleton(file); + } + + // Other resources are known only once the server's options are built, so the check runs then. + builder.Services.AddSingleton>( + services => new SkillFileConflictCheck(skillFiles, services.GetServices())); + + return builder; } /// /// Enables MCP Skills support for every skill directory found directly under , - /// serving both their entries and their files. + /// serving both their entries and their files, excluding dot-prefixed, hidden, and system entries. /// /// The server builder. /// - /// A directory whose immediate subdirectories are skills. Each subdirectory containing a SKILL.md becomes + /// A directory whose immediate subdirectories are skills. Each included subdirectory containing a SKILL.md becomes /// one skill; subdirectories without one are ignored. /// /// /// The prefix of each skill's URI. A skill's SKILL.md is published at {uriPrefix}{name}/SKILL.md, /// where name is the skill's frontmatter name. Defaults to skill://; use a longer prefix such as - /// skill://acme/billing/ to place the skills under an organizational path. + /// skill://acme/billing/ to place the skills under an organizational path. Names used as path segments + /// are percent-encoded. /// /// An optional callback that configures the extension's behavior. /// The builder provided in . @@ -129,11 +165,12 @@ public static IMcpServerBuilder WithSkills( /// No subdirectory contains a SKILL.md, a skill directory is invalid (see /// ), or two skills declare the same name. /// + /// Skills are already registered for this server. /// /// /// Each skill is built with : files are read once, - /// digests are computed from the bytes served, symbolic links are rejected, and frontmatter - /// cannot read is an error. Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one + /// digests are computed from the bytes served, symbolic links are rejected, and frontmatter the SDK's reader + /// cannot read is an error. Only immediate subdirectories are considered skills; a SKILL.md nested deeper inside a skill is one /// of that skill's files. /// /// @@ -143,6 +180,13 @@ public static IMcpServerBuilder WithSkills( /// absent from the catalog. /// /// + /// A server has one Skills registration, so this method can be called once. To serve skills from several + /// directories, build them with and pass all of them to + /// one call. + /// The skills' files are registered as described there, including the check that each file is the only + /// resource at its URI. + /// + /// /// Every caller sees every skill. skills/list and skills/get are raw request handlers and do not /// pass through the request filters that guard the built-in resource methods, such as the ASP.NET Core /// authorization filters. When some callers must not see some skills, implement @@ -154,6 +198,28 @@ public static IMcpServerBuilder WithSkillsFromDirectory( this IMcpServerBuilder builder, string directoryPath, string uriPrefix = "skill://", + Action? configure = null) => + WithSkillsFromDirectory(builder, directoryPath, includeHiddenAndSystemFiles: false, uriPrefix, configure); + + /// + /// Enables skills from immediate subdirectories with an explicit hidden/system-file policy. + /// + /// The server builder. + /// The directory containing skill directories. + /// Whether to include dot-prefixed, hidden, and system entries. + /// The prefix used to derive skill URIs. + /// An optional callback configuring the extension. + /// The supplied builder. + /// Skills are already registered for this server. + /// + /// The same selection applies to skill directories and their contents. Included symbolic links remain errors. + /// See . + /// + public static IMcpServerBuilder WithSkillsFromDirectory( + this IMcpServerBuilder builder, + string directoryPath, + bool includeHiddenAndSystemFiles, + string uriPrefix = "skill://", Action? configure = null) { #if NET @@ -166,6 +232,9 @@ public static IMcpServerBuilder WithSkillsFromDirectory( if (uriPrefix is null) throw new ArgumentNullException(nameof(uriPrefix)); #endif + // Before reading the directory, so a second registration reports this rather than an error about the directory. + ThrowIfSkillsRegistered(builder); + if (uriPrefix.Length == 0) { throw new ArgumentException("The URI prefix must not be empty.", nameof(uriPrefix)); @@ -188,9 +257,20 @@ public static IMcpServerBuilder WithSkillsFromDirectory( var skills = new List(); foreach (string skillDirectory in skillDirectories) { + if (!includeHiddenAndSystemFiles && + Path.GetFileName(skillDirectory).StartsWith(".", StringComparison.Ordinal)) + { + continue; + } + var attributes = File.GetAttributes(skillDirectory); + if (!includeHiddenAndSystemFiles && + (attributes & (FileAttributes.Hidden | FileAttributes.System)) != 0) + { + continue; + } // The per-skill loader rejects links inside a skill; the same rule applies to the skill directory // itself, which could otherwise be a link to a directory outside the skills root. - if ((File.GetAttributes(skillDirectory) & FileAttributes.ReparsePoint) != 0) + if ((attributes & FileAttributes.ReparsePoint) != 0) { throw new ArgumentException( $"'{skillDirectory}' is a symbolic link or other reparse point. Links are not followed when loading skills, " + @@ -203,7 +283,8 @@ public static IMcpServerBuilder WithSkillsFromDirectory( continue; } - skills.Add(McpServerSkill.CreateFromDirectory(skillDirectory, uriPrefix, nameof(directoryPath))); + skills.Add(McpServerSkill.CreateFromDirectory( + skillDirectory, uriPrefix, nameof(directoryPath), includeHiddenAndSystemFiles)); } if (skills.Count == 0) @@ -224,12 +305,18 @@ public static IMcpServerBuilder WithSkillsFromDirectory( /// An optional callback that configures the extension's behavior. /// The builder provided in . /// or is . + /// Skills are already registered for this server. /// /// /// This registers skills/list and skills/get and declares the extension in the server's /// capabilities. Declaring the extension commits the server to both methods. /// /// + /// A server has one Skills registration, and so one catalog. A second call to this method or to the other + /// WithSkills and WithSkillsFromDirectory overloads for the same service collection throws. To serve + /// skills from several sources, implement one that combines them. + /// + /// /// A catalog supplies entries only. The skills' files must be served as ordinary resources through /// resources/read, so register them as well (for example with WithResources). To have this done /// automatically, build the skills with and use the @@ -255,15 +342,67 @@ public static IMcpServerBuilder WithSkills( if (catalog is null) throw new ArgumentNullException(nameof(catalog)); #endif + ThrowIfSkillsRegistered(builder); + var options = new McpSkillsOptions(); configure?.Invoke(options); + builder.Services.AddSingleton(new SkillsRegistrationMarker()); builder.Services.AddSingleton>( _ => new McpSkillsConfigureOptions(catalog, options)); return builder; } + /// + /// Fails when Skills are already registered for the builder's service collection. A second registration would + /// install skills/list and skills/get again, which the server rejects only when it is created, with + /// an error that does not mention Skills. + /// + private static void ThrowIfSkillsRegistered(IMcpServerBuilder builder) + { + bool isRegistered = builder.Services.Any(descriptor => descriptor.ServiceType == typeof(SkillsRegistrationMarker)); + if (isRegistered) + { + throw new InvalidOperationException( + "Skills are already registered for this server. Call WithSkills or WithSkillsFromDirectory once: pass every " + + "McpServerSkill to a single WithSkills call, or implement one IMcpSkillCatalog that combines your sources."); + } + } + + /// Marks a service collection whose server has Skills registered. + private sealed class SkillsRegistrationMarker; + + /// + /// Fails building the server's options when another resource is registered at the URI of a Skills file. The + /// server's resource collection keeps one resource per URI, so one of the two would be served in place of the + /// other, whichever was registered first. + /// + private sealed class SkillFileConflictCheck(McpServerResourceCollection skillFiles, IEnumerable registeredResources) + : IPostConfigureOptions + { + public void PostConfigure(string? name, McpServerOptions options) + { + // The registered services include a resource the collection dropped because an equivalent URI came first; + // the collection can also hold resources that were added to it directly. + var resources = options.ResourceCollection is { } collection ? + registeredResources.Concat(collection) : + registeredResources; + foreach (var resource in resources) + { + string uri = resource.ProtocolResourceTemplate.UriTemplate; + if (skillFiles.TryGetPrimitive(uri, out var file) && + !ReferenceEquals(file, resource)) + { + throw new InvalidOperationException( + $"The Skills file '{file.ProtocolResourceTemplate.UriTemplate}' conflicts with another resource registered at '{uri}'. " + + "The server keeps one resource per URI, so one of them would be served in place of the other. " + + "Remove the other resource, or give it a different URI."); + } + } + } + } + private sealed class McpSkillsConfigureOptions(IMcpSkillCatalog catalog, McpSkillsOptions skillsOptions) : IConfigureOptions { @@ -305,9 +444,13 @@ 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) + // The sealed built-in catalog validates its owned snapshots at construction. + if (catalog is not InMemoryMcpSkillCatalog) { - ValidateCatalogEntry(entry); + foreach (var entry in page.Skills) + { + ValidateCatalogEntry(entry); + } } var result = new ListSkillsResult @@ -340,7 +483,10 @@ 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 (catalog is not InMemoryMcpSkillCatalog) + { + ValidateCatalogEntry(skill); + } if (!string.Equals(skill.Uri, requestParams.Uri, StringComparison.Ordinal)) { throw new McpProtocolException( diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs index c6fabd895..89536f389 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillFrontmatter.cs @@ -1,38 +1,41 @@ using System.Globalization; -using System.Numerics; using System.Text; using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Skills; /// -/// Reads a supported subset of SKILL.md YAML frontmatter into a , without a YAML library. -/// This optional helper supports server authoring and host-side comparison for that subset. +/// Reads a bounded subset of SKILL.md YAML frontmatter into a , without a YAML library. +/// Server authoring uses it to read the frontmatter of the skills it builds. /// /// /// -/// This reader supports block mappings, nested mappings and sequences, plain, quoted and block scalars, -/// and comments within its bounded YAML subset. Detected malformed input and unsupported constructs throw -/// ; unsupported forms include anchors, aliases, tags, complex keys and multi-line -/// flow collections. Parsing can stop at the first unsupported construct, so an exception is not proof that -/// the rest of the frontmatter is valid. This helper is not a general-purpose YAML validator. +/// This reader handles a bounded subset of YAML: block mappings, nested mappings and sequences, plain, quoted and +/// block scalars, single-line flow collections, and comments. It is not a general-purpose YAML parser or validator. +/// The root must be a mapping; a root that is any other node is rejected as not a mapping. /// /// -/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, -/// otherwise strings). Other YAML parsers can differ in the forms and values they support, so values meant to -/// be strings, such as version numbers, are best quoted. +/// It throws for malformed input it detects, including a raw character outside YAML's +/// printable set, such as a NUL, or a lone carriage return, NEL, LS or PS, and +/// for valid YAML outside its subset, such as anchors, aliases, tags, complex (? ) and empty mapping keys, +/// nested or multi-line flow collections (a comment inside a flow collection makes it multi-line), nesting deeper +/// than 32 levels, and hexadecimal or octal integers wider than 64 bits. Reading can stop at the first unsupported +/// construct, so that exception says nothing about the rest of the frontmatter. /// /// -/// Hosts can use this reader for the frontmatter verification described on . A -/// from it is a failed verification unless the host parses the file another way. +/// Unquoted scalars are resolved per the YAML 1.2 core schema (null, booleans, integers, finite floats, +/// otherwise strings). Hexadecimal (0x) and octal (0o) integers keep their exact value up to 64 bits, +/// leading zeros included. Values JSON cannot represent, such as .inf, are rejected. /// /// -public static class SkillFrontmatter +internal static class SkillFrontmatter { private const string Delimiter = "---"; + private const string NotAMappingMessage = "The frontmatter must be a YAML mapping of keys to values."; /// YAML's white space characters. Other Unicode spaces, such as U+00A0, are scalar content. private static readonly char[] s_yamlWhitespace = [' ', '\t']; + private static readonly char[] s_flowIndicators = [',', '[', ']', '{', '}']; /// The deepest nesting of block collections the reader accepts. Frontmatter is shallow; this only exists to keep a hostile file from exhausting the stack. private const int MaxNestingDepth = 32; @@ -43,6 +46,9 @@ public static class SkillFrontmatter /// internal sealed class UnsupportedYamlException(string message) : FormatException(message); + /// Thrown when a valid YAML scalar has no JSON form, such as .inf or an unpaired surrogate escape. + private sealed class UnrepresentableValueException(string message) : FormatException(message); + /// Thrown when a quoted scalar has no closing quote on its line; a later delimiter may indicate an unsupported multi-line form. private sealed class UnterminatedQuoteException(string message, char quote) : FormatException(message) { @@ -62,7 +68,7 @@ private sealed class UnterminatedFlowException(string message, char closer) : Fo /// 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. + /// contains malformed YAML or YAML this reader does not support. /// public static JsonObject Parse(string skillMarkdown) { @@ -91,14 +97,64 @@ public static JsonObject Parse(string skillMarkdown) var body = new List(end - 1); for (int i = 1; i < end; i++) { + ThrowIfDisallowedCharacter(lines[i], i + 1); body.Add(new Line(lines[i], i + 1)); } var parser = new Parser(body); - var root = parser.ParseDocument(); - return root as JsonObject ?? throw new FormatException("The frontmatter must be a YAML mapping of keys to values."); + JsonNode? root; + try + { + root = parser.ParseDocument(); + } + catch (UnrepresentableValueException e) + { + // The subclass only lets the parser tell a scalar root from a mapping with a broken first entry. + throw new FormatException(e.Message, e); + } + + return root as JsonObject ?? throw new FormatException(NotAMappingMessage); + } + + /// + /// Rejects a raw character that YAML parsers do not all read the same way. It runs on the raw lines before parsing, + /// so comments are checked too. That covers any character outside YAML's printable set (YAML 1.2.2 + /// c-printable): a C0 control other than tab, line feed and carriage return, DEL, a C1 control other than NEL, + /// U+FFFE, U+FFFF or an unpaired surrogate. YAML 1.2.2 lets a quoted scalar hold such characters other than C0 + /// controls, but libyaml, Go's yaml.v3 and PyYAML reject them anywhere, so this reader does too. It also covers a + /// lone carriage return ( removes the one before a line feed), NEL, LS and PS. YamlDotNet, + /// libyaml and PyYAML read all four as line breaks, as YAML 1.2.2 reads a lone carriage return, while this reader + /// and the npm yaml package would read them as text, so a comment could hide a key from one side. An escape + /// such as \0 or \L in a double-quoted scalar is not a raw character and stays valid. + /// + private static void ThrowIfDisallowedCharacter(string line, int lineNumber) + { + for (int i = 0; i < line.Length; i++) + { + char c = line[i]; + if (char.IsHighSurrogate(c) && + i + 1 < line.Length && + char.IsLowSurrogate(line[i + 1])) + { + i++; + } + else if (c is '\r' or '\u0085' or '\u2028' or '\u2029') + { + throw new FormatException( + $"Line {lineNumber}: the frontmatter contains U+{(int)c:X4}, which some YAML parsers read as a line break and others as text. Remove it, or write it as an escape in a double-quoted string."); + } + else if (!IsPrintable(c)) + { + throw new FormatException( + $"Line {lineNumber}: the frontmatter contains U+{(int)c:X4}, which is outside YAML's printable character set. Remove it, or write it as an escape in a double-quoted string."); + } + } } + /// Whether YAML's printable set contains , a character that is not a surrogate. + private static bool IsPrintable(char c) => + c is '\t' or '\n' or '\r' or (>= ' ' and <= '~') or '\u0085' or (>= '\u00A0' and <= '\uD7FF') or (>= '\uE000' and <= '\uFFFD'); + private static List SplitLines(string text) { if (text.Length > 0 && text[0] == '\uFEFF') @@ -171,6 +227,9 @@ private sealed class Parser(List lines) private int _pos; private int _depth; + /// The first multi-line flow collection, reported only after the rest of the frontmatter is read. + private UnsupportedYamlException? _multiLineFlow; + public JsonNode? ParseDocument() { SkipBlank(); @@ -180,18 +239,57 @@ private sealed class Parser(List lines) } var first = _lines[_pos]; - if (first.Indent != 0) + ThrowIfTabIndented(first); + if (first.Content[0] == '*') + { + throw new FormatException($"Line {first.Number}: the frontmatter cannot start with an alias ('*'), because no anchor is defined before it."); + } + + if (first.Content[0] is '&' or '!' && + SkipNodeProperties(first.Content) is { } afterProperties) + { + throw CreateRootPropertiesException(first, afterProperties); + } + + bool isFlowCollection = first.Content[0] is '[' or '{' && + !IsFlowCollectionKey(first.Content, first.Number); + if (IsSequenceEntry(first.Content) || + (isFlowCollection && first.Content[0] == '[')) + { + CheckRootSequence(first); + throw new FormatException(NotAMappingMessage); + } + + JsonNode? node; + if (isFlowCollection) + { + // A flow mapping is read like a flow value. One that spans lines is reported below, after the rest. + _pos++; + node = ParseValue(PrepareValue(first.Content, out _), commentEndedValue: false, parentIndent: -1, first.Number, allowSameIndentSequence: false); + } + else if (!StartsMappingEntry(first.Content, first.Number) && + IsScalarDocument(first)) + { + throw new FormatException(NotAMappingMessage); + } + else { - throw new FormatException($"Line {first.Number}: the frontmatter must start at column 1."); + // The root mapping may be indented, as long as all of its keys share that indentation. A first line + // that is neither a mapping entry nor a whole scalar document is reported by the mapping. + node = ParseBlock(first.Indent); } - var node = ParseBlock(0); SkipBlank(); if (_pos < _lines.Count) { throw new FormatException($"Line {_lines[_pos].Number}: unexpected content after the frontmatter mapping."); } + if (_multiLineFlow is not null) + { + throw _multiLineFlow; + } + return node; } @@ -216,6 +314,119 @@ private void SkipBlank() return null; } + /// + /// Classifies a root node that has an anchor or a tag, which this reader does not support: a mapping is valid + /// YAML outside the subset, and any other node is not a mapping. + /// + private FormatException CreateRootPropertiesException(Line first, string afterProperties) + { + string node = afterProperties; + int lineNumber = first.Number; + if (node.Length == 0) + { + // The properties belong to the node that starts on the next line. + _pos++; + if (PeekSignificant() is { } next) + { + node = SkipNodeProperties(next.Content) ?? next.Content; + lineNumber = next.Number; + } + } + + bool isMapping = node.Length > 0 && + (node[0] == '{' || StartsMappingEntry(node, lineNumber)); + return isMapping + ? new UnsupportedYamlException($"Line {first.Number}: YAML anchors, aliases, and tags are not supported in frontmatter.") + : new FormatException(NotAMappingMessage); + } + + /// + /// Reads a root sequence only to report malformed YAML in it. The frontmatter is rejected for not being a + /// mapping either way, so valid YAML in it that this reader does not support does not matter. + /// + private void CheckRootSequence(Line first) + { + try + { + if (first.Content[0] == '[') + { + _pos++; + ParseValue(PrepareValue(first.Content, out _), commentEndedValue: false, parentIndent: -1, first.Number, allowSameIndentSequence: false); + } + else + { + ParseBlock(first.Indent); + } + } + catch (UnsupportedYamlException) + { + // Still a sequence, so still not a mapping. + } + } + + /// Whether a line's content starts an entry of a block mapping, which the mapping then reads or reports. + private bool StartsMappingEntry(string content, int lineNumber) + { + if (IsSequenceEntry(content)) + { + return false; + } + + if (content[0] == '?' && + (content.Length == 1 || content[1] is ' ' or '\t')) + { + // An explicit key, which the mapping reports as unsupported. + return true; + } + + if (content[0] is '[' or '{') + { + return IsFlowCollectionKey(content, lineNumber); + } + + if (content[0] is '"' or '\'') + { + return IsCompactMappingEntry(content, lineNumber); + } + + return FindKeySeparator(content) >= 0; + } + + /// + /// Whether the whole document is one scalar that starts on , which is valid YAML but + /// not a mapping. The scalar is read only to tell it from a mapping whose first entry has a mistake. + /// + private bool IsScalarDocument(Line first) + { + int start = _pos; + try + { + _pos++; + string rest = PrepareValue(first.Content, out bool commentEndedValue); + ParseValue(rest, commentEndedValue, parentIndent: -1, first.Number, allowSameIndentSequence: false); + SkipBlank(); + return _pos >= _lines.Count; + } + catch (UnsupportedYamlException) + { + // A scalar form this reader does not support, such as a multi-line quoted scalar. + return true; + } + catch (UnrepresentableValueException) + { + // A scalar that JSON cannot represent, such as .inf. + return true; + } + catch (FormatException) + { + return false; + } + finally + { + _pos = start; + } + } + /// Parses the block node formed by the lines at . private JsonNode? ParseBlock(int indent, int parentIndent = -1) { @@ -223,7 +434,7 @@ private void SkipBlank() var line = _lines[_pos]; if (_depth >= MaxNestingDepth) { - throw new FormatException($"Line {line.Number}: the frontmatter is nested more than {MaxNestingDepth} levels deep."); + throw new UnsupportedYamlException($"Line {line.Number}: the frontmatter is nested more than {MaxNestingDepth} levels deep, which is not supported in frontmatter."); } _depth++; @@ -370,6 +581,34 @@ private static void ThrowIfTabIndented(Line line) } } + /// + /// Returns what follows the anchor and tag at the start of : empty when the node starts + /// on a later line, or null when an anchor has no name or has a flow indicator in it, which YAML does not allow. + /// + private static string? SkipNodeProperties(string text) + { + int pos = 0; + while (pos < text.Length && text[pos] is '&' or '!') + { + // A property runs to the next white space; an anchor name may end in ':', as in YAML 1.2. + int start = pos; + while (pos < text.Length && text[pos] is not (' ' or '\t')) + { + pos++; + } + + if (text[start] == '&' && + (pos == start + 1 || text.IndexOfAny(s_flowIndicators, start, pos - start) >= 0)) + { + return null; + } + + pos = SkipSpaces(text, pos); + } + + return pos < text.Length && text[pos] == '#' ? string.Empty : text.Substring(pos); + } + /// /// Rejects a plain scalar that starts with a character YAML gives another meaning: an anchor, alias, or tag /// (constructs this reader does not support), a reserved character, a flow indicator, a block scalar header, @@ -391,6 +630,10 @@ private static void ThrowIfReservedPlainStart(string text, int lineNumber) case '@' or '`' or '%': throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '{c}', which YAML reserves. Quote the value."); + case '#': + // Only a '#' after white space starts a comment; one that starts a scalar is not allowed. + throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with '#'. Quote the value."); + case ']' or '}' or ',': throw new FormatException($"Line {lineNumber}: a plain scalar cannot start with the flow indicator '{c}'. Quote the value."); @@ -402,6 +645,30 @@ private static void ThrowIfReservedPlainStart(string text, int lineNumber) } } + /// + /// Rejects a flow sequence entry or flow mapping key that starts with an explicit key: '?' followed by white + /// space. That is valid YAML this reader does not support. '?' followed by any other character is not an + /// explicit key, and '?' at the end of the line continues on a later line like an unterminated collection. + /// + private static void ThrowIfFlowExplicitKey(string text, int pos, char closer, int lineNumber) + { + if (text[pos] != '?') + { + return; + } + + if (pos + 1 == text.Length) + { + string collection = closer == ']' ? "sequence" : "mapping"; + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow {collection}.", closer); + } + + if (text[pos + 1] is ' ' or '\t') + { + throw new UnsupportedYamlException($"Line {lineNumber}: explicit mapping keys ('? ') in flow collections are not supported in frontmatter."); + } + } + private JsonObject ParseMapping(int indent) { var result = new JsonObject(); @@ -465,7 +732,7 @@ private JsonObject ParseMapping(int indent) string rawKey = content.Substring(0, separator).TrimEnd(s_yamlWhitespace); if (rawKey.Length == 0) { - throw new FormatException($"Line {line.Number}: empty mapping key."); + throw new UnsupportedYamlException($"Line {line.Number}: empty mapping keys (': value') are not supported in frontmatter."); } ThrowIfReservedPlainStart(content, line.Number); @@ -525,7 +792,11 @@ private JsonArray ParseSequence(int indent) } int itemIndent = indent + 1 + (item.Length - trimmedItem.Length); - if (IsSequenceEntry(trimmedItem) || IsCompactMappingEntry(trimmedItem, line.Number)) + bool isExplicitKey = trimmedItem[0] == '?' && + (trimmedItem.Length == 1 || trimmedItem[1] is ' ' or '\t'); + if (IsSequenceEntry(trimmedItem) || + IsCompactMappingEntry(trimmedItem, line.Number) || + isExplicitKey) { if (item.IndexOf('\t', 0, item.Length - trimmedItem.Length) >= 0) { @@ -533,8 +804,8 @@ private JsonArray ParseSequence(int indent) throw new FormatException($"Line {line.Number}: tabs are not allowed for indentation in YAML frontmatter."); } - // 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. + // A compact nested node ("- key: value", "- ? key" or "- - x"): re-read this line as if the item + // started on its own line at the item's column, then continue with the block at that indentation. _pos--; _lines[_pos] = new Line(new string(' ', itemIndent) + trimmedItem, line.Number); result.Add(ParseBlock(itemIndent)); @@ -586,11 +857,14 @@ private JsonArray ParseSequence(int indent) { return rest[0] == '[' ? ParseFlowSequence(rest, lineNumber) : ParseFlowMapping(rest, lineNumber); } - catch (UnterminatedFlowException e) when (ClosesLater(e.Closer)) + catch (UnterminatedFlowException) { - // A later delimiter may indicate an unsupported multi-line flow collection. This - // classification does not validate its contents or the rest of the frontmatter. - throw new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter."); + // The collection continues on later lines, which this reader does not support. It is checked and + // skipped, and the frontmatter is reported as unsupported only after the rest of it is read, so + // that only valid YAML is reported that way. + _pos = CheckMultiLineFlow(rest, parentIndent, lineNumber); + _multiLineFlow ??= new UnsupportedYamlException($"Line {lineNumber}: multi-line flow collections are not supported in frontmatter."); + return null; } case '"' or '\'': @@ -654,6 +928,220 @@ private bool ClosesLater(char quote) return false; } + /// + /// Checks a flow collection that continues past its first line the way a YAML parser reads it, and throws + /// if it is invalid. The rules that span lines are checked here: later lines are + /// indented more than the parent, though the closing bracket may sit at the parent's indentation; a comment, + /// or a white-space line whose tab falls short of that indentation, ends a plain scalar; and a key in a flow + /// sequence stays on one line. The lines, without comments, are then joined and checked like a single-line + /// collection. Valid YAML inside the collection that this reader does not support is not an error here. + /// + /// The index of the line after the collection. + private int CheckMultiLineFlow(string first, int parentIndent, int lineNumber) + { + char outerCloser = first[0] == '[' ? ']' : '}'; + string collection = outerCloser == ']' ? "sequence" : "mapping"; + var joined = new StringBuilder(); + var closers = new Stack(); + var entryLines = new Stack(); + char quote = '\0'; + bool inPlain = false; + bool afterQuoted = false; + bool plainEnded = false; + bool firstLineComment = false; + string text = first; + int number = lineNumber; + int next = _pos; + while (true) + { + int end = text.Length; + bool closed = false; + for (int i = 0; i < text.Length && !closed; i++) + { + char c = text[i]; + if (quote != '\0') + { + if (quote == '"' && c == '\\') + { + i++; + } + else if (c == quote && + quote == '\'' && + i + 1 < text.Length && + text[i + 1] == '\'') + { + i++; + } + else if (c == quote) + { + quote = '\0'; + afterQuoted = true; + } + + continue; + } + + if (c is ' ' or '\t') + { + continue; + } + + if (c == '#' && + i > 0 && + text[i - 1] is ' ' or '\t') + { + // A comment runs to the end of the line and ends a plain scalar. + firstLineComment |= number == lineNumber; + plainEnded |= inPlain; + inPlain = false; + end = i; + break; + } + + bool isValueIndicator = c == ':' && + (afterQuoted || + i + 1 == text.Length || + text[i + 1] is ' ' or '\t' || + Array.IndexOf(s_flowIndicators, text[i + 1]) >= 0); + if (plainEnded && + !isValueIndicator && + c is not (',' or ']' or '}')) + { + throw new FormatException($"Line {number}: the flow entry before this one already ended, so a ',' must separate them."); + } + + plainEnded = false; + afterQuoted = false; + if (entryLines.Count > 0 && + entryLines.Peek() < 0 && + c is not (',' or ']' or '}')) + { + // The first token of an entry. + entryLines.Pop(); + entryLines.Push(number); + } + + switch (c) + { + case '[' or '{': + closers.Push(c == '[' ? ']' : '}'); + entryLines.Push(-1); + inPlain = false; + break; + + case ']' or '}': + closers.Pop(); + entryLines.Pop(); + closed = closers.Count == 0; + inPlain = false; + break; + + case ',': + entryLines.Pop(); + entryLines.Push(-1); + inPlain = false; + break; + + case ':' when isValueIndicator: + if (closers.Peek() == ']' && + entryLines.Peek() != number) + { + throw new FormatException($"Line {number}: a key in a flow sequence must be on a single line."); + } + + inPlain = false; + break; + + case '"' or '\'' when !inPlain: + quote = c; + break; + + default: + inPlain = true; + break; + } + } + + joined.Append(text, 0, end).Append(' '); + if (closed) + { + break; + } + + if (next >= _lines.Count) + { + throw new FormatException(firstLineComment + ? $"Line {lineNumber}: a comment inside a flow collection runs to the end of the line, leaving the collection unterminated. Move the comment after the closing {(outerCloser == ']' ? "bracket" : "brace")}." + : $"Line {lineNumber}: unterminated flow {collection}."); + } + + var line = _lines[next++]; + number = line.Number; + text = line.Content; + bool emptyLine = text.Length == 0; + bool indented = line.Indent > parentIndent; + if (quote != '\0') + { + // A quoted scalar continues on more-indented lines. An empty line may be shorter, unless it holds a tab. + if (!indented && + (!emptyLine || line.HasTabIndentation)) + { + throw new FormatException($"Line {number}: unterminated quoted scalar in a flow {collection}. Lines that continue it must be indented more than its parent."); + } + + continue; + } + + if (emptyLine || + text[0] == '#') + { + // Empty and comment lines may sit at any indentation. A comment line ends a plain scalar, and so + // does a white-space line whose tab falls short of the indentation. + if (inPlain && + (!emptyLine || (line.HasTabIndentation && !indented))) + { + plainEnded = true; + inPlain = false; + } + + text = string.Empty; + continue; + } + + bool closesAtParentIndent = closers.Count == 1 && + line.Indent == parentIndent && + text[0] == outerCloser; + if (!indented && + !closesAtParentIndent) + { + throw new FormatException($"Line {number}: unterminated flow {collection}. Lines that continue it must be indented more than its parent."); + } + } + + try + { + string joinedText = joined.ToString(); + if (outerCloser == ']') + { + ParseFlowSequence(joinedText, lineNumber); + } + else + { + ParseFlowMapping(joinedText, lineNumber); + } + } + catch (UnsupportedYamlException) + { + // Valid YAML inside the collection that this reader does not support; the caller reports the collection. + } + catch (UnterminatedFlowException e) + { + throw new FormatException(e.Message); + } + + return next; + } + /// /// Folds the more-indented continuation lines of a plain multi-line scalar into it. A trailing comment on a /// continuation line ends the scalar, which reports. @@ -898,6 +1386,7 @@ private static JsonArray ParseFlowSequence(string text, int lineNumber) throw new FormatException($"Line {lineNumber}: unexpected ',' in flow sequence; every entry needs a value."); } + ThrowIfFlowExplicitKey(text, pos, ']', lineNumber); result.Add(ParseFlowScalar(text, ref pos, lineNumber, ",]")); pos = SkipSpaces(text, pos); if (pos < text.Length && text[pos] == ':') @@ -910,6 +1399,13 @@ private static JsonArray ParseFlowSequence(string text, int lineNumber) throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); } + if (text[pos] == '#' && + text[pos - 1] is ' ' or '\t') + { + // A comment runs to the end of the line, so the collection continues on a later line. + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow sequence.", ']'); + } + if (text[pos] == ',') { pos++; @@ -955,7 +1451,17 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) bool hasValue; if (text[pos] is '"' or '\'') { - keyNode = JsonValue.Create(ParseQuotedScalar(text, pos, lineNumber, out int afterQuote)); + int afterQuote; + try + { + keyNode = JsonValue.Create(ParseQuotedScalar(text, pos, lineNumber, out afterQuote)); + } + catch (UnterminatedQuoteException e) + { + // The quoted key, and so the collection, continues on a later line. + throw new UnterminatedFlowException(e.Message, '}'); + } + pos = SkipSpaces(text, afterQuote); hasValue = pos < text.Length && text[pos] == ':'; if (hasValue) @@ -965,22 +1471,29 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) } else { + ThrowIfFlowExplicitKey(text, pos, '}', lineNumber); + // A plain key ends at ',' or '}' (giving it a null value) or at a ':' that is followed by a - // space, a comma, a closing brace, or the end of the text. A ':' followed by anything else is + // space, a flow collection indicator, or the end of the text. A ':' followed by anything else is // part of the key, so "{version:1}" is the key "version:1" with a null value. int start = pos; int colonAt = -1; while (pos < text.Length && text[pos] is not (',' or '}')) { - if (text[pos] == ':' && (pos + 1 == text.Length || text[pos + 1] is ' ' or '\t' or ',' or '}')) + if (text[pos] == ':' && + (pos + 1 == text.Length || + text[pos + 1] is ' ' or '\t' || + Array.IndexOf(s_flowIndicators, text[pos + 1]) >= 0)) { colonAt = pos; break; } - if (text[pos] == '#' && (pos == start || text[pos - 1] is ' ' or '\t')) + if (text[pos] == '#' && + 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 brace."); + // A comment runs to the end of the line, so the collection continues on a later line. + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); } pos++; @@ -992,7 +1505,21 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) throw new UnsupportedYamlException($"Line {lineNumber}: nested flow collections are not supported in frontmatter."); } - ThrowIfReservedPlainStart(rawKey, lineNumber); + if (rawKey.IndexOfAny(s_flowIndicators) >= 0) + { + throw new FormatException($"Line {lineNumber}: flow indicators in a mapping key must be quoted."); + } + + // The key is trimmed, so the check below cannot see the ':' that makes "-:" the plain key "-". + // Followed by white space or the end of the entry, the indicator cannot start a plain key. + bool isIndicatorPlainKey = rawKey is "-" or "?" or ":" && + start + 1 < text.Length && + text[start + 1] == ':'; + if (!isIndicatorPlainKey) + { + ThrowIfReservedPlainStart(rawKey, lineNumber); + } + keyNode = ResolvePlainScalar(rawKey, lineNumber); hasValue = colonAt >= 0; if (hasValue) @@ -1014,6 +1541,13 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); } + if (text[pos] == '#' && + text[pos - 1] is ' ' or '\t') + { + // A comment runs to the end of the line, so the collection continues on a later line. + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow mapping.", '}'); + } + if (text[pos] == ',') { pos++; @@ -1034,10 +1568,12 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) private static JsonNode? ParseFlowScalar(string text, ref int pos, int lineNumber, string terminators) { + char closer = terminators.IndexOf(']') >= 0 ? ']' : '}'; pos = SkipSpaces(text, pos); if (pos >= text.Length) { - throw new FormatException($"Line {lineNumber}: unexpected end of flow collection."); + // The value is on a later line, so the collection continues there. + throw new UnterminatedFlowException($"Line {lineNumber}: unexpected end of flow collection.", closer); } char c = text[pos]; @@ -1048,7 +1584,18 @@ private static JsonObject ParseFlowMapping(string text, int lineNumber) if (c is '"' or '\'') { - string quoted = ParseQuotedScalar(text, pos, lineNumber, out int consumed); + string quoted; + int consumed; + try + { + quoted = ParseQuotedScalar(text, pos, lineNumber, out consumed); + } + catch (UnterminatedQuoteException e) + { + // The quoted scalar, and so the collection, continues on a later line. + throw new UnterminatedFlowException(e.Message, closer); + } + pos = consumed; return JsonValue.Create(quoted); } @@ -1056,20 +1603,36 @@ 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')) + if (text[pos] == '#' && + 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."); + // A comment runs to the end of the line, so the collection continues on a later line. + throw new UnterminatedFlowException($"Line {lineNumber}: unterminated flow collection.", closer); } pos++; } string plain = text.Substring(start, pos - start).Trim(s_yamlWhitespace); - if (FindKeySeparator(plain) >= 0) + int separator = FindKeySeparator(plain, inFlow: true); + if (separator >= 0) { + // A flow mapping's value cannot itself be a mapping, and a single 'key: value' pair in a flow sequence + // cannot hold another one; YAML rejects both. A single pair is valid YAML this reader does not support. + if (closer == '}' || + FindKeySeparator(plain.Substring(separator + 1), inFlow: true) >= 0) + { + throw new FormatException($"Line {lineNumber}: a plain value in a flow collection cannot contain ': '. Quote the value if it is meant literally."); + } + throw new UnsupportedYamlException($"Line {lineNumber}: compact mappings inside flow sequences ('[key: value]') are not supported in frontmatter."); } + if (plain.IndexOfAny(s_flowIndicators) >= 0) + { + throw new FormatException($"Line {lineNumber}: flow indicators in a plain scalar must be quoted."); + } + ThrowIfReservedPlainStart(plain, lineNumber); return ResolvePlainScalar(plain, lineNumber); } @@ -1102,7 +1665,7 @@ private static string ParseQuotedScalar(string text, int start, int lineNumber, } 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."); + throw new UnrepresentableValueException($"Line {lineNumber}: the quoted scalar contains an unpaired surrogate escape, which is not a valid Unicode character."); } } @@ -1206,19 +1769,40 @@ key is null ? string.Empty private static JsonNode? TryResolveNumber(string text, int lineNumber) { - // Hexadecimal and octal integers take no sign in the core schema, and may exceed 64 bits. + // Hexadecimal and octal integers take no sign in the core schema. As in yaml.v3, their values are exact up + // to 64 bits; a wider value is reported as unsupported rather than converted to another type. + int bitsPerDigit = 0; if (text.StartsWith("0x", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, IsHexDigit)) { - var value = BigInteger.Parse("0" + text.Substring(2), NumberStyles.AllowHexSpecifier, CultureInfo.InvariantCulture); - return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); + bitsPerDigit = 4; + } + else if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7')) + { + bitsPerDigit = 3; } - if (text.StartsWith("0o", StringComparison.Ordinal) && text.Length > 2 && IsAll(text, 2, static c => c is >= '0' and <= '7')) + if (bitsPerDigit > 0) { - BigInteger value = BigInteger.Zero; + ulong value = 0; for (int j = 2; j < text.Length; j++) { - value = (value * 8) + (text[j] - '0'); + // Shifting in another digit must not push set bits past 64. Leading zeros keep the value at 0. + if (value >> (64 - bitsPerDigit) != 0) + { + string literal = text.Length > 24 ? text.Substring(0, 24) + "..." : text; + throw new UnsupportedYamlException( + $"Line {lineNumber}: '{literal}' is a hexadecimal or octal integer wider than 64 bits, which this reader does not support. " + + "Supply the frontmatter explicitly, or parse it with a full YAML parser."); + } + + char c = text[j]; + int digit = c switch + { + <= '9' => c - '0', + <= 'F' => c - 'A' + 10, + _ => c - 'a' + 10, + }; + value = (value << bitsPerDigit) + (ulong)digit; } return JsonNode.Parse(value.ToString(CultureInfo.InvariantCulture)); @@ -1241,7 +1825,7 @@ key is null ? string.Empty 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."); + throw new UnrepresentableValueException($"Line {lineNumber}: '{text}' cannot be represented in JSON."); } // Integer: digits only. @@ -1304,7 +1888,7 @@ key is null ? string.Empty 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."); + throw new UnrepresentableValueException($"Line {lineNumber}: '{text}' cannot be represented as a JSON number."); } // JSON has no negative zero worth preserving: reference parsers render -0 and -0.0 as 0. @@ -1343,12 +1927,15 @@ private static int SkipSpaces(string text, int pos) return pos; } - /// Finds the ':' that separates a plain key from its value: the first ':' followed by white space or the end of the line. - private static int FindKeySeparator(string content) + /// Finds a plain key's separator; flow context also treats a following flow indicator as separation. + private static int FindKeySeparator(string content, bool inFlow = false) { for (int i = 0; i < content.Length; i++) { - if (content[i] == ':' && (i + 1 == content.Length || content[i + 1] is ' ' or '\t')) + if (content[i] == ':' && + (i + 1 == content.Length || + content[i + 1] is ' ' or '\t' || + (inFlow && Array.IndexOf(s_flowIndicators, content[i + 1]) >= 0))) { return i; } diff --git a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs index cb3ac0168..480275dc9 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -1,3 +1,6 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Skills; @@ -32,12 +35,12 @@ public static string GetSkillRoot(string skillUri, string paramName) } /// - /// Returns the final path segment of a skill root, which the specification requires to equal the skill's name. + /// Returns the decoded final path segment of a skill root, which the specification requires to equal the skill's name. /// public static string GetNameSegment(string skillRoot) { - int slash = skillRoot.LastIndexOf('/'); - return slash < 0 ? skillRoot : skillRoot.Substring(slash + 1); + int start = Math.Max(skillRoot.LastIndexOf('/') + 1, skillRoot.IndexOf(':') + 1); + return System.Uri.UnescapeDataString(skillRoot.Substring(start)); } /// @@ -46,29 +49,46 @@ public static string GetNameSegment(string skillRoot) /// public static bool IsValidSkillName(string name) { - if (name.Length is 0 or > 64) + if (name.Length == 0) { return false; } - char previous = '-'; - foreach (char c in name) + int scalarCount = 0; + bool previousIsHyphen = true; + for (int index = 0; index < name.Length; index++) { - bool isAlphanumeric = (char.IsLetter(c) && !char.IsUpper(c)) || char.IsDigit(c); - if (!isAlphanumeric && c != '-') + char c = name[index]; + bool isHyphen = c == '-'; + var category = CharUnicodeInfo.GetUnicodeCategory(name, index); + bool isAlphanumeric = category is UnicodeCategory.LowercaseLetter or + UnicodeCategory.ModifierLetter or UnicodeCategory.OtherLetter or UnicodeCategory.DecimalDigitNumber; + if (!isAlphanumeric && + !isHyphen) { return false; } - if (c == '-' && previous == '-') + if (isHyphen && + previousIsHyphen) { return false; } - previous = c; + if (++scalarCount > 64) + { + return false; + } + + if (char.IsHighSurrogate(c)) + { + index++; + } + + previousIsHyphen = isHyphen; } - return previous != '-'; + return !previousIsHyphen; } /// @@ -101,8 +121,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 || !System.Uri.TryCreate(uri, UriKind.Absolute, out _)) + int schemeEnd = uri.IndexOf(':'); + if (schemeEnd <= 0 || + !System.Uri.TryCreate(uri, UriKind.Absolute, out _)) { throw new ArgumentException($"{what} '{uri}' must be a syntactically valid absolute URI with a scheme, such as skill://name/SKILL.md.", paramName); } @@ -115,11 +136,27 @@ public static void ValidateUriShape(string uri, string what, string paramName) // Check the raw text rather than System.Uri's view of it, which decodes and compacts dot segments, so that // "%2e%2e" is caught as well as "..". The authority (the first segment) may be empty, as in // file:///name/SKILL.md; path segments may not. - string[] segments = uri.Substring(schemeEnd + 3).Split('/'); + int pathStart = schemeEnd + 1; + bool hasAuthority = + pathStart + 1 < uri.Length && + uri[pathStart] == '/' && + uri[pathStart + 1] == '/'; + if (hasAuthority) + { + pathStart += 2; + } + else if (pathStart < uri.Length && + uri[pathStart] == '/') + { + pathStart++; + } + + string[] segments = uri.Substring(pathStart).Split('/'); for (int i = 0; i < segments.Length; i++) { string segment = segments[i]; - if (segment.Length == 0 && i > 0) + if (segment.Length == 0 && + (i > 0 || !hasAuthority)) { throw new ArgumentException($"{what} '{uri}' must not contain empty path segments.", paramName); } @@ -141,6 +178,7 @@ public static void ValidateUriShape(string uri, string what, string paramName) { Uri = skill.Uri, Frontmatter = (JsonObject)skill.Frontmatter.DeepClone(), + Meta = (JsonObject?)skill.Meta?.DeepClone(), Resources = skill.Resources.IsDynamic ? SkillResources.Dynamic : SkillResources.FromResources(skill.Resources.Resources!.Select(static r => new SkillResource { Uri = r.Uri, Digest = r.Digest, Size = r.Size })), @@ -209,27 +247,40 @@ public static void Validate(Skill skill, string paramName) throw new ArgumentException($"Skill '{skill.Uri}' must declare a non-empty string 'description' in its frontmatter.", paramName); } - if (description!.Length > MaxDescriptionLength) + int descriptionLength = Encoding.UTF32.GetByteCount(description!) / 4; + if (descriptionLength > MaxDescriptionLength) { throw new ArgumentException( - $"Skill '{skill.Uri}' has a description of {description.Length} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.", + $"Skill '{skill.Uri}' has a description of {descriptionLength} characters; the Agent Skills specification allows at most {MaxDescriptionLength}.", paramName); } - // Beyond name and description, only the constraints the Agent Skills specification states as requirements - // are enforced: compatibility is 1 to 500 characters if provided, and metadata is a mapping. Other fields, - // and the values inside metadata, pass through verbatim. Hosts compare frontmatter field by field against - // the file and ignore fields such as allowed-tools for MCP-origin skills, so rejecting a whole skill over - // the shape of a field no host acts on would help nobody. ValidateOptionalString(skill, "compatibility", MaxCompatibilityLength, paramName); + ValidateOptionalStringType(skill, "license", "a license name or a reference to a bundled license file", paramName); - if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode) && metadataNode is not JsonObject) + if (skill.Frontmatter.TryGetPropertyValue("metadata", out var metadataNode)) { - 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 (metadataNode is not JsonObject metadata) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' frontmatter field that is not a mapping. Give it string key-value entries, or remove the key if it is not needed.", + paramName); + } + + foreach (var entry in metadata) + { + if (entry.Value is null || + entry.Value.GetValueKind() != JsonValueKind.String) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a 'metadata' value for '{entry.Key}' that is not a string.", + paramName); + } + } } + ValidateOptionalStringType(skill, "allowed-tools", "a space-separated string of tools", paramName); + if (skill.Resources is null) { throw new ArgumentException($"Skill '{skill.Uri}' has no resources manifest. Use SkillResources.Dynamic for generated content.", paramName); @@ -335,10 +386,25 @@ private static void ValidateOptionalString(Skill skill, string key, int? maxLeng paramName); } - if (text!.Length == 0 || (maxLength is { } max && text.Length > max)) + int length = Encoding.UTF32.GetByteCount(text!) / 4; + if (length == 0 || + (maxLength is { } max && length > max)) + { + throw new ArgumentException( + $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.", + paramName); + } + } + + // Type-only check for an optional field the Agent Skills specification defines as a string without a length rule. + // It uses the same string test as the Skill accessors, so an entry that passes always reads back as a string. + private static void ValidateOptionalStringType(Skill skill, string key, string definition, string paramName) + { + if (skill.Frontmatter.TryGetPropertyValue(key, out var node) && + !(node is JsonValue value && value.TryGetValue(out string? _))) { throw new ArgumentException( - $"Skill '{skill.Uri}' has a '{key}' frontmatter field of {text.Length} characters; the Agent Skills specification requires 1 to {maxLength ?? int.MaxValue}.", + $"Skill '{skill.Uri}' has a '{key}' frontmatter field that is not a string. The Agent Skills specification defines it as {definition}.", paramName); } } diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 98c225dd9..b38bb0ffe 100644 --- a/tests/Common/Utils/NodeHelpers.cs +++ b/tests/Common/Utils/NodeHelpers.cs @@ -396,10 +396,8 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) return (core, prerelease); } - - /// whether it succeeded along with the captured stdout/stderr. Centralizes the process - /// plumbing (output capture, a 5-minute timeout, and the Windows libuv-shutdown fallback) - /// shared by the server-side conformance tests. + /// + /// Runs the server-side conformance process and captures its output, by default within a five-minute deadline. /// /// Arguments to pass to the conformance runner. /// Optional callback invoked for each captured stdout/stderr line. @@ -407,112 +405,214 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) /// Forwarded to . /// /// Token used to cancel the run. + /// An alternative process for isolated process-outcome tests. + /// The deadline for the process to exit and close its output. Defaults to five minutes. + /// + /// Success requires the process to exit with code zero and both redirected streams to reach their end, all within + /// one deadline. On timeout or cancellation the result is a failure, even if the process had already exited; a + /// process that is still running is killed with its child processes. + /// public static async Task<(bool Success, string Output, string Error)> RunServerConformanceAsync( string arguments, Action? onLine = null, bool appendProtocolVersionFromEnv = true, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + ProcessStartInfo? processStartInfo = null, + TimeSpan? timeout = null) { - var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + var startInfo = processStartInfo ?? ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + var deadline = timeout ?? TimeSpan.FromMinutes(5); var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); + var bufferLock = new object(); + bool capturing = true; using var process = new Process { StartInfo = startInfo }; - // Protect callbacks with try/catch so a callback that throws on a background thread - // (e.g. ITestOutputHelper after the test completes) does not crash the test host. - DataReceivedEventHandler outputHandler = (sender, e) => + void Capture(string line, StringBuilder builder) { - if (e.Data != null) + // The lock keeps a late line from changing the buffers after the final snapshot. + lock (bufferLock) { - try { onLine?.Invoke(e.Data); } catch { } - outputBuilder.AppendLine(e.Data); - } - }; + if (!capturing) + { + return; + } - DataReceivedEventHandler errorHandler = (sender, e) => - { - if (e.Data != null) - { - try { onLine?.Invoke(e.Data); } catch { } - errorBuilder.AppendLine(e.Data); + builder.AppendLine(line); } - }; - process.OutputDataReceived += outputHandler; - process.ErrorDataReceived += errorHandler; + // Protect callbacks with try/catch so a callback that throws (e.g. ITestOutputHelper after the test + // completes) does not stop the reader. + try { onLine?.Invoke(line); } catch { } + } + + // Each stream is read on its own thread. On Windows, Begin*ReadLine block a pool thread per stream while the + // pipe is open; with a descendant holding both pipes, a busy pool can have no thread left for the deadline. + Task ReadLinesAsync(StreamReader reader, StringBuilder builder) => Task.Factory.StartNew( + () => + { + using (reader) + { + string? line; + while ((line = reader.ReadLine()) is not null) + { + Capture(line, builder); + } + } + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + +#if !NET + // .NET Framework has no cancellable WaitForExitAsync. Register the exit before the process starts, so the wait + // below can cover it and the end of both streams under one deadline, as on .NET. + var exited = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + EventHandler exitedHandler = (sender, e) => exited.TrySetResult(true); + + process.EnableRaisingEvents = true; + process.Exited += exitedHandler; +#endif process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + var outputRead = ReadLinesAsync(process.StandardOutput, outputBuilder); + var errorRead = ReadLinesAsync(process.StandardError, errorBuilder); + // One deadline covers the exit and both streams, so output held open by another process cannot extend it. using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(TimeSpan.FromMinutes(5)); + cts.CancelAfter(deadline); + bool completed; +#if NET try { -#if NET + // Waits for the exit and then for both redirected streams to end, all under the same token. await process.WaitForExitAsync(cts.Token); -#else - // net472 lacks the CancellationToken overload; fall back to the timeout-based polyfill - // extension and surface a timeout the same way the current target-framework path does. - await process.WaitForExitAsync(TimeSpan.FromMinutes(5)); - if (!process.HasExited) - { - throw new OperationCanceledException(); - } -#endif + await Task.WhenAll(outputRead, errorRead).WaitAsync(cts.Token); + completed = true; } catch (OperationCanceledException) { + completed = false; + } +#else + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using (cts.Token.Register(() => stopped.TrySetResult(true))) + { + var finished = Task.WhenAll(exited.Task, outputRead, errorRead); + completed = await Task.WhenAny(finished, stopped.Task) == finished; + } +#endif + + bool killed = false; + if (!completed) + { + // Only a process that is still running is killed, with its child processes; it can still exit between the + // check and the kill. + try + { + if (!process.HasExited) + { #if NET - process.Kill(entireProcessTree: true); + process.Kill(entireProcessTree: true); #else - process.Kill(); + KillProcessTree(process); #endif - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; - return ( - false, - outputBuilder.ToString(), - errorBuilder.ToString() + "\nProcess timed out after 5 minutes and was killed."); + killed = true; + } + } + catch (InvalidOperationException) + { + // It exited before the kill. + } + catch (System.ComponentModel.Win32Exception) + { + // It was already exiting. + } } - process.OutputDataReceived -= outputHandler; - process.ErrorDataReceived -= errorHandler; +#if !NET + process.Exited -= exitedHandler; +#endif + + string stdoutText; + string stderrText; + lock (bufferLock) + { + capturing = false; + stdoutText = outputBuilder.ToString(); + stderrText = errorBuilder.ToString(); + } - var stdoutText = outputBuilder.ToString(); - var stderrText = errorBuilder.ToString(); + if (!completed) + { + stderrText += $"{Environment.NewLine}Process timed out after {deadline.TotalSeconds} seconds before it exited and closed its output" + + (killed ? " and was killed." : "."); + return (false, stdoutText, stderrText); + } - // The Node.js conformance runner can crash during cleanup on Windows with a libuv - // assertion ("!(handle->flags & UV_HANDLE_CLOSING)") that produces a non-zero exit - // code even though every conformance check passed. When that happens, fall back to - // parsing the "Test Results:" summary in stdout to decide success. - bool success = process.ExitCode == 0 || ConformanceOutputIndicatesSuccess(stdoutText); + bool success = process.ExitCode == 0; + if (!success) + { + stderrText += $"{Environment.NewLine}Conformance runner exited with code {process.ExitCode}."; + } return (success, stdoutText, stderrText); } +#if !NET /// - /// Parses the conformance runner output for a "Test Results:" line such as - /// "Passed: 3/3, 0 failed, 0 warnings" and returns true when all checks passed - /// and none failed. + /// Kills a process and its child processes, like Process.Kill(entireProcessTree: true) on .NET. On Windows + /// this runs taskkill /T /F for at most ten seconds; if that cannot run, does not finish, or fails, only + /// the process itself is killed. /// - private static bool ConformanceOutputIndicatesSuccess(string output) + private static void KillProcessTree(Process process) { - // Match lines like "Passed: 3/3, 0 failed, 0 warnings" - var match = Regex.Match(output, @"Passed:\s*(\d+)/(\d+),\s*(\d+)\s*failed"); - if (!match.Success) + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - return false; - } + try + { + using var taskkill = new Process + { + StartInfo = new ProcessStartInfo(Path.Combine(Environment.SystemDirectory, "taskkill.exe"), $"/T /F /PID {process.Id}") + { + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + }, + }; + taskkill.Start(); + taskkill.BeginOutputReadLine(); + taskkill.BeginErrorReadLine(); + if (taskkill.WaitForExit(10_000) && + taskkill.ExitCode == 0) + { + return; + } - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); + if (!taskkill.HasExited) + { + taskkill.Kill(); + } + } + catch (InvalidOperationException) + { + // taskkill exited before it could be stopped. + } + catch (System.ComponentModel.Win32Exception) + { + // taskkill could not be started or stopped. + } + } - return passed == total && failed == 0 && total > 0; + if (!process.HasExited) + { + process.Kill(); + } } +#endif /// /// Checks whether the SEP-2322 (Multi Round-Trip Requests / InputRequiredResult) diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index dd2adc998..4a8d2098d 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -6,20 +6,26 @@ using ModelContextProtocol.Server; using ModelContextProtocol.Tests.Utils; using System.IO.Pipelines; +using System.Text.Json.Nodes; Pipe clientToServerPipe = new(), serverToClientPipe = new(); +var authoredSkill = McpServerSkill.Create( + "skill://aot/SKILL.md", + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]); +var expectedMeta = JsonNode.Parse("""{"example.com/aot":{"values":["native",42,true]}}""")!.AsObject(); +var entry = authoredSkill.ProtocolSkill; +entry.Meta = (JsonObject)expectedMeta.DeepClone(); +var catalog = new InMemoryMcpSkillCatalog([entry]); +entry.Meta["example.com/aot"]!["values"]!.AsArray().Clear(); + var services = new ServiceCollection(); services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() .WithMcpApps() - .WithSkills( - [ - McpServerSkill.Create( - "skill://aot/SKILL.md", - [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription:\n An AOT-published skill.\nmetadata:\n note: >\n First\n \tCode\n Last\n---\n")]), - ]); + .WithSkills(catalog) + .WithResources(authoredSkill.Resources); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -56,6 +62,11 @@ throw new Exception($"Unexpected skills listing: {skills.Count} entries."); } +if (!JsonNode.DeepEquals(expectedMeta, skills[0].Meta)) +{ + throw new Exception("Unexpected skill entry metadata in the listing."); +} + var skillResult = await client.GetSkillAsync(new GetSkillRequestParams { Uri = "skill://aot/SKILL.md" }); if (skillResult.ResultType != "complete" || skillResult.TimeToLive != TimeSpan.Zero || @@ -65,6 +76,11 @@ } var skill = skillResult.Skill; +if (!JsonNode.DeepEquals(expectedMeta, skill.Meta)) +{ + throw new Exception("Unexpected skill entry metadata in the lookup."); +} + if (skill.Description != "An AOT-published skill." || skill.Frontmatter["metadata"]?["note"]?.GetValue() != "First\n\tCode\nLast\n") { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs index 21fb98c2e..6bd8c749b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using Microsoft.AspNetCore.Builder; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -7,34 +7,42 @@ namespace ModelContextProtocol.ConformanceTests; /// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP /// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at /// , "/stateless") on one port. A single long-lived server avoids -/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and -/// centralizes the port-binding logic that the stateful and stateless conformance tests previously -/// duplicated. Shared by and +/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows. Kestrel +/// assigns a loopback port, and initialization awaits this host's startup before publishing its URL. +/// Shared by and /// via . /// public sealed class ConformanceServerFixture : IAsyncLifetime { - // Use different ports for each target framework to allow parallel execution across the - // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003. - private static int GetPortForTargetFramework() + private readonly Func _createApplication; + private readonly TimeProvider _timeProvider; + private WebApplication? _app; + private string? _serverUrl; + private CancellationTokenSource? _startupTimeoutCts; + private CancellationTokenSource? _startupCts; + private Task? _startupTask; + private Task? _stopTask; + private Task? _disposeTask; + private bool _startupOutcomeObserved; + + public ConformanceServerFixture() + : this(() => new ConformanceServer.Program().CreateApplication(["--urls", "http://127.0.0.1:0"])) { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + } - return targetFramework switch - { - "net10.0" => 3001, - "net9.0" => 3002, - "net8.0" => 3003, - _ => 3001 // Default fallback - }; + internal ConformanceServerFixture(Func createApplication) + : this(createApplication, TimeProvider.System) + { } - private Task? _serverTask; - private CancellationTokenSource? _serverCts; + internal ConformanceServerFixture(Func createApplication, TimeProvider timeProvider) + { + _createApplication = createApplication; + _timeProvider = timeProvider; + } /// Base URL of the stateful MCP endpoint (mapped at "/"). - public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; + public string ServerUrl => _serverUrl ?? throw new InvalidOperationException("The conformance server has not started."); /// /// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28 @@ -42,57 +50,115 @@ private static int GetPortForTargetFramework() /// public string StatelessServerUrl => $"{ServerUrl}/stateless"; - public async ValueTask InitializeAsync() - { - _serverCts = new CancellationTokenSource(); - _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + public ValueTask InitializeAsync() => InitializeAsync(TestContext.Current.CancellationToken); - // Wait for server to be ready (retry for up to 30 seconds) - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + internal async ValueTask InitializeAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _startupTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30), _timeProvider); + _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _startupTimeoutCts.Token); + _app = _createApplication(); + _startupTask = _app.StartAsync(_startupCts.Token); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = _startupCts.Token.Register(() => cancelled.TrySetResult(true)); - while (stopwatch.Elapsed < timeout) + try { - try - { - await httpClient.GetAsync($"{ServerUrl}/health"); - return; - } - catch (HttpRequestException) + await Task.WhenAny(_startupTask, cancelled.Task); + if (_startupTask.IsCompleted) { - // Connection refused means server not ready yet - } - catch (TaskCanceledException) - { - // Timeout means server might be processing, give it more time + _startupOutcomeObserved = true; + await _startupTask; } + _startupCts.Token.ThrowIfCancellationRequested(); + _startupTimeoutCts.CancelAfter(Timeout.InfiniteTimeSpan); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _startupCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer failed to start within 30 seconds.", error); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed.", error); + } - await Task.Delay(500); + if (!_app.Lifetime.ApplicationStarted.IsCancellationRequested || + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed."); } - throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + _serverUrl = _app.Urls.Single(); } public async ValueTask DisposeAsync() { - if (_serverCts != null) + if (_app is null) + { + DisposeStartupCancellation(); + return; + } + + using var shutdownCts = new CancellationTokenSource(TestConstants.DefaultTimeout, _timeProvider); + try { - _serverCts.Cancel(); - if (_serverTask != null) + if (_startupCts is not null) { - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch + await _startupCts.CancelAsync().WaitAsync(shutdownCts.Token); + } + + if (_startupTask is not null) + { + // Wait for completion without throwing its already-reported startup error. + // A timed-out, still-running startup retains ownership of the app and tokens. + await WaitForCompletionAsync(_startupTask, shutdownCts.Token); + } + + shutdownCts.Token.ThrowIfCancellationRequested(); + _stopTask ??= _app.StopAsync(shutdownCts.Token); + await WaitForCompletionAsync(_stopTask, shutdownCts.Token); + shutdownCts.Token.ThrowIfCancellationRequested(); + _disposeTask ??= _app.DisposeAsync().AsTask(); + await _disposeTask.WaitAsync(shutdownCts.Token); + + var startupOutcome = Task.CompletedTask; + if (!_startupOutcomeObserved && + _startupTask is not null) + { + _startupOutcomeObserved = true; + bool expectedCancellation = _startupTask.IsCanceled && + (_startupCts?.IsCancellationRequested).GetValueOrDefault(); + if (!expectedCancellation) { - // Ignore exceptions during shutdown + startupOutcome = _startupTask; } } - _serverCts.Dispose(); + + _app = null; + _serverUrl = null; + DisposeStartupCancellation(); + + // Observe failures only after safely releasing the host; neither a late startup + // fault nor a StopAsync failure is converted into successful cleanup. + await Task.WhenAll(_stopTask, startupOutcome); } + catch (OperationCanceledException error) when (shutdownCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer cleanup did not complete within 60 seconds.", error); + } + } + + private async Task WaitForCompletionAsync(Task task, CancellationToken cancellationToken) + { + await Task.WhenAny(task).WaitAsync(cancellationToken); + } + + private void DisposeStartupCancellation() + { + _startupCts?.Dispose(); + _startupTimeoutCts?.Dispose(); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs new file mode 100644 index 000000000..40bf6f516 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs @@ -0,0 +1,351 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.ConformanceTests; +using ModelContextProtocol.Tests.Utils; +using System.Net; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class ConformanceServerFixtureTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Fact] + public async Task Initialize_ActualConformanceHosts_OwnDistinctReadyEndpoints() + { + await using var first = new ConformanceServerFixture(); + await using var second = new ConformanceServerFixture(); + await first.InitializeAsync(TestContext.Current.CancellationToken); + await second.InitializeAsync(TestContext.Current.CancellationToken); + Assert.NotEqual(first.ServerUrl, second.ServerUrl); + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + foreach (var fixture in new[] { first, second }) + { + var address = new Uri(fixture.ServerUrl); + Assert.True(address.IsLoopback); + Assert.NotEqual(0, address.Port); + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + await using var transport = new HttpClientTransport(new() { Endpoint = new Uri(fixture.StatelessServerUrl) }); + await using var client = await TestMcpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); + Assert.NotEmpty(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + } + } + + [Fact] + public async Task Initialize_WaitsForStartupBeforePublishingUrl() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(true); + await release.Task.WaitAsync(cancellationToken); + }))); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.False(startup.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + } + finally + { + release.TrySetResult(true); + } + await startup; + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Initialize_StartupFault_SurfacesOriginalError() + { + var expected = new InvalidOperationException("Synthetic host startup failure."); + await using var fixture = new ConformanceServerFixture(() => CreateApplication( + _ => new StartupService(_ => Task.FromException(expected)))); + + var actual = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Same(expected, actual); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_HostStopsDuringStartup_DoesNotReportReady() + { + await using var fixture = new ConformanceServerFixture(() => CreateApplication(services => + new StartupService(_ => + { + services.GetRequiredService().StopApplication(); + return Task.CompletedTask; + }))); + + var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("stopped before startup completed", error.Message); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_CallerCancellation_PropagatesToHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => startup); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_StartupTimeout_CancelsHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => startup); + Assert.Contains("30 seconds", error.Message); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_NoncooperativeStartup_IsBoundedAndOwnedUntilDrain() + { + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + Task? disposal = null; + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + await service.CancellationObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("ConformanceServer failed to start within 30 seconds", error.Message); + + disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await Assert.ThrowsAsync(() => startup); + if (disposal is not null) + { + await disposal; + } + else + { + await fixture.DisposeAsync(); + } + } + } + + [Fact] + public async Task Dispose_NoncooperativeStartup_ReportsDeadlineAndRetainsOwnership() + { + var clock = new FakeTimeProvider(); + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture( + () => CreateApplication(_ => service, useNoSocketServer: true), clock); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + clock.Advance(TestConstants.DefaultTimeout); + var error = await Assert.ThrowsAsync(() => + disposal.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("cleanup did not complete within 60 seconds", error.Message); + Assert.False(service.Disposed); + + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Dispose_LateStartupFault_IsReportedAfterSafeCleanup() + { + var expected = new InvalidOperationException("Synthetic late startup failure."); + var service = new NoncooperativeStartupService { Failure = expected }; + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + var actual = await Assert.ThrowsAsync(() => disposal); + Assert.Same(expected, actual); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + private WebApplication CreateApplication(Func serviceFactory, bool useNoSocketServer = false) + { + var builder = WebApplication.CreateBuilder(["--urls", "http://127.0.0.1:0"]); + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(XunitLoggerProvider); + if (useNoSocketServer) + { + builder.Services.AddSingleton(); + } + builder.Services.AddSingleton(serviceFactory); + var app = builder.Build(); + app.MapGet("/health", () => "Healthy"); + return app; + } + + private sealed class StartupService(Func start) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => start(cancellationToken); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class NoncooperativeStartupService : IHostedService, IDisposable + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool StartupCompleted { get; private set; } + public bool Disposed { get; private set; } + public bool DisposedDuringStartup { get; private set; } + public Exception? Failure { get; init; } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => CancellationObserved.TrySetResult(true)); + Entered.TrySetResult(true); + await Release.Task; + StartupCompleted = true; + if (Failure is { } error) + { + throw error; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + DisposedDuringStartup = !StartupCompleted; + Disposed = true; + } + } + + private sealed class NoSocketServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 2cb1d736e..d421ffe9d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Tests.Utils; +using System.Text.RegularExpressions; namespace ModelContextProtocol.ConformanceTests; @@ -15,6 +16,67 @@ public class ServerConformanceTests( ConformanceServerFixture fixture, ITestOutputHelper output) { + [Theory] + [InlineData("sep-2640-skills-enumeration", "2026-07-28")] + [InlineData("sep-2640-skills-manifest", "2026-07-28")] + [InlineData("sep-2640-skills-directory", "2026-07-28")] + [InlineData("sep-2640-skills-enumeration", "2025-11-25")] + [InlineData("sep-2640-skills-manifest", "2025-11-25")] + [InlineData("sep-2640-skills-directory", "2025-11-25")] + public async Task RunSkillsConformanceTest(string scenario, string protocolVersion) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + var url = protocolVersion == "2026-07-28" ? fixture.StatelessServerUrl : fixture.ServerUrl; + + var result = await RunConformanceTestsAsync( + $"server --url {url} --scenario {scenario} --spec-version {protocolVersion} --force", + appendProtocolVersionFromEnv: false); + + Assert.True(result.Success, + $"Skills scenario '{scenario}' for '{protocolVersion}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + Assert.DoesNotContain("SKIPPED: scenario", result.Output); + AssertSkillsSummary(result.Output); + } + + [Fact] + public async Task InstalledRunnerListsAllRequiredSkillsScenarios() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + + var result = await NodeHelpers.RunServerConformanceAsync( + "list --server", + appendProtocolVersionFromEnv: false, + cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(result.Success, $"Runner failed.\n{result.Output}\n{result.Error}"); + Assert.Contains("sep-2640-skills-enumeration", result.Output); + Assert.Contains("sep-2640-skills-manifest", result.Output); + Assert.Contains("sep-2640-skills-directory", result.Output); + } + + [Theory] + [InlineData("Passed: 1/1, 0 failed", true)] + [InlineData("Test Results:\nPassed: 32/32, 0 failed, 0 warnings\n", true)] + [InlineData("Test Results:\r\n Passed:\t6/6, 0 failed, 0 warnings\r\n", true)] + [InlineData("Passed: 0/0, 0 failed", false)] + [InlineData("Passed: 1/2, 0 failed", false)] + [InlineData("Passed: 2/2, 1 failed", false)] + [InlineData("Log: Passed: 1/1, 0 failed", false)] + [InlineData("Passed: 1/1, 0 failed unexpectedly", false)] + [InlineData("Passed: 1/1, 0 failed\nPassed: 0/0, 0 failed", false)] + [InlineData("", false)] + public void SkillsSummaryRequiresExactlyOnePositiveSuccessfulSummary(string output, bool valid) + { + if (valid) + { + AssertSkillsSummary(output); + } + else + { + Assert.ThrowsAny(() => AssertSkillsSummary(output)); + } + } + [Fact] public async Task RunConformanceTests() { @@ -152,11 +214,25 @@ public async Task RunTasksExtensionConformanceTest(string scenario) $"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) + private void AssertSkillsSummary(string output) + { + var pattern = new Regex( + @"^[ \t]*Passed:[ \t]*(?0|[1-9][0-9]*)/(?0|[1-9][0-9]*),[ \t]*(?0|[1-9][0-9]*)[ \t]+failed(?:,[^\r\n]*)?\r?$", + RegexOptions.Multiline | RegexOptions.CultureInvariant | RegexOptions.NonBacktracking, + TimeSpan.FromSeconds(1)); + var summary = Assert.Single(pattern.Matches(output).Cast()); + Assert.NotEqual("0", summary.Groups["passed"].Value); + Assert.Equal(summary.Groups["total"].Value, summary.Groups["passed"].Value); + Assert.Equal("0", summary.Groups["failed"].Value); + } + + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync( + string arguments, bool appendProtocolVersionFromEnv = true) { return await NodeHelpers.RunServerConformanceAsync( arguments, line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: appendProtocolVersionFromEnv, cancellationToken: TestContext.Current.CancellationToken); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs new file mode 100644 index 000000000..ccf9a5c36 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs @@ -0,0 +1,192 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Security.Claims; +using System.Text; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class SkillsAuthorizationTests(ITestOutputHelper output) : KestrelInMemoryTest(output) +{ + private const string SkillUri = "skill://private/report/SKILL.md"; + private const string HiddenSkillUri = "skill://private/secret-payroll/SKILL.md"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PaginationDoesNotDiscloseDeniedEntriesThroughCursors(bool allowed) + { + await using var app = await StartServer(allowed, paginated: true); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await TestMcpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + var seen = new List(); + string? cursor = null; + int pages = 0; + do + { + var page = await client.ListSkillsAsync( + new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken); + Assert.DoesNotContain(page.Skills, skill => skill.Uri == HiddenSkillUri); + seen.AddRange(page.Skills.Select(skill => skill.Uri)); + cursor = page.NextCursor; + if (cursor is not null) + { + var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(cursor)); + Assert.DoesNotContain("secret-payroll", decoded); + Assert.Equal(Assert.Single(page.Skills).Uri, decoded); + } + Assert.True(++pages <= 3, "Authorized pagination did not terminate."); + } + while (cursor is not null); + + Assert.Equal(allowed ? 2 : 1, pages); + Assert.Equal(allowed ? new[] { "skill://private/zebra/SKILL.md", "skill://private/zulu/SKILL.md" } : [], seen); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(seen, resources.Select(resource => resource.Uri).OrderBy(uri => uri, StringComparer.Ordinal)); + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(HiddenSkillUri, TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(HiddenSkillUri, cancellationToken: TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed) + { + await using var app = await StartServer(allowed); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await TestMcpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + if (allowed) + { + Assert.Equal(SkillUri, Assert.Single(skills).Uri); + Assert.Equal(SkillUri, Assert.Single(resources).Uri); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken); + Assert.Single(content.Contents); + } + else + { + Assert.Empty(skills); + Assert.Empty(resources); + var metadataError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode); + var resourceError = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, resourceError.ErrorCode); + } + } + + [Fact] + public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead() + { + await using var app = await StartServer(allowed: true, hideResourceListing: true); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await TestMcpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + Assert.Equal(SkillUri, skill.Uri); + Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents); + } + + [Fact] + public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess() + { + await using var app = await StartServer(allowed: true, authenticated: false); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await TestMcpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + } + + private async Task StartServer( + bool allowed, bool hideResourceListing = false, bool authenticated = true, bool paginated = false) + { + var skills = paginated + ? new[] { CreateSkill("secret-payroll"), CreateSkill("zebra"), CreateSkill("zulu") } + : new[] { CreateSkill("report") }; + var policy = new ReadPolicy(); + var catalog = new AuthorizedCatalog(skills.Select(skill => skill.ProtocolSkill).ToArray(), policy, pageSize: 1); + Builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithSkills(catalog) + .WithResources(skills.SelectMany(skill => skill.Resources)) + .WithRequestFilters(filters => + { + filters.AddListResourcesFilter(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + result.Resources = result.Resources + .Where(resource => !hideResourceListing && policy.CanRead(context.User, resource.Uri)) + .ToList(); + return result; + }); + filters.AddReadResourceFilter(next => (context, cancellationToken) => + { + if (!policy.CanRead(context.User, context.Params.Uri)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); + } + return next(context, cancellationToken); + }); + }); + + var app = Builder.Build(); + app.Use(async (context, next) => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], authenticated ? "Test" : null)); + await next(context); + }); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private McpServerSkill CreateSkill(string name) => McpServerSkill.Create( + $"skill://private/{name}/SKILL.md", + [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {name}\ndescription: d\n---\n")]); + + private sealed class ReadPolicy + { + public bool CanRead(ClaimsPrincipal? user, string uri) => + uri.StartsWith("skill://private/", StringComparison.Ordinal) && + uri != HiddenSkillUri && + (user?.Identity?.IsAuthenticated).GetValueOrDefault() && + (user?.IsInRole("reader")).GetValueOrDefault(); + } + + private sealed class AuthorizedCatalog(IReadOnlyList skills, ReadPolicy policy, int pageSize) : IMcpSkillCatalog + { + public ValueTask ListAsync( + string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) => + ForUser(context.User).ListAsync(cursor, context, cancellationToken); + + public ValueTask GetAsync( + string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + ForUser(context.User).GetAsync(uri, context, cancellationToken); + + private InMemoryMcpSkillCatalog ForUser(ClaimsPrincipal? user) => + new(skills.Where(skill => policy.CanRead(user, skill.Uri)), pageSize); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 328b523de..568644262 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -15,6 +15,12 @@ namespace ModelContextProtocol.ConformanceServer; public class Program { public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvider = null, CancellationToken cancellationToken = default) + { + var app = new Program().CreateApplication(args, loggerProvider); + await app.RunAsync(cancellationToken); + } + + public WebApplication CreateApplication(string[] args, ILoggerProvider? loggerProvider = null) { var builder = WebApplication.CreateBuilder(args); @@ -39,7 +45,7 @@ public static async Task MainAsync(string[] args, ILoggerProvider? loggerProvide app.MapGet("/health", () => "Healthy"); - await app.RunAsync(cancellationToken); + return app; } // Registers the conformance MCP server (tools, prompts, resources, filters, and handlers) diff --git a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs index 81fe15827..6fc96ed78 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Skills/ConformanceSkills.cs @@ -11,8 +11,8 @@ namespace ModelContextProtocol.ConformanceServer.Skills; /// /// 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. +/// dynamically generated skill is included in the same catalog: it is listed and answerable through +/// skills/get, but its dynamic manifest carries no digests and so cannot be content-bound. /// /// /// The page size is deliberately small so the scenarios exercise cursor pagination. diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs new file mode 100644 index 000000000..608567af1 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientUnreadableTextTests.cs @@ -0,0 +1,139 @@ +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.Net; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +/// +/// A JSON string can hold an escaped unpaired surrogate such as "\uD800", which is not text. A server built on +/// System.Text.Json cannot write one, so these tests answer the client with raw response bodies and check that the +/// Skills client fails such an entry like any other malformed entry, without an . +/// +public class McpSkillsClientUnreadableTextTests(ITestOutputHelper testOutputHelper) : LoggedTest(testOutputHelper) +{ + private const string ValidFrontmatter = """{"name":"good","description":"d"}"""; + private const string UnpairedHighSurrogate = """{"name":"bad","description":"\uD800"}"""; + + [Theory] + [InlineData(UnpairedHighSurrogate)] + [InlineData("""{"name":"bad","description":"\uDC00 after an unpaired low surrogate"}""")] + [InlineData("""{"name":"bad","description":"d","\uD800":"in a key"}""")] + public async Task ListSkillsWithDiagnosticsAsync_RejectsAnEntryWithUnreadableText_AndKeepsTheValidEntry(string frontmatter) + { + await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", frontmatter))); + await using var client = await ConnectAsync(transport); + + var listing = await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal("good", Assert.Single(listing.Page.Skills).Name); + var rejection = Assert.Single(listing.RejectedEntries); + Assert.Equal(1, rejection.Index); + AssertUnreadableText(Assert.IsType(rejection.Error)); + } + + [Fact] + public async Task ListSkillsWithDiagnosticsAsync_ThrowsJsonException_WhenThePageHasUnreadableText() + { + string page = """{"skills":[],"nextCursor":"\uD800"}"""; + await using var transport = CreateTransport(listResult: page); + await using var client = await ConnectAsync(transport); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsWithDiagnosticsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken)); + + AssertUnreadableText(error); + } + + [Fact] + public async Task ListSkillsAsync_ThrowsJsonException_ForAnEntryWithUnreadableText() + { + await using var transport = CreateTransport(listResult: Page(Entry("good", ValidFrontmatter), Entry("bad", UnpairedHighSurrogate))); + await using var client = await ConnectAsync(transport); + + var error = await Assert.ThrowsAsync(async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + AssertUnreadableText(error); + } + + [Fact] + public async Task GetSkillAsync_ThrowsJsonException_ForAnEntryWithUnreadableText() + { + await using var transport = CreateTransport(listResult: Page(), getResult: "{\"skill\":" + Entry("bad", UnpairedHighSurrogate) + "}"); + await using var client = await ConnectAsync(transport); + + var error = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync("skill://bad/SKILL.md", TestContext.Current.CancellationToken)); + + AssertUnreadableText(error); + } + + private void AssertUnreadableText(JsonException error) + { + Assert.Contains("not valid Unicode text", error.Message); + Assert.IsType(error.InnerException); + } + + private string Page(params string[] entries) => "{\"skills\":[" + string.Join(",", entries) + "]}"; + + private string Entry(string name, string frontmatter) => + "{\"uri\":\"skill://" + name + "/SKILL.md\",\"frontmatter\":" + frontmatter + + ",\"resources\":[{\"uri\":\"skill://" + name + "/SKILL.md\",\"digest\":\"sha256:" + new string('a', 64) + "\",\"size\":1}]}"; + + /// Creates a transport to a raw Streamable HTTP server that answers skills/list and skills/get with the given result bodies. + private HttpClientTransport CreateTransport(string listResult, string? getResult = null) + { + var handler = new MockHttpHandler { RequestHandler = request => RespondAsync(request, listResult, getResult) }; + return new HttpClientTransport(new HttpClientTransportOptions + { + Endpoint = new Uri("http://localhost"), + TransportMode = HttpTransportMode.StreamableHttp, + Name = "Raw skills server test client", + }, new HttpClient(handler), LoggerFactory, ownsHttpClient: true); + } + + private Task ConnectAsync(HttpClientTransport transport) => McpClient.CreateAsync( + transport, + new McpClientOptions { ProtocolVersion = McpProtocolVersions.November2025ProtocolVersion }, + LoggerFactory, + TestContext.Current.CancellationToken); + + private async Task RespondAsync(HttpRequestMessage request, string listResult, string? getResult) + { + // The server offers no standalone SSE stream, which the spec permits. + // net472 does not populate a default Content, so every response sets one explicitly. + if (request.Method != HttpMethod.Post) + { + var status = request.Method == HttpMethod.Get ? HttpStatusCode.MethodNotAllowed : HttpStatusCode.OK; + return new HttpResponseMessage(status) { Content = new StringContent(string.Empty) }; + } + + using var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()); + if (!body.RootElement.TryGetProperty("id", out var id)) + { + return new HttpResponseMessage(HttpStatusCode.Accepted) { Content = new StringContent(string.Empty) }; + } + + string method = body.RootElement.GetProperty("method").GetString()!; + string result = method switch + { + RequestMethods.Initialize => + "{\"protocolVersion\":\"" + McpProtocolVersions.November2025ProtocolVersion + "\"," + + "\"capabilities\":{\"extensions\":{\"" + SkillsProtocol.ExtensionId + "\":{}}}," + + "\"serverInfo\":{\"name\":\"raw\",\"version\":\"1.0\"}}", + SkillsProtocol.MethodSkillsList => listResult, + SkillsProtocol.MethodSkillsGet when getResult is not null => getResult, + _ => throw new InvalidOperationException($"The raw test server does not answer '{method}'."), + }; + + var response = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":" + id.GetRawText() + ",\"result\":" + result + "}", Encoding.UTF8, "application/json"), + }; + response.Headers.Add("mcp-session-id", "raw-session"); + return response; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs index 2fa4222e0..b78f6a864 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -16,6 +16,9 @@ namespace ModelContextProtocol.Tests.Client; /// public class McpSkillsClientValidationTests : ClientServerTestBase { + private JsonObject? _listResponse; + private JsonObject? _getResponse; + public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -32,18 +35,38 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer options.RequestHandlers.Add(new McpServerRequestHandler { Method = SkillsProtocol.MethodSkillsList, - Handler = (request, _) => new ValueTask(JsonNode.Parse(request.Params?["cursor"]?.GetValue() == "no-skills-array" ? """{ "skills": null }""" : """ - { "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 } ] } ] } - """)), + Handler = (request, _) => + { + if (_listResponse is not null) + { + return new ValueTask(_listResponse.DeepClone()); + } + + var json = request.Params?["cursor"]?.GetValue() switch + { + "no-skills-array" => """{ "skills": null }""", + "missing-skills-array" => "{}", + "empty-skills-array" => """{ "skills": [] }""", + _ => """ + { "skills": [ { "uri": "skill://good/SKILL.md", "frontmatter": { "name": "good", "description": "d" }, + "resources": [ { "uri": "skill://good/SKILL.md", "digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 1 } ] }, + { "uri": "skill://bad/SKILL.md", "frontmatter": { "name": "mismatch", "description": "d" }, + "resources": [ { "uri": "skill://bad/SKILL.md", "digest": "not-a-digest", "size": 1 } ] } ] } + """, + }; + return new ValueTask(JsonNode.Parse(json)); + }, }); options.RequestHandlers.Add(new McpServerRequestHandler { Method = SkillsProtocol.MethodSkillsGet, Handler = (request, _) => { + if (_getResponse is not null) + { + return new ValueTask(_getResponse.DeepClone()); + } + // Any request other than the escaping skill is answered with a valid entry for a different URI. string? requested = request.Params?["uri"]?.GetValue(); string json = requested == "skill://escape/SKILL.md" @@ -74,13 +97,296 @@ public async Task ListSkillsAsync_RejectsAnInvalidEntry() Assert.Contains("skill://bad/SKILL.md", exception.Message); } + [Theory] + [InlineData("no-skills-array")] + [InlineData("missing-skills-array")] + public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray(string cursor) + { + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ListSkillsAsync_AcceptsAnEmptySkillsArray() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "empty-skills-array" }, TestContext.Current.CancellationToken); + + Assert.Empty(result.Skills); + } + + [Theory] + [InlineData("\U00010428")] + [InlineData("\U00020000")] + public async Task ListSkillsAsync_AcceptsSupplementaryUnicodeNames(string name) + { + _listResponse = CreateListResponse(name); + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(name, Assert.Single(result).Name); + } + + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("\U00010428")] + public async Task ClientMethodsAcceptPercentEncodedUnicodeNameSegments(string name) + { + var uri = $"skill://catalog/{Uri.EscapeDataString(name)}/SKILL.md"; + _listResponse = CreateListResponse(name, uri: uri); + var entry = _listResponse["skills"]![0]!; + _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; + await using var client = await CreateMcpClientForServer(); + + var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var diagnostic = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal(uri, Assert.Single(listed).Uri); + Assert.Equal(name, fetched.Name); + Assert.Equal(uri, fetched.Uri); + Assert.Empty(diagnostic.RejectedEntries); + Assert.Equal(uri, Assert.Single(diagnostic.Page.Skills).Uri); + } + + [Theory] + [InlineData("repo:/skills/alpha/SKILL.md")] + [InlineData("repo:skills/alpha/SKILL.md")] + [InlineData("repo:alpha/SKILL.md")] + [InlineData("urn:alpha/SKILL.md")] + public async Task ClientMethodsAcceptNativeAbsoluteResourceUris(string uri) + { + _listResponse = CreateListResponse("alpha", uri: uri); + var entry = _listResponse["skills"]![0]!; + _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; + await using var client = await CreateMcpClientForServer(); + + var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + Assert.Equal(uri, Assert.Single(listed).Uri); + Assert.Equal(uri, fetched.Uri); + } + + [Theory] + [InlineData("description", 1024, false)] + [InlineData("description", 1024, true)] + [InlineData("compatibility", 500, false)] + [InlineData("compatibility", 500, true)] + public async Task ClientMethodsUseScalarFrontmatterLimits(string field, int limit, bool overLimit) + { + int length = limit + (overLimit ? 1 : 0); + var text = "a" + string.Concat(Enumerable.Repeat("\U0001f600", length - 1)); + _listResponse = CreateListResponse("alpha"); + var entry = _listResponse["skills"]![0]!.AsObject(); + entry["frontmatter"]![field] = text; + _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; + var uri = entry["uri"]!.GetValue(); + await using var client = await CreateMcpClientForServer(); + + if (overLimit) + { + var listError = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + var getError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken)); + Assert.Contains($"{length} characters", listError.Message); + Assert.Contains($"{length} characters", getError.Message); + } + else + { + var listed = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + Assert.Equal(text, Assert.Single(listed).Frontmatter[field]!.GetValue()); + Assert.Equal(text, fetched.Frontmatter[field]!.GetValue()); + } + + var diagnostic = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + if (overLimit) + { + Assert.Empty(diagnostic.Page.Skills); + Assert.Contains($"{length} characters", Assert.Single(diagnostic.RejectedEntries).Error.Message); + } + else + { + Assert.Empty(diagnostic.RejectedEntries); + Assert.Equal(text, Assert.Single(diagnostic.Page.Skills).Frontmatter[field]!.GetValue()); + } + } + + [Fact] + public async Task ClientMethodsPreserveEntryMetaSeparatelyFromFrontmatterAndPageMeta() + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" }); + var entry = _listResponse["skills"]![0]!.AsObject(); + var meta = JsonNode.Parse("""{"example.com/extension":{"items":[{"value":"original"}]}}""")!.AsObject(); + entry["_meta"] = meta.DeepClone(); + _listResponse["_meta"] = new JsonObject { ["scope"] = "page" }; + _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; + var uri = entry["uri"]!.GetValue(); + await using var client = await CreateMcpClientForServer(); + + var page = await client.ListSkillsAsync(new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + var listed = Assert.Single(page.Skills); + Assert.True(JsonNode.DeepEquals(meta, listed.Meta)); + Assert.Equal("page", page.Meta!["scope"]!.GetValue()); + Assert.Equal("2.1", listed.Frontmatter["metadata"]!["version"]!.GetValue()); + listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "changed"; + + var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + Assert.True(JsonNode.DeepEquals(meta, fetched.Meta)); + var diagnostic = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + Assert.Empty(diagnostic.RejectedEntries); + Assert.True(JsonNode.DeepEquals(meta, Assert.Single(diagnostic.Page.Skills).Meta)); + Assert.Equal("page", diagnostic.Page.Meta!["scope"]!.GetValue()); + } + + [Fact] + public async Task ListSkillsAsync_RejectsTitlecaseNames() + { + _listResponse = CreateListResponse("\u01c5"); + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value) + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) }); + await using McpClient client = await CreateMcpClientForServer(); + + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("metadata", exception.Message); + } + [Fact] - public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray() + public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionFields() { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" }); + var frontmatter = _listResponse["skills"]![0]!["frontmatter"]!.AsObject(); + frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) }; await using McpClient client = await CreateMcpClientForServer(); + var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter)); + } + + [Fact] + public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal("good", Assert.Single(result.Page.Skills).Name); + var rejected = Assert.Single(result.RejectedEntries); + Assert.Equal(1, rejected.Index); + Assert.IsType(rejected.Error); + } + + [Fact] + public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints() + { + _listResponse = CreateListResponse("alpha"); + var entries = _listResponse["skills"]!.AsArray(); + entries.Add(null); + entries.Add(JsonNode.Parse("42")); + entries.Add(new JsonObject()); + _listResponse["nextCursor"] = "next-page"; + _listResponse["ttlMs"] = 3000; + _listResponse["cacheScope"] = "public"; + _listResponse["resultType"] = "complete"; + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Single(result.Page.Skills); + Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index)); + Assert.Equal("next-page", result.Page.NextCursor); + Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive); + Assert.Equal(CacheScope.Public, result.Page.CacheScope); + Assert.Equal("complete", result.Page.ResultType); + Assert.Equal(4, entries.Count); + } + + [Theory] + [InlineData("no-skills-array")] + [InlineData("missing-skills-array")] + public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor) + { + await using var client = await CreateMcpClientForServer(); + await Assert.ThrowsAsync( - async () => await client.ListSkillsAsync(new ListSkillsRequestParams { Cursor = "no-skills-array" }, TestContext.Current.CancellationToken)); + async () => await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams { Cursor = cursor }, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DiagnosticListingReportsAnAllInvalidPageRatherThanHidingItsFailure() + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = 1 }); + _listResponse["nextCursor"] = "next-page"; + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Empty(result.Page.Skills); + Assert.Contains("metadata", Assert.Single(result.RejectedEntries).Error.Message); + Assert.Equal("next-page", result.Page.NextCursor); + } + + [Theory] + [InlineData("allowed-tools", "[\"Read\",\"Write\"]")] + [InlineData("allowed-tools", "1")] + [InlineData("allowed-tools", "{\"Read\":true}")] + [InlineData("allowed-tools", "true")] + [InlineData("allowed-tools", "null")] + [InlineData("license", "[\"MIT\"]")] + [InlineData("license", "2")] + [InlineData("license", "{\"name\":\"MIT\"}")] + [InlineData("license", "false")] + [InlineData("license", "null")] + public async Task ClientMethodsRejectSpecFieldsThatAreNotStrings(string field, string valueJson) + { + _listResponse = CreateListResponse("alpha"); + var entry = _listResponse["skills"]![0]!.AsObject(); + entry["frontmatter"]![field] = JsonNode.Parse(valueJson); + _getResponse = new JsonObject { ["skill"] = entry.DeepClone() }; + var uri = entry["uri"]!.GetValue(); + await using var client = await CreateMcpClientForServer(); + + var listError = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + var getError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(uri, TestContext.Current.CancellationToken)); + var diagnostic = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + var expected = $"'{field}' frontmatter field that is not a string"; + Assert.Contains(expected, listError.Message); + Assert.Contains(expected, getError.Message); + Assert.Empty(diagnostic.Page.Skills); + Assert.Contains(expected, Assert.Single(diagnostic.RejectedEntries).Error.Message); } [Fact] @@ -111,4 +417,29 @@ public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() Assert.Contains("skills/get", exception.Message); Assert.Contains("'..'", exception.Message); } + + private JsonObject CreateListResponse(string name, JsonObject? metadata = null, string? uri = null) + { + uri ??= $"skill://catalog/{name}/SKILL.md"; + var frontmatter = new JsonObject { ["name"] = name, ["description"] = "d" }; + if (metadata is not null) + { + frontmatter["metadata"] = metadata; + } + + return new JsonObject + { + ["skills"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["frontmatter"] = frontmatter, + ["resources"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["digest"] = "sha256:" + new string('a', 64), + ["size"] = 1, + }), + }), + }; + } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs new file mode 100644 index 000000000..3bdfa0c33 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs @@ -0,0 +1,190 @@ +#pragma warning disable MCPEXP002 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class McpSkillsEnumerationTests : ClientServerTestBase +{ + private Func? _page; + private readonly List _cursors = []; + + public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder) + { + services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= []; + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (request, _) => + { + _cursors.Add(request.Params?["cursor"]?.GetValue()); + return new ValueTask(_page!(_cursors.Count)); + }, + }); + }); + } + + [Theory] + [InlineData("repeat")] + [InlineData("")] + public async Task RepeatedCursorFailsInsteadOfLooping(string cursor) + { + _page = number => Page(cursor, $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("repeated", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent() + { + _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing() + { + _page = number => number == 1 ? Page("", "first") : Page(null, "second"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name)); + Assert.Equal(2, _cursors.Count); + Assert.Null(_cursors[0]); + Assert.Equal("", _cursors[1]); + } + + [Fact] + public async Task EverChangingCursorsStopAtTheRequestedPageLimit() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken)); + + Assert.Contains("3 pages", error.Message); + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task DefaultPageLimitIsOneHundred() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("100 pages", error.Message); + Assert.Equal(100, _cursors.Count); + } + + [Fact] + public async Task EntryLimitFailsWithoutReturningATruncatedList() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken)); + + Assert.Contains("3 entries", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task LastPageMayExactlyReachBothLimits() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken); + + Assert.Equal(3, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly() + { + _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray()); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + Assert.Contains("1000 entries", error.Message); + + var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken); + Assert.Equal(1001, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(1, 0)] + [InlineData(1, -1)] + public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills) + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken)); + Assert.Empty(_cursors); + } + + [Fact] + public async Task CancellationStopsBeforeRequestingAPage() + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await client.ListSkillsAsync(cancellation.Token)); + Assert.Empty(_cursors); + } + + private JsonObject Page(string? nextCursor, params string[] names) + { + var entries = new JsonArray(); + foreach (var name in names) + { + entries.Add(new JsonObject + { + ["uri"] = $"skill://{name}/SKILL.md", + ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" }, + ["resources"] = "dynamic", + }); + } + return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs index c8164defc..8a176c572 100644 --- a/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/SkillVerifierTests.cs @@ -1,6 +1,7 @@ using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using System.Text; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Client; @@ -12,7 +13,7 @@ public class SkillVerifierTests { private const string Uri = "skill://alpha/SKILL.md"; - private static SkillResource Entry(byte[] content) => new() + private SkillResource Entry(byte[] content) => new() { Uri = Uri, Digest = SkillVerifier.ComputeDigest(content), @@ -131,6 +132,21 @@ public void Verify_Skill_RejectsEmptyResult() Assert.Throws(() => SkillVerifier.Verify(skill, new ReadResourceResult())); } + [Theory] + [InlineData("""{"contents":[null]}""")] + [InlineData("""{"contents":[null,{"uri":"skill://alpha/SKILL.md","text":"x"}]}""")] + [InlineData("""{"contents":[{"uri":"skill://alpha/SKILL.md","text":"x"},null]}""")] + public void Verify_Skill_RejectsNullWireContentsAsVerificationFailure(string json) + { + var skill = CreateSkill(Encoding.UTF8.GetBytes("x")); + var result = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); + Assert.NotNull(result); + + var error = Assert.Throws(() => SkillVerifier.Verify(skill, result)); + Assert.Contains("null", error.Message); + Assert.Throws(() => SkillVerifier.Verify(skill, Uri, result)); + } + [Fact] public void Verify_Skill_RejectsMissingManifestWithoutCrashing() { @@ -177,7 +193,7 @@ public void Verify_SkillAndUri_RequiresTheRequestedFileInTheResult() SkillVerifier.Verify(skill, "skill://alpha/unlisted.md", new ReadResourceResult { Contents = [new TextResourceContents { Uri = Uri, Text = "x" }] })); } - private static Skill CreateSkill(byte[] skillFileContent) => new() + private Skill CreateSkill(byte[] skillFileContent) => new() { Uri = Uri, Frontmatter = new JsonObject { ["name"] = "alpha", ["description"] = "d" }, diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 0ae1d0775..1ac0cbaa7 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -41,6 +41,7 @@ + @@ -52,6 +53,12 @@ + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs index bca8ae7ee..aba2221e9 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/SkillSerializationTests.cs @@ -11,7 +11,7 @@ namespace ModelContextProtocol.Tests.Protocol; /// public class SkillSerializationTests { - private static Skill CreateEntry(SkillResources resources) => new() + private Skill CreateEntry(SkillResources resources) => new() { Uri = "skill://git-workflow/SKILL.md", Frontmatter = new JsonObject @@ -22,7 +22,7 @@ public class SkillSerializationTests Resources = resources, }; - private static SkillResource CreateResource(string uri, string digest, long size) => new() + private SkillResource CreateResource(string uri, string digest, long size) => new() { Uri = uri, Digest = digest, @@ -114,6 +114,69 @@ public void Skill_PreservesArbitraryFrontmatterFields() Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized))); } + [Theory] + [InlineData("{}")] + [InlineData("""{"io.modelcontextprotocol.skills/x":{"values":[true,42,null,{"text":"value"}]}}""")] + public void Skill_WithMeta_RoundTrips(string meta) + { + var json = $$""" + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { "name": "refunds", "description": "d", "metadata": { "version": "2.1" } }, + "resources": "dynamic", + "_meta": {{meta}} + } + """; + + var skill = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(skill); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(meta), skill.Meta)); + Assert.Equal("2.1", skill.Frontmatter["metadata"]!["version"]!.GetValue()); + var reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(json), JsonNode.Parse(reserialized))); + } + + [Theory] + [InlineData("")] + [InlineData(""", "_meta": null""")] + public void Skill_OmitsUnsetOrNullMeta(string metaProperty) + { + var json = $$""" + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { "name": "refunds", "description": "d" }, + "resources": "dynamic"{{metaProperty}} + } + """; + + var skill = JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(skill); + Assert.Null(skill.Meta); + var reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill); + Assert.False(JsonNode.Parse(reserialized)!.AsObject().ContainsKey("_meta")); + } + + [Theory] + [InlineData("[]")] + [InlineData("42")] + [InlineData("true")] + [InlineData("\"metadata\"")] + public void Skill_RejectsNonObjectMeta(string meta) + { + var json = $$""" + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { "name": "refunds", "description": "d" }, + "resources": "dynamic", + "_meta": {{meta}} + } + """; + + Assert.ThrowsAny(() => JsonSerializer.Deserialize(json, McpSkillsJsonContext.Default.Skill)); + } + [Fact] public void Skill_NameAndDescription_AreNullWhenAbsentOrNotStrings() { @@ -128,6 +191,54 @@ public void Skill_NameAndDescription_AreNullWhenAbsentOrNotStrings() Assert.Null(skill.Description); } + [Fact] + public void Skill_TypedAccessors_ReadTheStringFieldsTheSpecificationDefines() + { + const string Json = """ + { + "uri": "skill://refunds/SKILL.md", + "frontmatter": { + "name": "refunds", + "description": "Process refunds", + "license": "Apache-2.0", + "compatibility": "Requires git", + "allowed-tools": "Bash(git:*) Read" + }, + "resources": "dynamic" + } + """; + + var skill = JsonSerializer.Deserialize(Json, McpSkillsJsonContext.Default.Skill); + + Assert.NotNull(skill); + Assert.Equal("refunds", skill.Name); + Assert.Equal("Process refunds", skill.Description); + Assert.Equal("Apache-2.0", skill.License); + Assert.Equal("Requires git", skill.Compatibility); + Assert.Equal("Bash(git:*) Read", skill.AllowedTools); + + // The accessors read the frontmatter; they are not part of the wire shape. + string reserialized = JsonSerializer.Serialize(skill, McpSkillsJsonContext.Default.Skill); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(Json), JsonNode.Parse(reserialized))); + } + + [Fact] + public void Skill_TypedAccessors_AreNullWhenAbsentOrNotStrings() + { + var absent = CreateEntry(SkillResources.Dynamic); + Assert.Null(absent.License); + Assert.Null(absent.Compatibility); + Assert.Null(absent.AllowedTools); + + var notStrings = CreateEntry(SkillResources.Dynamic); + notStrings.Frontmatter["license"] = 1; + notStrings.Frontmatter["compatibility"] = true; + notStrings.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + Assert.Null(notStrings.License); + Assert.Null(notStrings.Compatibility); + Assert.Null(notStrings.AllowedTools); + } + [Theory] [InlineData("\"static\"")] [InlineData("\"Dynamic\"")] diff --git a/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs new file mode 100644 index 000000000..265d0d6be --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Samples/SkillsClientFrontmatterReaderTests.cs @@ -0,0 +1,190 @@ +#if NET +using ModelContextProtocol.Extensions.Skills; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Samples; + +/// +/// The SkillsClient sample loads a skill only if the frontmatter it parses from the fetched SKILL.md equals the +/// entry the server published. These tests run the sample's YamlFrontmatterReader on files the server authored. +/// +public class SkillsClientFrontmatterReaderTests +{ + [Theory] + [InlineData("|+", " Kept\n\n", "Kept\n\n", false)] + [InlineData("|+", " Kept\n\n", "Kept\n\n", true)] + [InlineData("|", " Kept\n\n", "Kept\n", false)] + [InlineData("|", " Kept\n", "Kept\n", false)] + [InlineData("|-", " Kept\n\n", "Kept", false)] + public void BlockScalarAtClosingFence_MatchesTheServer(string header, string content, string expectedDescription, bool crlf) + { + var markdown = "---\nname: alpha\ndescription: " + header + "\n" + content + "---\n# Alpha\n"; + if (crlf) + { + markdown = markdown.Replace("\n", "\r\n"); + } + + var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + + var fetched = new YamlFrontmatterReader().Read(markdown); + + Assert.Equal(expectedDescription, fetched["description"]!.GetValue()); + Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}."); + } + + [Theory] + [InlineData("extra: !!str [one]")] + [InlineData("extra: !!str {key: value}")] + [InlineData("extra: !!str\n - one")] + [InlineData("extra: !!str\n key: value")] + [InlineData("extra: !!map [one]")] + [InlineData("extra: !!seq {key: value}")] + [InlineData("extra: !!set {one}")] + [InlineData("extra: !custom [one]")] + [InlineData("extra: ! [one]")] + public void CollectionTagThatChangesTheValue_IsRejected(string entry) + { + var exception = Assert.Throws(() => new YamlFrontmatterReader().Read("---\nname: alpha\ndescription: d\n" + entry + "\n---\n")); + + Assert.Contains("does not convert", exception.Message); + } + + [Theory] + [InlineData("!!str")] + [InlineData("!!seq")] + [InlineData("!custom")] + public void RootTagThatChangesTheValue_IsRejected(string tag) + { + var exception = Assert.Throws(() => new YamlFrontmatterReader().Read("---\n" + tag + "\nname: alpha\ndescription: d\n---\n")); + + Assert.Contains("does not convert", exception.Message); + } + + [Theory] + [InlineData("extra: !!seq [one]", """["one"]""")] + [InlineData("extra: ! [one]", """["one"]""")] + [InlineData("extra: !!map {key: value}", """{"key":"value"}""")] + [InlineData("extra: ! {key: value}", """{"key":"value"}""")] + [InlineData("extra: !!map\n key: value", """{"key":"value"}""")] + [InlineData("extra: ! [one]", """["one"]""")] + [InlineData("extra: ! {key: value}", """{"key":"value"}""")] + public void CollectionTagThatKeepsTheValue_IsAccepted(string entry, string expectedExtra) + { + var fetched = new YamlFrontmatterReader().Read("---\nname: alpha\ndescription: d\n" + entry + "\n---\n"); + + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedExtra), fetched["extra"]), fetched.ToJsonString()); + } + + [Theory] + [InlineData("!!map")] + [InlineData("!")] + [InlineData("!")] + public void RootMappingTag_IsAccepted(string tag) + { + var fetched = new YamlFrontmatterReader().Read("---\n" + tag + "\nname: alpha\ndescription: d\n---\n"); + + Assert.True(JsonNode.DeepEquals(new JsonObject { ["name"] = "alpha", ["description"] = "d" }, fetched), fetched.ToJsonString()); + } + + [Theory] + [InlineData("description: x{0}y")] + [InlineData("description: \"x{0}y\"")] + [InlineData("description: 'x{0}y'")] + [InlineData("description: |\n x{0}y")] + [InlineData("description: d\n# note{0}")] + [InlineData("description: d # note{0}")] + [InlineData("description: xy{0}\nlicense: MIT")] + public void LiteralNul_IsRejectedLikeTheServer(string entry) + { + AssertRejectedLikeTheServer(string.Format(entry, '\0'), 0x0000); + } + + [Theory] + [InlineData(0x000B)] + [InlineData(0x000C)] + [InlineData(0x001B)] + [InlineData(0x007F)] + [InlineData(0x0080)] + [InlineData(0xFFFE)] + [InlineData(0xFFFF)] + public void RawCharacterOutsideYamlsPrintableSet_IsRejectedLikeTheServer(int codePoint) + { + AssertRejectedLikeTheServer("description: x" + char.ConvertFromUtf32(codePoint) + "y", codePoint); + } + + [Theory] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x000D)] + [InlineData("description: d\n# note{0}allowed-tools: Bash", 0x2028)] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x0085)] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x2029)] + [InlineData("description: x{0}y", 0x0085)] + [InlineData("description: d{0}{0}", 0x000D)] + public void RawCharacterSomeParsersReadAsALineBreak_IsRejectedLikeTheServer(string entry, int codePoint) + { + AssertRejectedLikeTheServer(string.Format(entry, (char)codePoint), codePoint); + } + + [Theory] + [InlineData(new[] { 0xD800 }, false, 0xD800)] + [InlineData(new[] { 0xD800 }, true, 0xD800)] + [InlineData(new[] { 0xDC00 }, false, 0xDC00)] + [InlineData(new[] { 0xDC00, 0xD800 }, false, 0xDC00)] + public void UnpairedSurrogate_IsRejected(int[] codeUnits, bool atLineEnd, int expectedCodeUnit) + { + var markdown = "---\nname: alpha\ndescription: x" + new string(codeUnits.Select(codeUnit => (char)codeUnit).ToArray()) + (atLineEnd ? "" : "y") + "\n---\n"; + + var exception = Assert.Throws(() => new YamlFrontmatterReader().Read(markdown)); + + Assert.Contains($"U+{expectedCodeUnit:X4}", exception.Message); + } + + [Fact] + public void SecondByteOrderMark_IsRejectedLikeTheServer() + { + var markdown = "\uFEFF\uFEFF---\nname: alpha\ndescription: d\n---\n"; + + Assert.Throws(() => new YamlFrontmatterReader().Read(markdown)); + Assert.Throws(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)])); + } + + [Fact] + public void EscapedNul_MatchesTheServer() + { + var markdown = "---\nname: alpha\ndescription: \"x\\0y\"\n---\n"; + + var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + var fetched = new YamlFrontmatterReader().Read(markdown); + + Assert.Equal("x\0y", fetched["description"]!.GetValue()); + Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}."); + } + + [Theory] + [InlineData("\\r", 0x000D)] + [InlineData("\\N", 0x0085)] + [InlineData("\\L", 0x2028)] + [InlineData("\\P", 0x2029)] + public void EscapedCharacterSomeParsersReadAsALineBreak_MatchesTheServer(string escape, int codePoint) + { + var markdown = "---\nname: alpha\ndescription: \"x" + escape + "y\"\n---\n"; + + var served = McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)]).ProtocolSkill.Frontmatter; + var fetched = new YamlFrontmatterReader().Read(markdown); + + Assert.Equal("x" + char.ConvertFromUtf32(codePoint) + "y", fetched["description"]!.GetValue()); + Assert.True(JsonNode.DeepEquals(served, fetched), $"The server published {served.ToJsonString()}, the sample read {fetched.ToJsonString()}."); + } + + private void AssertRejectedLikeTheServer(string entry, int codePoint) + { + var markdown = $"---\nname: alpha\n{entry}\n---\n"; + + var sampleException = Record.Exception(() => new YamlFrontmatterReader().Read(markdown)); + var serverException = Record.Exception(() => McpServerSkill.Create([McpServerSkillFile.FromText("SKILL.md", markdown)])); + + string character = $"U+{codePoint:X4}"; + Assert.Contains(character, Assert.IsType(sampleException).Message); + Assert.Contains(character, Assert.IsType(serverException).Message); + } +} +#endif diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs index 18b4caae0..d5b6f9fa3 100644 --- a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs @@ -1,6 +1,9 @@ using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; +using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; namespace ModelContextProtocol.Tests.Server; @@ -12,8 +15,9 @@ 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 McpSkillRequestContext Context => new(new JsonRpcRequest { Method = SkillsProtocol.MethodSkillsList }); + // Static because the InvalidSkills member data, which xUnit requires to be static, builds its cases with it. private static Skill CreateSkill(string name, string? description = "A skill", SkillResources? resources = null) { string uri = $"skill://{name}/SKILL.md"; @@ -29,7 +33,7 @@ private static Skill CreateSkill(string name, string? description = "A skill", S }; } - private static InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => + private InMemoryMcpSkillCatalog CreateCatalog(int pageSize, params string[] names) => new([.. names.Select(name => CreateSkill(name))], pageSize); [Fact] @@ -107,13 +111,17 @@ public async Task ListAsync_WithEmptyCatalog_ReturnsEmptyPage() Assert.Null(page.NextCursor); } - [Fact] - public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams() + [Theory] + [InlineData("not-base64!!")] + [InlineData("/w==")] + [InlineData("wK8=")] + [InlineData("7aCA")] + public async Task ListAsync_WithMalformedCursor_ThrowsInvalidParams(string cursor) { var catalog = CreateCatalog(10, "a"); var exception = await Assert.ThrowsAsync( - async () => await catalog.ListAsync("not-base64!!", Context, TestContext.Current.CancellationToken)); + async () => await catalog.ListAsync(cursor, Context, TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.InvalidParams, exception.ErrorCode); } @@ -173,15 +181,125 @@ public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResp Assert.Equal(s_validDigest, again.Resources.Resources![0].Digest); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task EntryMetaIsOwnedAcrossConstructionListingAndLookup(bool isDynamic) + { + var source = CreateSkill("alpha", resources: isDynamic ? SkillResources.Dynamic : null); + source.Meta = JsonNode.Parse("""{"example.com/extension":{"items":[{"value":"original"}]},"empty":null}""")!.AsObject(); + var expected = source.Meta.DeepClone(); + var catalog = new InMemoryMcpSkillCatalog([source]); + source.Meta["example.com/extension"]!["items"]![0]!["value"] = "source changed"; + + var page = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + var listed = Assert.Single(page.Skills); + Assert.True(JsonNode.DeepEquals(expected, listed.Meta)); + listed.Meta!["example.com/extension"]!["items"]![0]!["value"] = "listed entry changed"; + + var fetched = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken); + Assert.NotNull(fetched); + Assert.True(JsonNode.DeepEquals(expected, fetched.Meta)); + fetched.Meta!["example.com/extension"]!["items"]!.AsArray().Clear(); + + var listedAgain = await catalog.ListAsync(null, Context, TestContext.Current.CancellationToken); + var fetchedAgain = await catalog.GetAsync(source.Uri, Context, TestContext.Current.CancellationToken); + Assert.True(JsonNode.DeepEquals(expected, Assert.Single(listedAgain.Skills).Meta)); + Assert.True(JsonNode.DeepEquals(expected, fetchedAgain!.Meta)); + } + [Theory] [InlineData("café")] [InlineData("数据分析")] [InlineData("données-2024")] + [InlineData("\U00010428")] + [InlineData("\U00020000")] public void Constructor_AcceptsUnicodeLowercaseNames(string name) { Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count); } + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("\U00010428")] + public void Constructor_AcceptsPercentEncodedUnicodeNameSegments(string name) + { + var skill = CreateSkill(name); + skill.Uri = $"skill://catalog/{Uri.EscapeDataString(name)}/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Theory] + [InlineData("caf%25C3%25A9")] + [InlineData("%2e%2e")] + [InlineData("ca%2ffé")] + [InlineData("ca%5cfé")] + public void Constructor_DoesNotDecodeNamesRepeatedlyOrAllowEncodedTraversal(string segment) + { + var skill = CreateSkill("café"); + skill.Uri = $"skill://catalog/{segment}/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + } + + [Theory] + [InlineData("\u01c5")] + [InlineData("\U00010400")] + public void Constructor_RejectsUppercaseOrTitlecaseNames(string name) + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(0xd801)] + [InlineData(0xdc28)] + public void Constructor_RejectsUnpairedSurrogates(int codeUnit) + { + var name = new string((char)codeUnit, 1); + + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(64, true)] + [InlineData(65, false)] + public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) + { + var name = string.Concat(Enumerable.Repeat("\U00010428", length)); + var skill = CreateSkill(name); + skill.Uri = $"skill://catalog/{name}/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 10 }]); + + if (valid) + { + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + else + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + } + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public void Constructor_RejectsNonStringMetadataValues(string value) + { + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) }; + + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", exception.Message); + } + [Fact] public void Constructor_WithDuplicateUris_Throws() { @@ -213,17 +331,101 @@ public void Constructor_AcceptsFullAgentSkillsFrontmatter() Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); } + [Theory] + [InlineData("description", 513)] + [InlineData("compatibility", 300)] + public void Constructor_AcceptsSupplementaryFrontmatterWithinTheLimit(string field, int scalarCount) + { + var skill = CreateSkill("alpha"); + skill.Frontmatter[field] = string.Concat(Enumerable.Repeat("\U0001f600", scalarCount)); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Theory] + [InlineData("description", 1024, false)] + [InlineData("description", 1024, true)] + [InlineData("compatibility", 500, false)] + [InlineData("compatibility", 500, true)] + public void Constructor_UsesScalarCountsAtFrontmatterBoundaries(string field, int limit, bool mixed) + { + var text = (mixed ? "a" : "\U0001f600") + + string.Concat(Enumerable.Repeat("\U0001f600", limit - 1)); + var skill = CreateSkill("alpha"); + skill.Frontmatter[field] = text; + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + + skill.Frontmatter[field] = text + "a"; + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Contains($"{limit + 1} characters", error.Message); + } + + [Theory] + [InlineData("description", 1024)] + [InlineData("compatibility", 500)] + public void Constructor_CountsCombiningMarksSeparately(string field, int limit) + { + var skill = CreateSkill("alpha"); + var text = string.Concat(Enumerable.Repeat("e\u0301", limit / 2)); + skill.Frontmatter[field] = text; + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + + skill.Frontmatter[field] = text + "a"; + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Contains($"{limit + 1} characters", error.Message); + } + + [Fact] + public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization() + { + var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter()); + var value = JsonValue.Create(1, typeInfo)!; + Assert.Equal(JsonValueKind.String, value.GetValueKind()); + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value }; + + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", error.Message); + } + + private sealed class ChangingMetadataConverter : JsonConverter + { + private int _writes; + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (++_writes == 1) + { + writer.WriteStringValue("initial-string"); + } + else + { + writer.WriteNumberValue(value); + } + } + } + [Fact] public void Constructor_PassesThroughFieldsHostsDoNotActOn() { - // 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. + // Unknown fields keep structured values. Fields the Agent Skills specification defines are type-checked, and the + // known metadata map has its own string-value requirement. var skill = CreateSkill("alpha"); - skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); - skill.Frontmatter["license"] = null; - skill.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; + skill.Frontmatter["allowed-tools"] = "Read Write"; + skill.Frontmatter["license"] = "Apache-2.0"; + skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + + skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Contains("'allowed-tools' frontmatter field that is not a string", exception.Message); } [Fact] @@ -256,6 +458,54 @@ public void Constructor_AcceptsNestedSkillPath() Assert.Equal(1, catalog.Count); } + [Theory] + [InlineData("repo:/skills/alpha/SKILL.md")] + [InlineData("repo:skills/alpha/SKILL.md")] + [InlineData("repo:alpha/SKILL.md")] + [InlineData("urn:alpha/SKILL.md")] + public void Constructor_AcceptsNativeAbsoluteResourceUris(string uri) + { + var skill = CreateSkill("alpha"); + skill.Uri = uri; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + + [Theory] + [InlineData("repo:/skills//alpha/SKILL.md")] + [InlineData("repo:skills/../alpha/SKILL.md")] + [InlineData("repo:skills/%2e%2e/alpha/SKILL.md")] + [InlineData("repo:skills/ca%2ffé/alpha/SKILL.md")] + [InlineData("repo:/skills/alpha/SKILL.md?query")] + [InlineData("repo:/skills/alpha/SKILL.md#fragment")] + public void Constructor_RejectsUnsafeNativeUriPaths(string uri) + { + var skill = CreateSkill("alpha"); + skill.Uri = uri; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }]); + + Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + } + + [Theory] + [InlineData("repo:/skills/alpha/SKILL.md")] + [InlineData("repo:alpha/SKILL.md")] + public void Constructor_RejectsTraversalInNativeManifestUris(string uri) + { + var skill = CreateSkill("alpha"); + skill.Uri = uri; + var root = uri.Substring(0, uri.Length - "/SKILL.md".Length); + skill.Resources = SkillResources.FromResources( + [ + new SkillResource { Uri = uri, Digest = s_validDigest, Size = 1 }, + new SkillResource { Uri = root + "/%2e%2e/secret.md", Digest = s_validDigest, Size = 1 }, + ]); + + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + Assert.Contains("encoded separator", error.Message); + } + public static IEnumerable InvalidSkills() { static object[] Case(string reason, Action mutate) @@ -307,6 +557,16 @@ static object[] Case(string reason, Action mutate) yield return Case("compatibility over 500 characters", s => s.Frontmatter["compatibility"] = new string('c', 501)); yield return Case("compatibility empty", s => s.Frontmatter["compatibility"] = ""); yield return Case("metadata not a mapping", s => s.Frontmatter["metadata"] = "x"); + yield return Case("allowed-tools as a list", s => s.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write")); + yield return Case("allowed-tools as a number", s => s.Frontmatter["allowed-tools"] = 1); + yield return Case("allowed-tools as an object", s => s.Frontmatter["allowed-tools"] = new JsonObject { ["Read"] = true }); + yield return Case("allowed-tools as a boolean", s => s.Frontmatter["allowed-tools"] = true); + yield return Case("allowed-tools present but null", s => s.Frontmatter["allowed-tools"] = null); + yield return Case("license as a list", s => s.Frontmatter["license"] = new JsonArray("MIT")); + yield return Case("license as a number", s => s.Frontmatter["license"] = 2); + yield return Case("license as an object", s => s.Frontmatter["license"] = new JsonObject { ["name"] = "MIT" }); + yield return Case("license as a boolean", s => s.Frontmatter["license"] = false); + yield return Case("license present but null", s => s.Frontmatter["license"] = null); 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/InjectedParameterSchemaTests.cs b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs new file mode 100644 index 000000000..c1304fb77 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.cs @@ -0,0 +1,191 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Moq; +using System.Globalization; +using System.Reflection; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Tests.Server; + +public partial class InjectedParameterSchemaTests +{ + private RequestContext? _toolRequest; + private RequestContext? _promptRequest; + private InjectedService? _service; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Tool_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var method = GetMethod(nameof(EchoTool), freshParameters); + var tool = McpServerTool.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options }); + var properties = tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray(); + Assert.Equal(["value"], properties); + + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("tool"), Method = RequestMethods.ToolsCall }, + new() + { + Name = tool.ProtocolTool.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }); + var result = await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + + Assert.Same(request, _toolRequest); + Assert.Equal("hello", Assert.IsType(Assert.Single(result.Content)).Text); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Prompt_ContextRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var method = GetMethod(nameof(EchoPrompt), freshParameters); + var prompt = McpServerPrompt.Create(method, this, new() { SerializerOptions = WireJsonContext.Default.Options }); + Assert.Equal(["value"], Assert.IsAssignableFrom>(prompt.ProtocolPrompt.Arguments).Select(argument => argument.Name).ToArray()); + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("prompt"), Method = RequestMethods.PromptsGet }, + new() + { + Name = prompt.ProtocolPrompt.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }); + + await prompt.GetAsync(request, TestContext.Current.CancellationToken); + + Assert.Same(request, _promptRequest); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Tool_RegisteredServiceRemainsInjectedWhenParametersAreReenumerated(bool freshParameters) + { + var expected = new InjectedService(); + await using var services = new ServiceCollection().AddSingleton(expected).BuildServiceProvider(); + var tool = McpServerTool.Create(GetMethod(nameof(EchoService), freshParameters), this, + new() { Services = services, SerializerOptions = WireJsonContext.Default.Options }); + Assert.Equal(["value"], tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(property => property.Name).ToArray()); + var request = new RequestContext( + new Mock().Object, + new JsonRpcRequest { Id = new RequestId("service"), Method = RequestMethods.ToolsCall }, + new() + { + Name = tool.ProtocolTool.Name, + Arguments = new Dictionary + { + ["value"] = JsonSerializer.SerializeToElement("hello", WireJsonContext.Default.String), + }, + }) + { + Services = services, + }; + + await tool.InvokeAsync(request, TestContext.Current.CancellationToken); + Assert.Same(expected, _service); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Tool_CustomSchemaFilterIsPreservedWithoutMutatingOptions(bool freshParameters) + { + var schemaOptions = new AIJsonSchemaCreateOptions { IncludeParameter = parameter => parameter.Name != "value" }; + var filter = schemaOptions.IncludeParameter; + var tool = McpServerTool.Create(GetMethod(nameof(EchoTool), freshParameters), this, new() + { + SerializerOptions = WireJsonContext.Default.Options, + SchemaCreateOptions = schemaOptions, + }); + + Assert.Empty(tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject()); + Assert.Same(filter, schemaOptions.IncludeParameter); + } + + private string EchoTool(string value, RequestContext context) + { + _toolRequest = context; + return value; + } + + private string EchoPrompt(string value, RequestContext context) + { + _promptRequest = context; + return value; + } + + private string EchoService(string value, InjectedService service) + { + _service = service; + return value; + } + + private sealed class InjectedService; + + private MethodInfo GetMethod(string name, bool freshParameters) + { + var method = Assert.IsAssignableFrom(GetType().GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic)); + return freshParameters ? new ReenumeratingMethodInfo(method) : method; + } + + // ParameterInfo identity is not part of the method signature. Return equivalent but distinct + // instances to deterministically exercise separate binding and schema-enumeration passes. + private sealed class ReenumeratingMethodInfo(MethodInfo method) : MethodInfo + { + public override string Name => method.Name; + public override Type? DeclaringType => method.DeclaringType; + public override Type? ReflectedType => method.ReflectedType; + public override Module Module => method.Module; + public override int MetadataToken => method.MetadataToken; + public override MethodAttributes Attributes => method.Attributes; + public override RuntimeMethodHandle MethodHandle => method.MethodHandle; + public override Type ReturnType => method.ReturnType; + public override ParameterInfo ReturnParameter => method.ReturnParameter; + public override ICustomAttributeProvider ReturnTypeCustomAttributes => method.ReturnTypeCustomAttributes; + public override bool ContainsGenericParameters => method.ContainsGenericParameters; + public override bool IsGenericMethod => method.IsGenericMethod; + public override bool IsGenericMethodDefinition => method.IsGenericMethodDefinition; + public override MethodInfo GetBaseDefinition() => method.GetBaseDefinition(); + public override MethodImplAttributes GetMethodImplementationFlags() => method.GetMethodImplementationFlags(); + public override Type[] GetGenericArguments() => method.GetGenericArguments(); + public override object[] GetCustomAttributes(bool inherit) => method.GetCustomAttributes(inherit); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => method.GetCustomAttributes(attributeType, inherit); + public override IList GetCustomAttributesData() => method.GetCustomAttributesData(); + public override bool IsDefined(Type attributeType, bool inherit) => method.IsDefined(attributeType, inherit); + public override object? Invoke(object? obj, BindingFlags invokeAttr, Binder? binder, object?[]? parameters, CultureInfo? culture) => + method.Invoke(obj, invokeAttr, binder, parameters, culture); + public override ParameterInfo[] GetParameters() => + Array.ConvertAll(method.GetParameters(), parameter => new ReenumeratedParameter(parameter)); + } + + private sealed class ReenumeratedParameter(ParameterInfo parameter) : ParameterInfo + { + public override string? Name => parameter.Name; + public override Type ParameterType => parameter.ParameterType; + public override int Position => parameter.Position; + public override MemberInfo Member => parameter.Member; + public override ParameterAttributes Attributes => parameter.Attributes; + public override object? DefaultValue => parameter.DefaultValue; + public override object? RawDefaultValue => parameter.RawDefaultValue; + public override bool HasDefaultValue => parameter.HasDefaultValue; + public override object[] GetCustomAttributes(bool inherit) => parameter.GetCustomAttributes(inherit); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) => parameter.GetCustomAttributes(attributeType, inherit); + public override IList GetCustomAttributesData() => parameter.GetCustomAttributesData(); + public override bool IsDefined(Type attributeType, bool inherit) => parameter.IsDefined(attributeType, inherit); + } + + [JsonSerializable(typeof(string))] + private partial class WireJsonContext : JsonSerializerContext; +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs new file mode 100644 index 000000000..6c567f395 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerNativeUriSkillTests.cs @@ -0,0 +1,46 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerNativeUriSkillTests(ITestOutputHelper output) : ClientServerTestBase(output) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder.WithSkills( + new[] + { + (Uri: "repo:/skills/alpha/SKILL.md", Name: "alpha"), + (Uri: "repo:skills/alpha/SKILL.md", Name: "alpha"), + (Uri: "repo:alpha/SKILL.md", Name: "alpha"), + (Uri: "urn:alpha/SKILL.md", Name: "alpha"), + (Uri: "repo:/skills/caf%C3%A9/SKILL.md", Name: "café"), + (Uri: "repo:skills/caf%C3%A9/SKILL.md", Name: "café"), + }.Select(entry => McpServerSkill.Create( + entry.Uri, [McpServerSkillFile.FromText("SKILL.md", $"---\nname: {entry.Name}\ndescription: Native resource\n---\n")]))); + } + + [Theory] + [InlineData("repo:/skills/alpha/SKILL.md")] + [InlineData("repo:skills/alpha/SKILL.md")] + [InlineData("repo:alpha/SKILL.md")] + [InlineData("urn:alpha/SKILL.md")] + [InlineData("repo:/skills/caf%C3%A9/SKILL.md")] + [InlineData("repo:skills/caf%C3%A9/SKILL.md")] + public async Task NativeResourceUrisListGetAndVerifyWithoutRewritingTheirIdentity(string uri) + { + await using var client = await CreateMcpClientForServer(); + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var fetched = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(6, skills.Count); + Assert.Equal(uri, fetched.Uri); + Assert.Contains(resources, resource => resource.Uri == uri); + var read = await client.ReadSkillResourceAsync(fetched, uri, TestContext.Current.CancellationToken); + Assert.Equal(uri, Assert.Single(read.Contents).Uri); + Assert.IsType(read.Contents[0]); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs new file mode 100644 index 000000000..40db64be3 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerNestedUnicodeSkillTests.cs @@ -0,0 +1,58 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerNestedUnicodeSkillTests(ITestOutputHelper output) : ClientServerTestBase(output) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + var files = new List + { + McpServerSkillFile.FromText("SKILL.md", "---\nname: outer\ndescription: Parent skill\n---\n"), + }; + var skills = new List(); + foreach (var name in new[] { "café", "数据分析", "\U00010428" }) + { + var markdown = $"---\nname: {name}\ndescription: Nested skill\n---\n"; + files.Add(McpServerSkillFile.FromText($"{name}/SKILL.md", markdown)); + skills.Add(McpServerSkill.Create( + $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md", + [McpServerSkillFile.FromText("SKILL.md", markdown)])); + } + skills.Add(McpServerSkill.Create("skill://outer/SKILL.md", files)); + mcpServerBuilder.WithSkills(skills); + } + + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("\U00010428")] + public async Task NestedUnicodeSkillsListGetAndShareVerifiedResources(string name) + { + await using var client = await CreateMcpClientForServer(); + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + var uri = $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md"; + + Assert.Equal(4, skills.Count); + Assert.Equal(4, resources.Count); + Assert.Contains(skills, skill => skill.Uri == uri); + Assert.Contains(resources, resource => resource.Uri == uri); + + var inner = await client.GetSkillAsync(uri, TestContext.Current.CancellationToken); + var outer = await client.GetSkillAsync("skill://outer/SKILL.md", TestContext.Current.CancellationToken); + Assert.Equal(name, inner.Name); + Assert.Contains(outer.Resources.Resources!, resource => resource.Uri == uri); + + var innerRead = await client.ReadSkillResourceAsync(inner, uri, TestContext.Current.CancellationToken); + var outerRead = await client.ReadSkillResourceAsync(outer, uri, TestContext.Current.CancellationToken); + var innerText = Assert.IsType(Assert.Single(innerRead.Contents)); + var outerText = Assert.IsType(Assert.Single(outerRead.Contents)); + Assert.Equal(uri, innerText.Uri); + Assert.Equal(innerText.Text, outerText.Text); + Assert.Equal(name, SkillFrontmatter.Parse(innerText.Text!)["name"]!.GetValue()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs index 54cf43dab..50f0970e7 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillAuthoringBoundaryTests.cs @@ -7,6 +7,48 @@ public class McpServerSkillAuthoringBoundaryTests { private const string SkillUri = "skill://owned/SKILL.md"; + [Theory] + [InlineData("description", 1024, false)] + [InlineData("description", 1024, true)] + [InlineData("compatibility", 500, false)] + [InlineData("compatibility", 500, true)] + public void AuthoringCountsSupplementaryFrontmatterByScalar(string field, int limit, bool overLimit) + { + int length = limit + (overLimit ? 1 : 0); + var text = string.Concat(Enumerable.Repeat("\U0001f600", length)); + var description = field == "description" ? text : "d"; + var compatibility = field == "compatibility" ? $"compatibility: {text}\n" : ""; + var markdown = $"---\nname: owned\ndescription: {description}\n{compatibility}---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + if (overLimit) + { + var error = Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Contains($"{length} characters", error.Message); + Assert.Throws(() => McpServerSkill.Create(SkillUri, files)); + } + else + { + Assert.Equal(text, McpServerSkill.Create(files).ProtocolSkill.Frontmatter[field]!.GetValue()); + Assert.Equal(text, McpServerSkill.Create(SkillUri, files).ProtocolSkill.Frontmatter[field]!.GetValue()); + } + + WithDirectory(markdown, directory => + { + if (overLimit) + { + var error = Assert.Throws(() => McpServerSkill.CreateFromDirectory(directory)); + Assert.Contains($"{length} characters", error.Message); + Assert.Throws(() => McpServerSkill.CreateFromDirectory(SkillUri, directory)); + } + else + { + Assert.Equal(text, McpServerSkill.CreateFromDirectory(directory).ProtocolSkill.Frontmatter[field]!.GetValue()); + Assert.Equal(text, McpServerSkill.CreateFromDirectory(SkillUri, directory).ProtocolSkill.Frontmatter[field]!.GetValue()); + } + }); + } + [Theory] [InlineData("---\nname: &name owned\ndescription: d\n---\n")] [InlineData("---\nname: owned\ndescription: d\nextra: [never\n---\n")] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs index 1bb510e26..f7cb0a248 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillTests.cs @@ -13,7 +13,7 @@ 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() + private JsonObject Frontmatter(string name = "git-workflow", string? description = "Git conventions") => new() { ["name"] = name, ["description"] = description, @@ -230,11 +230,31 @@ public void Create_WithExplicitFrontmatter_AcceptsMatchAndUnreadableFile() Assert.Equal("Git conventions", multiLine.ProtocolSkill.Description); // So is a flow collection that closes on a later line. - var explicitTools = Frontmatter(); - explicitTools["allowed-tools"] = new JsonArray("Read", "Write"); - var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTools, - [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\nallowed-tools: [Read,\n Write]\n---\n")]); - Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["allowed-tools"]!.AsArray().Count); + var explicitTags = Frontmatter(); + explicitTags["tags"] = new JsonArray("Read", "Write"); + var multiLineFlow = McpServerSkill.Create(SkillUri, explicitTags, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\ntags: [Read,\n Write]\n---\n")]); + Assert.Equal(2, multiLineFlow.ProtocolSkill.Frontmatter["tags"]!.AsArray().Count); + } + + [Theory] + [InlineData("allowed-tools", "\n - Read\n - Write")] + [InlineData("allowed-tools", " [Read, Write]")] + [InlineData("allowed-tools", " 1")] + [InlineData("allowed-tools", "\n Read: true")] + [InlineData("allowed-tools", " true")] + [InlineData("allowed-tools", "")] + [InlineData("license", " [MIT]")] + [InlineData("license", " 2")] + [InlineData("license", "\n name: MIT")] + [InlineData("license", " false")] + [InlineData("license", "")] + public void Create_RejectsSpecFieldsThatAreNotStrings(string field, string yamlValue) + { + var exception = Assert.Throws(() => McpServerSkill.Create( + [McpServerSkillFile.FromText("SKILL.md", "---\nname: git-workflow\ndescription: Git conventions\n" + field + ":" + yamlValue + "\n---\n")])); + + Assert.Contains($"'{field}' frontmatter field that is not a string", exception.Message); } [Theory] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index f3ae48a55..92dea3a77 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -66,7 +66,7 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer .WithResources([McpServerResource.Create(() => "the content the server actually serves", new() { UriTemplate = TamperedUri })]); } - private static Skill CreateEntry(string uri, string name, byte[]? content = null) + private Skill CreateEntry(string uri, string name, byte[]? content = null) { content ??= Encoding.UTF8.GetBytes($"---\nname: {name}\n---\n"); return new Skill @@ -139,6 +139,19 @@ public async Task SkillsGet_WithInvalidCatalogEntry_ReturnsInternalError() Assert.Contains("invalid entry", exception.Message); } + [Fact] + public async Task SkillsList_WithInvalidCustomCatalogEntry_ReturnsInternalError() + { + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync( + new ListSkillsRequestParams { Cursor = "broken-entry" }, TestContext.Current.CancellationToken)); + + Assert.Equal(McpErrorCode.InternalError, error.ErrorCode); + Assert.Contains("invalid entry", error.Message); + } + [Fact] public async Task DynamicSkill_IsListedWithTheDynamicMarker() { @@ -269,6 +282,10 @@ private sealed class PartialCatalog(InMemoryMcpSkillCatalog listed, Skill unlist public ValueTask ListAsync(string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) { AssertContext(context, SkillsProtocol.MethodSkillsList); + if (string.Equals(cursor, "broken-entry", StringComparison.Ordinal)) + { + return new ValueTask(new McpSkillPage { Skills = [broken] }); + } return listed.ListAsync(cursor, context, cancellationToken); } @@ -295,7 +312,7 @@ public ValueTask ListAsync(string? cursor, McpSkillRequestContext } // 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) + private void AssertContext(McpSkillRequestContext context, string method) { Assert.Equal(method, context.JsonRpcRequest.Method); Assert.Null(context.User); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs index 7919fe04f..f141950a1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs @@ -21,6 +21,9 @@ public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) 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"); + File.WriteAllText(Path.Combine(_root, "git-workflow", ".env"), "synthetic-private-value"); + Directory.CreateDirectory(Path.Combine(_root, "git-workflow", ".git")); + File.WriteAllText(Path.Combine(_root, "git-workflow", ".git", "config"), "synthetic-config"); 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"); @@ -31,6 +34,8 @@ public McpServerSkillsFromDirectoryTests(ITestOutputHelper testOutputHelper) // A file at the root is ignored too. File.WriteAllText(Path.Combine(_root, "README.md"), "about these skills"); + Directory.CreateDirectory(Path.Combine(_root, ".private-skill")); + File.WriteAllText(Path.Combine(_root, ".private-skill", "SKILL.md"), "---\nname: private-skill\ndescription: d\n---\n"); McpServerBuilder.WithSkillsFromDirectory(_root, uriPrefix: "skill://acme/billing"); StartServer(); @@ -68,6 +73,31 @@ public async Task ServedFiles_VerifyAgainstTheirManifest() } } + [Fact] + public async Task HiddenEntriesAreNeitherListedNorReadable() + { + await using var client = await CreateMcpClientForServer(); + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(skills, skill => skill.Name == "private-skill"); + Assert.DoesNotContain(skills.SelectMany(skill => skill.Resources.Resources!), resource => resource.Uri.Contains("/.")); + + await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( + "skill://acme/billing/git-workflow/.env", cancellationToken: TestContext.Current.CancellationToken)); + } + + [Fact] + public void BuilderCanIncludeHiddenEntriesExplicitly() + { + var services = new ServiceCollection(); + services.AddMcpServer().WithSkillsFromDirectory(_root, includeHiddenAndSystemFiles: true); + using var provider = services.BuildServiceProvider(); + var resources = provider.GetServices().Select(resource => resource.ProtocolResource!.Uri).ToList(); + + Assert.Contains("skill://private-skill/SKILL.md", resources); + Assert.Contains("skill://git-workflow/.env", resources); + Assert.Contains("skill://git-workflow/.git/config", resources); + } + [Fact] public void WithSkills_DetectsFilesThatCollideUnderUriEquivalence() { @@ -96,6 +126,52 @@ public void WithSkills_DetectsFilesThatCollideUnderUriEquivalence() new ServiceCollection().AddMcpServer().WithSkills([outer, inner]); } + [Theory] + [InlineData("café")] + [InlineData("数据分析")] + [InlineData("\U00010428")] + public void WithSkills_RegistersNestedUnicodeSkillsWithIdenticalSharedUris(string name) + { + var innerMarkdown = $"---\nname: {name}\ndescription: nested\n---\n"; + var outer = McpServerSkill.Create("skill://outer/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: outer\ndescription: parent\n---\n"), + McpServerSkillFile.FromText($"{name}/SKILL.md", innerMarkdown), + ]); + var uri = $"skill://outer/{Uri.EscapeDataString(name)}/SKILL.md"; + var inner = McpServerSkill.Create(uri, [McpServerSkillFile.FromText("SKILL.md", innerMarkdown)]); + var services = new ServiceCollection(); + services.AddMcpServer().WithSkills([outer, inner]); + using var provider = services.BuildServiceProvider(); + var resources = provider.GetServices().Select(resource => resource.ProtocolResource!.Uri).ToArray(); + + Assert.Equal(2, resources.Length); + Assert.Contains(uri, resources); + Assert.Equal(uri, inner.ProtocolSkill.Uri); + Assert.Contains(outer.ProtocolSkill.Resources.Resources!, resource => resource.Uri == uri); + } + + [Theory] + [InlineData("café", "skill://outer/")] + [InlineData("数据分析", "skill://outer/")] + [InlineData("\U00010428", "skill://outer/")] + [InlineData("café", "repo:/skills/")] + [InlineData("数据分析", "repo:skills/")] + [InlineData("\U00010428", "repo:/skills/")] + public void WithSkillsFromDirectory_EscapesUnicodeNamePathSegments(string name, string prefix) + { + var root = Path.Combine(_root, "unicode-root"); + var directory = Path.Combine(root, name); + Directory.CreateDirectory(directory); + File.WriteAllText(Path.Combine(directory, "SKILL.md"), $"---\nname: {name}\ndescription: d\n---\n"); + var services = new ServiceCollection(); + services.AddMcpServer().WithSkillsFromDirectory(root, uriPrefix: prefix); + using var provider = services.BuildServiceProvider(); + + var resource = Assert.Single(provider.GetServices()); + Assert.Equal($"{prefix}{Uri.EscapeDataString(name)}/SKILL.md", resource.ProtocolResource!.Uri); + } + [Fact] public void WithSkillsFromDirectory_RejectsDirectoriesWithoutSkills() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs new file mode 100644 index 000000000..46f0e2962 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsRegistrationTests.cs @@ -0,0 +1,232 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests that a server has one Skills registration, rejected early when repeated through any public method, and +/// that each Skills file is the only resource at its URI. +/// +public sealed class McpServerSkillsRegistrationTests : IDisposable +{ + private const string GitWorkflowUri = "skill://git-workflow/SKILL.md"; + private const string GitWorkflowMarkdown = "---\nname: git-workflow\ndescription: Git conventions\n---\n# Git\n"; + + private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-skills-registration-" + Guid.NewGuid().ToString("N")); + + public McpServerSkillsRegistrationTests() + { + Directory.CreateDirectory(Path.Combine(_root, "git-workflow")); + File.WriteAllText(Path.Combine(_root, "git-workflow", "SKILL.md"), GitWorkflowMarkdown); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Theory] + [InlineData("skills")] + [InlineData("directory")] + [InlineData("directory-with-hidden-files")] + [InlineData("catalog")] + public void SecondRegistrationOnTheSameBuilder_ThrowsBeforeRegisteringAnything(string method) + { + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + builder.WithSkills([CreateGitWorkflow()]); + int registrationCount = services.Count; + + var exception = Assert.Throws(() => Register(builder, method)); + + Assert.Contains("Skills are already registered", exception.Message); + Assert.Equal(registrationCount, services.Count); + } + + [Theory] + [InlineData("skills")] + [InlineData("directory")] + [InlineData("directory-with-hidden-files")] + [InlineData("catalog")] + public void SecondRegistrationThroughAnotherBuilderForTheSameServices_Throws(string method) + { + // For example a library and the application each enabling Skills on the same service collection. + var services = new ServiceCollection(); + Register(services.AddMcpServer(), method); + var secondBuilder = services.AddMcpServer(); + int registrationCount = services.Count; + + var exception = Assert.Throws(() => secondBuilder.WithSkillsFromDirectory(_root)); + + Assert.Contains("Skills are already registered", exception.Message); + Assert.Equal(registrationCount, services.Count); + } + + [Theory] + [InlineData("skills")] + [InlineData("directory")] + [InlineData("directory-with-hidden-files")] + [InlineData("catalog")] + public void SingleRegistration_InstallsEachSkillsMethodOnce(string method) + { + var services = new ServiceCollection(); + Register(services.AddMcpServer(), method); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + +#pragma warning disable MCPEXP002 // RequestHandlers is experimental. + Assert.Single(options.RequestHandlers!, handler => handler.Method == "skills/list"); + Assert.Single(options.RequestHandlers!, handler => handler.Method == "skills/get"); +#pragma warning restore MCPEXP002 + } + + [Fact] + public void FailedRegistration_DoesNotBlockALaterOne() + { + var empty = Path.Combine(_root, "not-a-skill"); + Directory.CreateDirectory(empty); + var services = new ServiceCollection(); + + Assert.Throws(() => services.AddMcpServer().WithSkillsFromDirectory(empty)); + services.AddMcpServer().WithSkillsFromDirectory(_root); + } + + [Theory] + [InlineData("conflicting-file", typeof(ArgumentException))] + [InlineData("duplicate-skill", typeof(ArgumentException))] + [InlineData("configure", typeof(InvalidOperationException))] + public void FailedRegistration_LeavesNoResources_AndARetryWithNewSkillsSucceeds(string failure, Type expectedException) + { + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + int registrationCount = services.Count; + + Assert.Throws(expectedException, () => RegisterFailing(builder, failure)); + Assert.Equal(registrationCount, services.Count); + + // New skill instances: a resource left behind by the failed call would conflict with them. + builder.WithSkills([CreateGitWorkflow()]); + using var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().Value; + + Assert.Equal([GitWorkflowUri], options.ResourceCollection!.PrimitiveNames); + } + + [Theory] + [InlineData(true, GitWorkflowUri)] + [InlineData(false, GitWorkflowUri)] + [InlineData(true, "skill://GIT-WORKFLOW/SKILL.md")] + [InlineData(false, "SKILL://git-workflow/SKILL.md")] + public void ApplicationResourceAtASkillFileUri_FailsWhenTheOptionsAreBuilt(bool applicationResourceFirst, string applicationUri) + { + // The reported shape: an application resource whose parameterless template is a Skills file's URI. + var applicationResource = McpServerResource.Create( + () => "application content", + new McpServerResourceCreateOptions { UriTemplate = applicationUri }); + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + if (applicationResourceFirst) + { + builder.WithResources([applicationResource]).WithSkillsFromDirectory(_root); + } + else + { + builder.WithSkillsFromDirectory(_root).WithResources([applicationResource]); + } + + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => provider.GetRequiredService>().Value); + Assert.Contains(GitWorkflowUri, exception.Message); + Assert.Contains(applicationUri, exception.Message); + } + + [Fact] + public void ResourceAddedDirectlyToTheOptions_ConflictsLikeARegisteredOne() + { + var applicationResource = McpServerResource.Create( + () => "application content", + new McpServerResourceCreateOptions { UriTemplate = GitWorkflowUri }); + var services = new ServiceCollection(); + services.AddMcpServer(options => options.ResourceCollection = [applicationResource]) + .WithSkills([CreateGitWorkflow()]); + using var provider = services.BuildServiceProvider(); + + var exception = Assert.Throws(() => provider.GetRequiredService>().Value); + Assert.Contains(GitWorkflowUri, exception.Message); + } + + [Fact] + public void SharedNestedFiles_TheSkillsOwnResources_AndOtherUris_DoNotConflict() + { + const string InnerMarkdown = "---\nname: inner\ndescription: nested\n---\n"; + var outer = McpServerSkill.Create("skill://acme/SKILL.md", + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: acme\ndescription: outer\n---\n"), + McpServerSkillFile.FromText("inner/SKILL.md", InnerMarkdown), + ]); + var inner = McpServerSkill.Create("skill://acme/inner/SKILL.md", [McpServerSkillFile.FromText("SKILL.md", InnerMarkdown)]); + var services = new ServiceCollection(); + services.AddMcpServer() + .WithSkills([outer, inner]) + .WithResources(outer.Resources) + .WithResources( + [ + McpServerResource.Create(() => "a different path", new McpServerResourceCreateOptions { UriTemplate = "skill://acme/skill.md" }), + McpServerResource.Create((string name) => name, new McpServerResourceCreateOptions { UriTemplate = "skill://{name}/SKILL.md" }), + ]); + using var provider = services.BuildServiceProvider(); + + var options = provider.GetRequiredService>().Value; + + Assert.Equal( + ["skill://acme/SKILL.md", "skill://acme/inner/SKILL.md", "skill://acme/skill.md", "skill://{name}/SKILL.md"], + options.ResourceCollection!.PrimitiveNames); + Assert.Same(outer.Resources[1], options.ResourceCollection["skill://acme/inner/SKILL.md"]); + } + + private void Register(IMcpServerBuilder builder, string method) + { + switch (method) + { + case "skills": + builder.WithSkills([CreateGitWorkflow()]); + break; + case "directory": + builder.WithSkillsFromDirectory(_root); + break; + case "directory-with-hidden-files": + builder.WithSkillsFromDirectory(_root, includeHiddenAndSystemFiles: true); + break; + case "catalog": + builder.WithSkills(new InMemoryMcpSkillCatalog([CreateGitWorkflow().ProtocolSkill])); + break; + default: + throw new ArgumentOutOfRangeException(nameof(method), method, "Unknown registration method."); + } + } + + private void RegisterFailing(IMcpServerBuilder builder, string failure) + { + switch (failure) + { + case "conflicting-file": + // The second skill lists the first one's file with different content. + var changed = McpServerSkill.Create(GitWorkflowUri, [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown + "Changed.\n")]); + builder.WithSkills([CreateGitWorkflow(), changed]); + break; + case "duplicate-skill": + // Identical files are shared, so this fails later, when the catalog sees the same skill URI twice. + builder.WithSkills([CreateGitWorkflow(), CreateGitWorkflow()]); + break; + case "configure": + builder.WithSkills([CreateGitWorkflow()], _ => throw new InvalidOperationException("The configuration failed.")); + break; + default: + throw new ArgumentOutOfRangeException(nameof(failure), failure, "Unknown failure."); + } + } + + private McpServerSkill CreateGitWorkflow() => + McpServerSkill.Create(GitWorkflowUri, [McpServerSkillFile.FromText("SKILL.md", GitWorkflowMarkdown)]); +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs new file mode 100644 index 000000000..62c960662 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpSkillDirectorySelectionTests.cs @@ -0,0 +1,107 @@ +using ModelContextProtocol.Extensions.Skills; +using System.Runtime.InteropServices; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpSkillDirectorySelectionTests : IDisposable +{ + private const string SkillUri = "skill://selected/SKILL.md"; + private const string Markdown = "---\nname: selected\ndescription: d\n---\n"; + private readonly string _root = Path.Combine(Path.GetTempPath(), "mcp-selection-" + Guid.NewGuid().ToString("N")); + + public McpSkillDirectorySelectionTests() + { + Directory.CreateDirectory(Path.Combine(_root, ".git")); + File.WriteAllText(Path.Combine(_root, "SKILL.md"), Markdown); + File.WriteAllText(Path.Combine(_root, "README.md"), "visible"); + File.WriteAllText(Path.Combine(_root, ".env"), "synthetic-private-value"); + File.WriteAllText(Path.Combine(_root, ".git", "config"), "synthetic-config"); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void ExistingDirectoryOverloadsExcludeDotEntries(int overload) + { + using var held = new FileStream(Path.Combine(_root, ".git", "config"), FileMode.Open, FileAccess.Read, FileShare.None); + + var skill = Create(overload, includeHidden: false); + + Assert.Equal(2, skill.Resources.Count); + Assert.DoesNotContain(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.Contains("/.")); + Assert.Equal( + skill.ProtocolSkill.Resources.Resources!.Select(resource => resource.Uri), + skill.Resources.Select(resource => resource.ProtocolResource!.Uri)); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void HiddenEntriesCanBeIncludedExplicitly(int overload) + { + var skill = Create(overload, includeHidden: true); + + Assert.Equal(4, skill.Resources.Count); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.git/config", StringComparison.Ordinal)); + } + + [Theory] + [InlineData(FileAttributes.Hidden)] + [InlineData(FileAttributes.System)] + public void AttributeMarkedFilesAreExcludedUnlessExplicitlyIncluded(FileAttributes attribute) + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Windows file attribute semantics."); + var path = Path.Combine(_root, "marked.txt"); + File.WriteAllText(path, "synthetic"); + File.SetAttributes(path, File.GetAttributes(path) | attribute); + + Assert.DoesNotContain(Create(0, false).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + Assert.Contains(Create(0, true).Resources, resource => resource.ProtocolResource!.Uri.EndsWith("/marked.txt", StringComparison.Ordinal)); + } + + [Fact] + public void ExcludedFilesAreNotReadOrCountedAgainstThePublishedSizeLimit() + { + using (var file = new FileStream(Path.Combine(_root, ".large-cache"), FileMode.Create)) + { + file.SetLength(SkillsProtocol.MaxTotalSizeBytes + 1); + } + + Assert.Equal(2, Create(0, false).Resources.Count); + Assert.Throws(() => Create(0, true)); + } + + [Fact] + public void ExplicitFileAuthoringRetainsTheCallersFileSelection() + { + var skill = McpServerSkill.Create( + [ + McpServerSkillFile.FromText("SKILL.md", Markdown), + McpServerSkillFile.FromText(".env", "synthetic-explicit-value"), + ]); + + Assert.Equal(2, skill.Resources.Count); + Assert.Contains(skill.ProtocolSkill.Resources.Resources!, resource => resource.Uri.EndsWith("/.env", StringComparison.Ordinal)); + } + + private McpServerSkill Create(int overload, bool includeHidden) + { + var metadata = new JsonObject { ["name"] = "selected", ["description"] = "d" }; + return (overload, includeHidden) switch + { + (0, false) => McpServerSkill.CreateFromDirectory(_root), + (1, false) => McpServerSkill.CreateFromDirectory(SkillUri, _root), + (2, false) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root), + (0, true) => McpServerSkill.CreateFromDirectory(_root, includeHiddenAndSystemFiles: true), + (1, true) => McpServerSkill.CreateFromDirectory(SkillUri, _root, includeHiddenAndSystemFiles: true), + (2, true) => McpServerSkill.CreateFromDirectory(SkillUri, metadata, _root, includeHiddenAndSystemFiles: true), + _ => throw new ArgumentOutOfRangeException(nameof(overload)), + }; + } + + public void Dispose() => Directory.Delete(_root, recursive: true); +} diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs index 608af911d..660109598 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterNodeClassificationTests.cs @@ -32,7 +32,7 @@ public void FlowCollectionKeys_KeepTheExplicitFrontmatterFallback(string body, s } [Theory] - [InlineData("metadata:\n {version: 1}", """{"metadata":{"version":1}}""")] + [InlineData("extra:\n {version: 1}", """{"extra":{"version":1}}""")] [InlineData("tags:\n [read]", """{"tags":["read"]}""")] [InlineData("metadata:\n \"note\": value", """{"metadata":{"note":"value"}}""")] [InlineData("extra:\n [\"a]: b\"]", """{"extra":["a]: b"]}""")] @@ -87,6 +87,326 @@ public void MalformedFlowValueSuffixes_DoNotBecomeUnsupportedKeys(string body, s Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); } + [Fact] + public void ExplicitFlowKeyInTags_IsUnsupportedAndKeepsTheExplicitFrontmatterFallback() + { + // The reported case: valid YAML (a single-pair mapping with an explicit key) that the reader does not support. + var markdown = CreateMarkdown("tags: [? a]"); + var supplied = new JsonObject + { + ["name"] = "alpha", + ["description"] = "d", + ["tags"] = JsonNode.Parse("""[{"a":null}]"""), + }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); + var skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.Contains("explicit mapping keys", parseException!.Message); + Assert.Contains("does not support", automaticException.Message); + Assert.Contains("explicit mapping keys", automaticException.Message); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("extra: [? a, b]", """[{"a":null},"b"]""", "explicit mapping keys")] + [InlineData("extra: [x, ? a]", """["x",{"a":null}]""", "explicit mapping keys")] + [InlineData("extra: [? ]", """[{"":null}]""", "explicit mapping keys")] + [InlineData("extra: [?\ta]", """[{"a":null}]""", "explicit mapping keys")] + [InlineData("extra: {? a}", """{"a":null}""", "explicit mapping keys")] + [InlineData("extra: {? a: b}", """{"a":"b"}""", "explicit mapping keys")] + [InlineData("extra: {? : b}", """{"":"b"}""", "explicit mapping keys")] + [InlineData("extra: {? }", """{"":null}""", "explicit mapping keys")] + [InlineData("extra: {k: v, ? a}", """{"k":"v","a":null}""", "explicit mapping keys")] + [InlineData("extra: [?\n ]", """[{"":null}]""", "multi-line flow collections")] + [InlineData("extra:\n : a", """{"":"a"}""", "empty mapping keys")] + [InlineData("extra:\n :", """{"":null}""", "empty mapping keys")] + [InlineData("extra:\n :\ta", """{"":"a"}""", "empty mapping keys")] + [InlineData("extra:\n - : a", """[{"":"a"}]""", "empty mapping keys")] + [InlineData("extra:\n - ? a", """[{"a":null}]""", "complex mapping keys")] + [InlineData("extra:\n - ?", """[{"":null}]""", "complex mapping keys")] + [InlineData("extra:\n - ?\ta", """[{"a":null}]""", "complex mapping keys")] + [InlineData("extra:\n - ? a\n : b", """[{"a":"b"}]""", "complex mapping keys")] + public void ValidYamlOutsideTheSubset_IsUnsupportedRatherThanMalformed(string body, string extraJson, string messageFragment) + { + AssertUnsupportedWithExplicitFallback(body, extraJson, messageFragment); + } + + [Fact] + public void CommentInsideAFlowSequence_IsUnsupportedAndKeepsTheExplicitFrontmatterFallback() + { + // The reported case. The yaml package and PyYAML both read it as the JSON supplied below. The comment runs to + // the end of its line, so the flow sequence continues on the next one. + var markdown = "---\nname: test\ndescription: Test\ntags: [a, # comment\n b]\n---\nbody\n"; + var supplied = new JsonObject + { + ["name"] = "test", + ["description"] = "Test", + ["tags"] = new JsonArray("a", "b"), + }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); + var skill = McpServerSkill.Create("skill://test/SKILL.md", supplied, files); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.Contains("multi-line flow collections", parseException!.Message); + Assert.Contains("does not support", automaticException.Message); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("extra: [ # c\n a, b]", """["a","b"]""")] + [InlineData("extra: [a, b, # c\n ]", """["a","b"]""")] + [InlineData("extra: [a # c\n , b]", """["a","b"]""")] + [InlineData("extra: [a, # c\n b\n]", """["a","b"]""")] + [InlineData("extra: [a, # c\n\n# column 0\n b]", """["a","b"]""")] + [InlineData("extra: [\"a\" # c\n , b]", """["a","b"]""")] + [InlineData("extra: {a: 1, # c\n b: 2}", """{"a":1,"b":2}""")] + [InlineData("extra: {a: # c\n 1}", """{"a":1}""")] + [InlineData("extra: {a # c\n : 1}", """{"a":1}""")] + [InlineData("extra: {a:\n 1}", """{"a":1}""")] + [InlineData("extra: [\"a\n b\"]", """["a b"]""")] + [InlineData("extra:\n - [a, # c\n b]", """[["a","b"]]""")] + public void MultiLineFlowCollections_AreUnsupportedRatherThanMalformed(string body, string extraJson) + { + AssertUnsupportedWithExplicitFallback(body, extraJson, "multi-line flow collections"); + } + + [Theory] + [InlineData("extra: [a, b")] + [InlineData("extra: [a, # c\nnext: x]")] + [InlineData("extra: [a # c\n b]")] + [InlineData("extra: [a # c\n : b]")] + [InlineData("extra: [\"a\" # c\n b]")] + [InlineData("extra: [a,# c\n b]")] + [InlineData("extra: [a, # c\n b]\n other: 1")] + [InlineData("extra: [a,\nb]")] + [InlineData("extra: [a\n # c\n b]")] + [InlineData("extra: {a: b c: d}")] + [InlineData("extra: {k: : a}")] + [InlineData("extra: [a: b: c]")] + public void InvalidFlowCollections_StayMalformedForEveryAuthoringPath(string body) + { + // YAML rejects all of these. The last five used to be reported as unsupported. + AssertMalformedForEveryAuthoringPath(body); + } + + [Fact] + public void NestingDeeperThanTheLimit_IsUnsupportedAndKeepsTheExplicitFrontmatterFallback() + { + // 33 levels of block mappings: valid YAML, but the reader stops at 32 to bound its recursion. + string body = "extra:\n" + string.Join("\n", Enumerable.Range(1, 32).Select(i => new string(' ', i) + $"k{i}:")) + " v"; + JsonNode extra = "v"; + for (int i = 32; i >= 1; i--) + { + extra = new JsonObject { [$"k{i}"] = extra }; + } + + AssertUnsupportedWithExplicitFallback(body, extra.ToJsonString(), "nested more than"); + } + + [Theory] + [InlineData(" name: alpha\n description: d\n tags: [a, b]", """{"name":"alpha","description":"d","tags":["a","b"]}""")] + [InlineData(" name: alpha\n description: d\n meta:\n k: v", """{"name":"alpha","description":"d","meta":{"k":"v"}}""")] + [InlineData(" name: alpha\n description: |\n line1\n line2", """{"name":"alpha","description":"line1\nline2\n"}""")] + [InlineData(" name: alpha\n description: d\n tags:\n - a\n - b", """{"name":"alpha","description":"d","tags":["a","b"]}""")] + [InlineData("# comment\n name: alpha\n\n description: d", """{"name":"alpha","description":"d"}""")] + public void IndentedRootMapping_IsReadForEveryAuthoringPath(string frontmatter, string expectedJson) + { + // YAML lets the root mapping start at any column, as long as all of its keys share it. + var markdown = "---\n" + frontmatter + "\n---\nbody\n"; + var expected = JsonNode.Parse(expectedJson)!.AsObject(); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, + McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData(" name: alpha\n description: d")] + [InlineData(" name: alpha\ndescription: d")] + [InlineData(" name: alpha\n description: d")] + [InlineData("\tname: alpha\n\tdescription: d")] + [InlineData(" \tname: alpha\n \tdescription: d")] + public void MismatchedOrTabIndentedRoot_StaysMalformedForEveryAuthoringPath(string frontmatter) + { + AssertMalformedMarkdownForEveryAuthoringPath("---\n" + frontmatter + "\n---\n"); + } + + [Theory] + [InlineData("extra: ? a")] + [InlineData("extra: ?")] + [InlineData("extra: : a")] + [InlineData("extra: - a")] + [InlineData("extra: [- a]")] + [InlineData("extra: [-]")] + [InlineData("extra: [@a]")] + [InlineData("extra: [|a]")] + [InlineData("extra: {k: ? a}")] + [InlineData("extra: {k: ?}")] + [InlineData("extra: {- a: b}")] + [InlineData("extra: {-}")] + [InlineData("extra: {-, a}")] + [InlineData("extra: {@a: b}")] + [InlineData("extra: [?\nnext: x")] + [InlineData("@a: x")] + public void InvalidIndicatorUse_StaysMalformedForEveryAuthoringPath(string body) + { + AssertMalformedForEveryAuthoringPath(body); + } + + [Theory] + [InlineData("extra: [?]")] + [InlineData("extra: [?,]")] + [InlineData("extra: {?}")] + [InlineData("extra: {?, a}")] + public void ExplicitKeyIndicatorWithoutWhiteSpace_StaysMalformed(string body) + { + // YAML 1.2 requires white space after the '?' of an explicit key. Some parsers accept these anyway; the + // reader does not treat them as valid YAML outside its subset. + AssertMalformedForEveryAuthoringPath(body); + } + + [Theory] + [InlineData("extra: {?: b}", """{"?":"b"}""")] + [InlineData("extra: {-: b}", """{"-":"b"}""")] + [InlineData("extra: {:: b}", """{":":"b"}""")] + public void IndicatorFollowedByColon_IsAPlainFlowMappingKey(string body, string extraJson) + { + var markdown = CreateMarkdown(body); + var expected = CreateFrontmatter(extraJson); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, + McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("just a scalar")] + [InlineData("just a\nscalar")] + [InlineData("\"quoted\"")] + [InlineData("'quoted'")] + [InlineData("|\n block")] + [InlineData("42")] + [InlineData(".inf")] + [InlineData("0x1FFFFFFFFFFFFFFFFF")] + [InlineData("\"\\ud800\"")] + [InlineData("&a value")] + [InlineData("!!str\nvalue")] + [InlineData("&a: x")] + [InlineData("!t: x")] + [InlineData("\"a\n b\"")] + [InlineData("[a, b]")] + [InlineData("[a,\n b]")] + [InlineData("[[a]]")] + [InlineData("&a [x: 1]")] + [InlineData("- a\n- b")] + [InlineData("- &a x")] + [InlineData("- a\n- [b,\n c]")] + public void RootThatIsNotAMapping_IsRejectedAsNotAMappingForEveryAuthoringPath(string frontmatter) + { + // Valid YAML, but frontmatter must be a mapping, so no supplied JsonObject can stand in for it. In YAML 1.2, + // "&a: x" is the scalar "x" with the anchor "a:", and "!t: x" is "x" with the tag "!t:". + var markdown = "---\n" + frontmatter + "\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); + var explicitException = Assert.Throws( + () => McpServerSkill.Create("skill://alpha/SKILL.md", CreateFrontmatter("null"), files)); + + Assert.NotEqual("UnsupportedYamlException", parseException.GetType().Name); + Assert.Contains("must be a YAML mapping", parseException.Message); + Assert.Contains("must be a YAML mapping", automaticException.Message); + Assert.Contains("must be a YAML mapping", explicitException.Message); + } + + [Theory] + [InlineData("{name: alpha, description: d}")] + [InlineData(" {name: alpha, description: d} # comment")] + public void RootFlowMapping_IsReadForEveryAuthoringPath(string frontmatter) + { + var markdown = "---\n" + frontmatter + "\n---\n"; + var expected = new JsonObject { ["name"] = "alpha", ["description"] = "d" }; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + Assert.True(JsonNode.DeepEquals(expected, SkillFrontmatter.Parse(markdown))); + Assert.True(JsonNode.DeepEquals(expected, McpServerSkill.Create(files).ProtocolSkill.Frontmatter)); + Assert.True(JsonNode.DeepEquals(expected, + McpServerSkill.Create("skill://alpha/SKILL.md", expected, files).ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("{name: alpha,\n description: d}", "multi-line flow collections")] + [InlineData("&a\nname: alpha\ndescription: d", "anchors")] + [InlineData("!!map\nname: alpha\ndescription: d", "anchors")] + [InlineData("&a {name: alpha, description: d}", "anchors")] + public void RootMappingOutsideTheSubset_IsUnsupportedRatherThanNotAMapping(string frontmatter, string messageFragment) + { + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d" }; + + AssertUnsupportedMarkdownWithExplicitFallback("---\n" + frontmatter + "\n---\n", supplied, messageFragment); + } + + [Theory] + [InlineData("{name: alpha, description: d}\nextra: 1")] + [InlineData("{name: alpha, name: beta}")] + [InlineData("{name: alpha, description: d} extra")] + [InlineData("*a: x")] + [InlineData("[a, b]\nextra: 1")] + [InlineData("[a,\n b]: v")] + public void InvalidRoot_StaysMalformedForEveryAuthoringPath(string frontmatter) + { + AssertMalformedMarkdownForEveryAuthoringPath("---\n" + frontmatter + "\n---\n"); + } + + private void AssertUnsupportedWithExplicitFallback(string body, string extraJson, string messageFragment) + { + AssertUnsupportedMarkdownWithExplicitFallback(CreateMarkdown(body), CreateFrontmatter(extraJson), messageFragment); + } + + private void AssertUnsupportedMarkdownWithExplicitFallback(string markdown, JsonObject supplied, string messageFragment) + { + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + McpServerSkill? skill = null; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.Contains(messageFragment, parseException!.Message); + Assert.IsType(automaticException); + Assert.Null(explicitException); + Assert.True(JsonNode.DeepEquals(supplied, skill!.ProtocolSkill.Frontmatter)); + } + + private void AssertMalformedForEveryAuthoringPath(string body) + { + AssertMalformedMarkdownForEveryAuthoringPath(CreateMarkdown(body)); + } + + private void AssertMalformedMarkdownForEveryAuthoringPath(string markdown) + { + var supplied = CreateFrontmatter("null"); + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + + var parseException = Assert.ThrowsAny(() => SkillFrontmatter.Parse(markdown)); + Assert.NotEqual("UnsupportedYamlException", parseException.GetType().Name); + Assert.Throws(() => McpServerSkill.Create(files)); + var explicitException = Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + Assert.Contains("could not be read", explicitException.Message); + } + private string CreateMarkdown(string body) => "---\nname: alpha\ndescription: d\n" + body + "\n---\n"; private JsonObject CreateFrontmatter(string extraJson) => new() diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs index b3a0fbe72..6a8c193e7 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs @@ -1,10 +1,206 @@ using ModelContextProtocol.Extensions.Skills; +using System.Diagnostics; +using System.Globalization; +using System.Numerics; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; -public class SkillFrontmatterRegressionTests +public class SkillFrontmatterRegressionTests(ITestOutputHelper output) { + [Theory] + [InlineData("{items:[one]}", """{"items":["one"]}""")] + [InlineData("{items :[one]}", """{"items":["one"]}""")] + [InlineData("{items:{value: one}}", """{"items":{"value":"one"}}""")] + [InlineData("{items:[one], other: two}", """{"items":["one"],"other":"two"}""")] + [InlineData("[key:{value}]", """[{"key":{"value":null}}]""")] + [InlineData("[key:[one]]", """[{"key":["one"]}]""")] + [InlineData("[key: {value}]", """[{"key":{"value":null}}]""")] + [InlineData("[key :[one], other]", """[{"key":["one"]},"other"]""")] + public void NestedFlowValuesAreRejectedInsteadOfPublishedAsPlainKeys(string yaml, string expectedJson) + { + var markdown = $"---\nname: alpha\ndescription: d\nextra: {yaml}\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var expected = new JsonObject + { + ["name"] = "alpha", + ["description"] = "d", + ["extra"] = JsonNode.Parse(expectedJson), + }; + + var error = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + Assert.Equal("UnsupportedYamlException", error?.GetType().Name); + Assert.Contains("not supported", error!.Message); + Assert.Throws(() => McpServerSkill.Create(files)); + var supplied = McpServerSkill.Create("skill://alpha/SKILL.md", expected, files); + Assert.True(JsonNode.DeepEquals(expected, supplied.ProtocolSkill.Frontmatter)); + } + + [Theory] + [InlineData("[word{value}]")] + [InlineData("[word}value]")] + [InlineData("{word[value]: one}")] + public void FlowIndicatorsCannotBecomeUnquotedScalarContent(string yaml) + { + var markdown = $"---\nname: alpha\ndescription: d\nextra: {yaml}\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["extra"] = yaml }; + + Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + Assert.Throws(() => McpServerSkill.Create(files)); + Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + } + + [Theory] + [InlineData("[https://example.test/path, key:value]", """["https://example.test/path","key:value"]""")] + [InlineData("[\"word{value}\", 'key:[one]']", """["word{value}","key:[one]"]""")] + [InlineData("{\"word[value]\": one}", """{"word[value]":"one"}""")] + [InlineData("{version:1}", """{"version:1":null}""")] + public void PlainColonsAndQuotedFlowIndicatorsRemainSupported(string yaml, string expectedJson) + { + var expected = new JsonObject + { + ["name"] = "alpha", + ["description"] = "d", + ["extra"] = JsonNode.Parse(expectedJson), + }; + AssertParsedAndPublished($"---\nname: alpha\ndescription: d\nextra: {yaml}\n---\n", expected); + } + + [Theory] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + [InlineData(4)] + [InlineData(5)] + [InlineData(6)] + [InlineData(7)] + [InlineData(8)] + [InlineData(9)] + [InlineData(21)] + public void OctalConversionIsExactWithin64Bits(int digits) + { + var parsed = SkillFrontmatter.Parse($"---\nvalue: 0o{new string('7', digits)}\n---\n"); + + var expected = (BigInteger.One << (3 * digits)) - BigInteger.One; + Assert.Equal(expected.ToString(CultureInfo.InvariantCulture), parsed["value"]!.ToJsonString()); + } + + [Theory] + [InlineData(22)] + [InlineData(23)] + [InlineData(24)] + [InlineData(40_000)] + [InlineData(80_000)] + [InlineData(160_000)] + public void OctalIntegersWiderThan64Bits_AreUnsupported(int digits) + { + var markdown = $"---\nvalue: 0o{new string('7', digits)}\n---\n"; + var stopwatch = Stopwatch.StartNew(); + var error = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + stopwatch.Stop(); + + Assert.Equal("UnsupportedYamlException", error?.GetType().Name); + Assert.Contains("wider than 64 bits", error!.Message); + output.WriteLine($"OCTAL digits={digits}; elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}"); + } + + [Fact] + public void OctalConversionPreservesMixedDigitsAndLeadingZeros() + { + var random = new Random(2640); + for (int length = 1; length <= 128; length++) + { + var digits = new char[length + 1]; + digits[0] = '0'; + var expected = BigInteger.Zero; + for (int index = 1; index < digits.Length; index++) + { + int digit = random.Next(8); + digits[index] = (char)('0' + digit); + expected = (expected * 8) + digit; + } + + var markdown = $"---\nvalue: 0o{new string(digits)}\n---\n"; + if (expected <= ulong.MaxValue) + { + var parsed = SkillFrontmatter.Parse(markdown); + Assert.Equal(expected.ToString(CultureInfo.InvariantCulture), parsed["value"]!.ToJsonString()); + } + else + { + var error = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + Assert.Equal("UnsupportedYamlException", error?.GetType().Name); + } + } + } + + [Theory] + [InlineData("0xffffffffffffffff")] + [InlineData("0xFFFFFFFFFFFFFFFF")] + [InlineData("0o1777777777777777777777")] + public void HexAndOctalIntegersAtTheUnsigned64BitMaximum_KeepTheirExactValue(string literal) + { + var expected = new JsonObject + { + ["name"] = "alpha", + ["description"] = "d", + ["value"] = JsonNode.Parse("18446744073709551615"), + }; + + AssertParsedAndPublished($"---\nname: alpha\ndescription: d\nvalue: {literal}\n---\n", expected); + Assert.Equal("18446744073709551615", ParseValue(literal)); + } + + [Fact] + public void LeadingZeros_DoNotCountTowardThe64BitLimit() + { + var zeros = new string('0', 10_000); + + Assert.Equal("1", ParseValue("0x" + zeros + "1")); + Assert.Equal("7", ParseValue("0o" + zeros + "7")); + Assert.Equal("18446744073709551615", ParseValue("0x" + zeros + "ffffffffffffffff")); + Assert.Equal("18446744073709551615", ParseValue("0o" + zeros + "1777777777777777777777")); + } + + [Theory] + [InlineData("0x10000000000000000")] + [InlineData("0x1ffffffffffffffff")] + [InlineData("0o2000000000000000000000")] + [InlineData("0o777777777777777777777777")] + public void HexAndOctalIntegersWiderThan64Bits_AreUnsupportedForEveryAuthoringPath(string literal) + { + AssertWideIntegerIsUnsupported(literal); + } + + [Theory] + [InlineData("0x", 'f')] + [InlineData("0o", '7')] + public void HexAndOctalIntegersWith200000Digits_AreUnsupportedWithoutEchoingTheLiteral(string prefix, char digit) + { + var literal = prefix + new string(digit, 200_000); + var stopwatch = Stopwatch.StartNew(); + var error = AssertWideIntegerIsUnsupported(literal); + stopwatch.Stop(); + + Assert.DoesNotContain(literal, error.Message); + Assert.True(error.Message.Length < 400, $"The message has {error.Message.Length} characters."); + output.WriteLine($"{prefix} digits=200000; elapsed_ms={stopwatch.Elapsed.TotalMilliseconds:F1}"); + } + + [Theory] + [InlineData("0x10000000000000000")] + [InlineData("0o2000000000000000000000")] + public void WideIntegerKeys_AreUnsupportedLikeValues(string literal) + { + var blockKey = Record.Exception(() => SkillFrontmatter.Parse($"---\n{literal}: a\n---\n")); + var flowKey = Record.Exception(() => SkillFrontmatter.Parse($"---\nvalue: {{{literal}: a}}\n---\n")); + + Assert.Equal("UnsupportedYamlException", blockKey?.GetType().Name); + Assert.Equal("UnsupportedYamlException", flowKey?.GetType().Name); + Assert.Equal(["18446744073709551615"], SkillFrontmatter.Parse("---\n0xffffffffffffffff: a\n---\n").Select(p => p.Key)); + } + [Theory] [InlineData("description:\n Builds reusable components with isolated tables.\n Use for new components.", "Builds reusable components with isolated tables. Use for new components.")] @@ -63,10 +259,10 @@ public void BlockScalarAtClosingFence_PreservesAuthoredLineBreaks(string header, } [Theory] - [InlineData("\"Useful\"#comment", "{version: 1}", "[read]")] - [InlineData("'Useful'#comment", "{version: 1}", "[read]")] - [InlineData("Useful", "{version: 1}#comment", "[read]")] - [InlineData("Useful", "{version: 1}", "[read]#comment")] + [InlineData("\"Useful\"#comment", "{version: '1'}", "[read]")] + [InlineData("'Useful'#comment", "{version: '1'}", "[read]")] + [InlineData("Useful", "{version: '1'}#comment", "[read]")] + [InlineData("Useful", "{version: '1'}", "[read]#comment")] public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloads( string description, string metadata, string tags) { @@ -75,7 +271,7 @@ public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloa { ["name"] = "comparison", ["description"] = "Useful", - ["metadata"] = new JsonObject { ["version"] = 1 }, + ["metadata"] = new JsonObject { ["version"] = "1" }, ["tags"] = new JsonArray("read"), }; var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; @@ -90,13 +286,13 @@ public void UnseparatedTrailingComment_IsRejectedByParserAndBothAuthoringOverloa } [Theory] - [InlineData("Useful#hash", "{version: 1}", "[read]", "Useful#hash")] - [InlineData("\"Useful\" #comment", "{version: 1}", "[read]", "Useful")] - [InlineData("'Useful'\t#comment", "{version: 1}", "[read]", "Useful")] - [InlineData("Useful", "{version: 1} #comment", "[read]", "Useful")] - [InlineData("Useful", "{version: 1}\t#comment", "[read]", "Useful")] - [InlineData("Useful", "{version: 1}", "[read] #comment", "Useful")] - [InlineData("Useful", "{version: 1}", "[read]\t#comment", "Useful")] + [InlineData("Useful#hash", "{version: '1'}", "[read]", "Useful#hash")] + [InlineData("\"Useful\" #comment", "{version: '1'}", "[read]", "Useful")] + [InlineData("'Useful'\t#comment", "{version: '1'}", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'} #comment", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'}\t#comment", "[read]", "Useful")] + [InlineData("Useful", "{version: '1'}", "[read] #comment", "Useful")] + [InlineData("Useful", "{version: '1'}", "[read]\t#comment", "Useful")] public void SeparatedCommentsAndPlainHashes_RemainValid( string description, string metadata, string tags, string expectedDescription) { @@ -105,7 +301,7 @@ public void SeparatedCommentsAndPlainHashes_RemainValid( { ["name"] = "comparison", ["description"] = expectedDescription, - ["metadata"] = new JsonObject { ["version"] = 1 }, + ["metadata"] = new JsonObject { ["version"] = "1" }, ["tags"] = new JsonArray("read"), }; @@ -128,6 +324,122 @@ public void NonFiniteScalar_CannotBeReplacedWithJsonNullThroughExplicitFrontmatt Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); } + [Theory] + [InlineData("description: x{0}y")] + [InlineData("description: \"x{0}y\"")] + [InlineData("description: 'x{0}y'")] + [InlineData("description: |\n x{0}y")] + [InlineData("description: d\n# note{0}")] + [InlineData("description: d # note{0}")] + [InlineData("description: d\nex{0}tra: e")] + public void LiteralNul_IsRejectedByTheReaderAndBothAuthoringOverloads(string entry) + { + AssertRawCharacterIsRejected(string.Format(entry, '\0'), 0x0000); + } + + [Theory] + [InlineData(0x0001)] + [InlineData(0x0008)] + [InlineData(0x000B)] + [InlineData(0x000C)] + [InlineData(0x001B)] + [InlineData(0x001F)] + [InlineData(0x007F)] + [InlineData(0x0080)] + [InlineData(0x009F)] + [InlineData(0xFFFE)] + [InlineData(0xFFFF)] + public void RawCharacterOutsideYamlsPrintableSet_IsRejected(int codePoint) + { + AssertRawCharacterIsRejected("description: x" + char.ConvertFromUtf32(codePoint) + "y", codePoint); + } + + [Theory] + [InlineData("\"", 0x007F)] + [InlineData("'", 0x0080)] + [InlineData("\"", 0xFFFE)] + public void RawCharacterOutsideYamlsPrintableSet_IsRejectedInsideQuotesToo(string quote, int codePoint) + { + AssertRawCharacterIsRejected("description: " + quote + "x" + char.ConvertFromUtf32(codePoint) + "y" + quote, codePoint); + } + + [Theory] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x000D)] + [InlineData("description: d\n# note{0}allowed-tools: Bash", 0x2028)] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x0085)] + [InlineData("description: d # note{0}allowed-tools: Bash", 0x2029)] + [InlineData("description: x{0}y", 0x0085)] + [InlineData("description: d{0}{0}", 0x000D)] + public void RawCharacterSomeParsersReadAsALineBreak_IsRejected(string entry, int codePoint) + { + AssertRawCharacterIsRejected(string.Format(entry, (char)codePoint), codePoint); + } + + [Fact] + public void RawCharacter_IsRejectedEvenAfterAnUnsupportedConstruct() + { + var markdown = "---\nname: alpha\nx: &a 1\ndescription: x\0y\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var supplied = new JsonObject { ["name"] = "alpha", ["x"] = 1, ["description"] = "x\0y" }; + + var exception = Assert.Throws(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + + Assert.Contains("Line 4:", exception.Message); + Assert.Contains("U+0000", exception.Message); + } + + [Theory] + [InlineData(new[] { 0xD800 }, false, 0xD800)] + [InlineData(new[] { 0xD800 }, true, 0xD800)] + [InlineData(new[] { 0xDC00 }, false, 0xDC00)] + [InlineData(new[] { 0xDC00, 0xD800 }, false, 0xDC00)] + public void UnpairedSurrogate_IsRejectedByTheReader(int[] codeUnits, bool atLineEnd, int expectedCodeUnit) + { + var markdown = "---\nname: alpha\ndescription: x" + Text(codeUnits) + (atLineEnd ? "" : "y") + "\n---\n"; + + var exception = Assert.Throws(() => SkillFrontmatter.Parse(markdown)); + + Assert.Contains($"U+{expectedCodeUnit:X4}", exception.Message); + } + + [Theory] + [InlineData(0x0009)] + [InlineData(0x00A0)] + [InlineData(0xE000)] + [InlineData(0xFFFD)] + [InlineData(0x1F916)] + public void RawCharacterInYamlsPrintableSet_IsKept(int codePoint) + { + string character = char.ConvertFromUtf32(codePoint); + var expected = new JsonObject { ["name"] = "alpha", ["description"] = "x" + character + "y" }; + + AssertParsedAndPublished("---\nname: alpha\ndescription: x" + character + "y\n---\n", expected); + } + + [Theory] + [InlineData("\\0", 0x0000)] + [InlineData("\\x1B", 0x001B)] + [InlineData("\\x7F", 0x007F)] + [InlineData("\\uFFFE", 0xFFFE)] + public void EscapedCharacterOutsideYamlsPrintableSet_IsKept(string escape, int codePoint) + { + var expected = new JsonObject { ["name"] = "alpha", ["description"] = "x" + char.ConvertFromUtf32(codePoint) + "y" }; + + AssertParsedAndPublished("---\nname: alpha\ndescription: \"x" + escape + "y\"\n---\n", expected); + } + + [Theory] + [InlineData("\\r", 0x000D)] + [InlineData("\\N", 0x0085)] + [InlineData("\\L", 0x2028)] + [InlineData("\\P", 0x2029)] + public void EscapedCharacterSomeParsersReadAsALineBreak_IsKept(string escape, int codePoint) + { + var expected = new JsonObject { ["name"] = "alpha", ["description"] = "x" + char.ConvertFromUtf32(codePoint) + "y" }; + + AssertParsedAndPublished("---\nname: alpha\ndescription: \"x" + escape + "y\"\n---\n", expected); + } + private void AssertParsedAndPublished(string markdown, JsonObject expected) { var parsed = SkillFrontmatter.Parse(markdown); @@ -139,4 +451,54 @@ private void AssertParsedAndPublished(string markdown, JsonObject expected) Assert.True(JsonNode.DeepEquals(expected, automatic.ProtocolSkill.Frontmatter)); Assert.True(JsonNode.DeepEquals(expected, explicitMetadata.ProtocolSkill.Frontmatter)); } + + private string ParseValue(string literal) => + SkillFrontmatter.Parse($"---\nvalue: {literal}\n---\n")["value"]!.ToJsonString(); + + /// + /// Asserts that the reader treats a raw character it does not allow as malformed, not unsupported, so automatic + /// authoring and explicit frontmatter both reject the file, each naming the character. + /// + private void AssertRawCharacterIsRejected(string entry, int codePoint) + { + var markdown = $"---\nname: alpha\n{entry}\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d" }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Record.Exception(() => McpServerSkill.Create(files)); + var explicitException = Record.Exception(() => McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files)); + + string character = $"U+{codePoint:X4}"; + Assert.Contains(character, Assert.IsType(parseException).Message); + Assert.Contains(character, Assert.IsType(automaticException).Message); + Assert.Contains(character, Assert.IsType(explicitException).Message); + } + + private string Text(int[] codeUnits) => new(codeUnits.Select(codeUnit => (char)codeUnit).ToArray()); + + /// + /// Asserts the unsupported classification for each authoring path: the reader throws its FormatException-derived + /// unsupported exception, automatic authoring rejects the file, and explicit frontmatter is accepted. + /// + private Exception AssertWideIntegerIsUnsupported(string literal) + { + var markdown = $"---\nname: alpha\ndescription: d\nvalue: {literal}\n---\n"; + var files = new[] { McpServerSkillFile.FromText("SKILL.md", markdown) }; + var supplied = new JsonObject { ["name"] = "alpha", ["description"] = "d", ["value"] = literal }; + + var parseException = Record.Exception(() => SkillFrontmatter.Parse(markdown)); + var automaticException = Assert.Throws(() => McpServerSkill.Create(files)); + var skill = McpServerSkill.Create("skill://alpha/SKILL.md", supplied, files); + + Assert.Equal("UnsupportedYamlException", parseException?.GetType().Name); + Assert.IsAssignableFrom(parseException); + Assert.Contains("Line 4:", parseException!.Message); + Assert.Contains("wider than 64 bits", parseException.Message); + Assert.Contains("full YAML parser", parseException.Message); + Assert.Contains("does not support", automaticException.Message); + Assert.Contains("wider than 64 bits", automaticException.Message); + Assert.True(JsonNode.DeepEquals(supplied, skill.ProtocolSkill.Frontmatter)); + return parseException; + } } diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs index 85bf07427..03d175710 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterTests.cs @@ -9,10 +9,10 @@ namespace ModelContextProtocol.Tests.Server; /// public class SkillFrontmatterTests { - private static JsonObject Parse(string yamlBody, string suffix = "\n# Body\n") => + private JsonObject Parse(string yamlBody, string suffix = "\n# Body\n") => SkillFrontmatter.Parse("---\n" + yamlBody + "\n---" + suffix); - private static void AssertJson(string expectedJson, JsonNode? actual) => + private void AssertJson(string expectedJson, JsonNode? actual) => Assert.True(JsonNode.DeepEquals(JsonNode.Parse(expectedJson), actual), $"Expected {expectedJson} but got {actual?.ToJsonString() ?? "null"}."); [Fact] @@ -62,7 +62,7 @@ public void PreservesKeyOrder() [InlineData("0x1F", "31")] [InlineData("0o17", "15")] [InlineData("0xffffffffffffffff", "18446744073709551615")] - [InlineData("0o777777777777777777777777", "4722366482869645213695")] + [InlineData("0o1777777777777777777777", "18446744073709551615")] [InlineData("+0x10", "\"+0x10\"")] [InlineData("-0o10", "\"-0o10\"")] [InlineData("0x", "\"0x\"")] @@ -273,7 +273,9 @@ public void RejectsNestingBeyondTheDepthLimit() { string deep = string.Join("\n", Enumerable.Range(0, 40).Select(i => new string(' ', i) + $"k{i}:")) + " v"; - var exception = Assert.Throws(() => Parse(deep)); + // Deeper nesting is valid YAML that the reader does not support, rather than malformed input. + var exception = Assert.ThrowsAny(() => Parse(deep)); + Assert.Equal("UnsupportedYamlException", exception.GetType().Name); Assert.Contains("nested more than", exception.Message); string shallow = string.Join("\n", Enumerable.Range(0, 20).Select(i => new string(' ', i) + $"k{i}:")) + " v"; @@ -406,9 +408,11 @@ public void ACommentEndsAPlainScalarOnAnyOfItsLines() [InlineData("---\nvalue: ,x\n---", "flow indicator")] [InlineData("---\nvalue: ?\n---", "block indicator")] [InlineData("---\nvalue: ? a\n---", "block indicator")] - [InlineData("---\n&a: x\n---", "anchors")] - [InlineData("---\n*a: x\n---", "anchors")] - [InlineData("---\n!t: x\n---", "anchors")] + [InlineData("---\n&a: x\n---", "must be a YAML mapping")] + [InlineData("---\n*a: x\n---", "cannot start with an alias")] + [InlineData("---\n!t: x\n---", "must be a YAML mapping")] + [InlineData("---\n&a k: x\n---", "anchors")] + [InlineData("---\n!t k: x\n---", "anchors")] [InlineData("---\n@a: x\n---", "reserves")] [InlineData("---\n`a: x\n---", "reserves")] [InlineData("---\n%a: x\n---", "reserves")] @@ -428,6 +432,7 @@ public void ACommentEndsAPlainScalarOnAnyOfItsLines() [InlineData("---\nvalue: [\"a\": b]\n---", "compact mappings inside flow sequences")] [InlineData("---\nvalue: |\n \n a\n---", "leading empty line")] [InlineData("---\nvalue: .inf\n---", "cannot be represented")] + [InlineData("---\nvalue: 0o777777777777777777777777\n---", "wider than 64 bits")] [InlineData("---\nvalue: @handle\n---", "reserves")] [InlineData("---\nvalue: `tick\n---", "reserves")] [InlineData("---\nvalue: %pct\n---", "reserves")] @@ -452,7 +457,7 @@ public void ACommentEndsAPlainScalarOnAnyOfItsLines() [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")] + [InlineData("---\njust a scalar\n---", "must be a YAML mapping")] [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 ': '")] @@ -476,4 +481,14 @@ public void RoundTripsThroughSkillEntryValidation() Assert.Equal("git-workflow", skill.ProtocolSkill.Name); Assert.Equal("Git conventions", skill.ProtocolSkill.Description); } + + [Fact] + public void Reader_IsNotPartOfThePublicApi() + { + // Server authoring uses the reader internally; hosts verify fetched frontmatter with a full YAML parser. + var reader = typeof(McpServerSkill).Assembly.GetType("ModelContextProtocol.Extensions.Skills.SkillFrontmatter"); + + Assert.NotNull(reader); + Assert.False(reader.IsVisible); + } } diff --git a/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs new file mode 100644 index 000000000..9027e8746 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs @@ -0,0 +1,140 @@ +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace ModelContextProtocol.Tests.Utils; + +public class ConformanceProcessTests +{ + // The runner starts a detached descendant that inherits its redirected stdout and stderr, then exits. The + // descendant keeps both streams open for two minutes, longer than the test allows for. + private const string LingeringDescendantScript = + "const child = require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120000)'], { stdio: 'inherit', detached: true, windowsHide: true }); " + + "child.unref(); console.log('descendant ' + child.pid); console.log('runner exited');"; + + // The runner starts a child that inherits its redirected stdout and stderr, and both keep running past the deadline. + private const string RunningTreeScript = + "const child = require('child_process').spawn(process.execPath, ['-e', 'setTimeout(() => {}, 120000)'], { stdio: 'inherit', windowsHide: true }); " + + "console.log('descendant ' + child.pid); setTimeout(() => {}, 120000);"; + + [Theory] + [InlineData(0, true, true)] + [InlineData(17, true, false)] + [InlineData(3, false, false)] + public async Task ExitCodeCannotBeOverriddenByPrintedSummary(int exitCode, bool passingSummary, bool expectedSuccess) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + var startInfo = CreateStartInfo(exitCode, passingSummary); + + var result = await NodeHelpers.RunServerConformanceAsync( + string.Empty, + cancellationToken: TestContext.Current.CancellationToken, + processStartInfo: startInfo); + + Assert.Equal(expectedSuccess, result.Success); + Assert.Contains(passingSummary ? "Passed: 3/3" : "Passed: 2/3", result.Output); + Assert.Contains("synthetic stderr", result.Error); + if (exitCode != 0) + { + Assert.Contains($"exited with code {exitCode}", result.Error); + } + } + + [Fact(Timeout = 60_000)] + public async Task RunnerThatExitsWhileADescendantHoldsItsOutput_FailsWithinTheDeadline() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + var deadline = TimeSpan.FromSeconds(3); + var stopwatch = Stopwatch.StartNew(); + + var result = await NodeHelpers.RunServerConformanceAsync( + string.Empty, + cancellationToken: TestContext.Current.CancellationToken, + processStartInfo: CreateNodeStartInfo(LingeringDescendantScript), + timeout: deadline); + + stopwatch.Stop(); + using var descendant = FindDescendant(result.Output); + try + { + Assert.False(result.Success); + Assert.Contains("runner exited", result.Output); + Assert.Contains("timed out after 3 seconds", result.Error); + Assert.True(stopwatch.Elapsed < deadline + TimeSpan.FromSeconds(20), $"The helper returned after {stopwatch.Elapsed}."); + + // Still running and holding the streams, so only the deadline could have ended the wait. + Assert.False(descendant.HasExited); + } + finally + { + if (!descendant.HasExited) + { + descendant.Kill(); + } + } + } + + [Fact(Timeout = 60_000)] + public async Task RunnerStillRunningAtTheDeadline_IsKilledWithItsChildren() + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed."); + var deadline = TimeSpan.FromSeconds(3); + var stopwatch = Stopwatch.StartNew(); + + var result = await NodeHelpers.RunServerConformanceAsync( + string.Empty, + cancellationToken: TestContext.Current.CancellationToken, + processStartInfo: CreateNodeStartInfo(RunningTreeScript), + timeout: deadline); + + stopwatch.Stop(); + Assert.False(result.Success); + Assert.Contains("timed out after 3 seconds", result.Error); + Assert.Contains("and was killed", result.Error); + Assert.True(stopwatch.Elapsed < deadline + TimeSpan.FromSeconds(20), $"The helper returned after {stopwatch.Elapsed}."); + + // The child is not killed here: if the helper missed it, it ends on its own after two minutes. + Assert.True(ExitsWithin(DescendantId(result.Output), TimeSpan.FromSeconds(10)), "The runner's child process is still running."); + } + + private ProcessStartInfo CreateStartInfo(int exitCode, bool passingSummary) + { + var summary = passingSummary + ? "Passed: 3/3, 0 failed, 0 warnings" + : "Passed: 2/3, 1 failed, 0 warnings"; + return CreateNodeStartInfo($"console.log('Test Results:'); console.log('{summary}'); console.error('synthetic stderr'); process.exitCode = {exitCode};"); + } + + private ProcessStartInfo CreateNodeStartInfo(string script) => new() + { + FileName = "node", + Arguments = $"-e \"{script}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + private Process FindDescendant(string runnerOutput) => Process.GetProcessById(DescendantId(runnerOutput)); + + private int DescendantId(string runnerOutput) + { + var match = Regex.Match(runnerOutput, @"descendant (\d+)"); + Assert.True(match.Success, $"The runner did not report its descendant: {runnerOutput}"); + return int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + } + + private bool ExitsWithin(int processId, TimeSpan timeout) + { + try + { + using var process = Process.GetProcessById(processId); + return process.WaitForExit((int)timeout.TotalMilliseconds); + } + catch (ArgumentException) + { + // No process has that id anymore. + return true; + } + } +}