From 47b9db7df84fef3eed2745d24a289a09e314bead 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 f3d028b731d0b17551e2427f4a56716a5c254ed5 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 b3c1d9143..3926c3adb 100644 --- a/src/PACKAGE.md +++ b/src/PACKAGE.md @@ -18,6 +18,8 @@ The SDK packages are: - **[ModelContextProtocol.Extensions.Apps](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Apps.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Apps) - MCP Apps extension for building interactive UI applications that render inside MCP hosts. +- **[ModelContextProtocol.Extensions.Skills](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Skills.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Skills) - MCP Skills extension for serving and consuming Agent Skills with verifiable file manifests. + - **[ModelContextProtocol.Extensions.Tasks](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks)** [![NuGet version](https://img.shields.io/nuget/v/ModelContextProtocol.Extensions.Tasks.svg)](https://www.nuget.org/packages/ModelContextProtocol.Extensions.Tasks) - MCP Tasks extension for running long-running tool invocations asynchronously with status polling and input requests. ## Getting Started diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj index a8ab66edf..6c1d79770 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/ModelContextProtocol.AotCompatibility.TestApp.csproj @@ -16,11 +16,13 @@ + + diff --git a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs index 708cd9361..d4057e4c0 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Extensions.Skills; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.IO.Pipelines; @@ -11,7 +12,14 @@ services.AddMcpServer() .WithStreamServerTransport(clientToServerPipe.Reader.AsStream(), serverToClientPipe.Writer.AsStream()) .WithTools() - .WithMcpApps(); + .WithMcpApps() + .WithSkills( + [ + McpServerSkill.Create( + "skill://aot/SKILL.md", + new System.Text.Json.Nodes.JsonObject { ["name"] = "aot", ["description"] = "An AOT-published skill." }, + [McpServerSkillFile.FromText("SKILL.md", "---\nname: aot\ndescription: An AOT-published skill.\n---\n")]), + ]); await using var serviceProvider = services.BuildServiceProvider(); var server = serviceProvider.GetRequiredService(); @@ -41,6 +49,20 @@ throw new Exception($"Unexpected result: {result}"); } +// List, get, and verify a skill. +var skills = await client.ListSkillsAsync(); +if (skills.Count != 1 || skills[0].Name != "aot") +{ + throw new Exception($"Unexpected skills listing: {skills.Count} entries."); +} + +var skill = await client.GetSkillAsync("skill://aot/SKILL.md"); +var skillFile = await client.ReadSkillResourceAsync(skill, skill.Uri); +if (skillFile.Contents is not [TextResourceContents { Text: var skillText }] || !skillText.Contains("name: aot")) +{ + throw new Exception("Unexpected skill content."); +} + Console.WriteLine("Success!"); [McpServerToolType] diff --git a/tests/ModelContextProtocol.ConformanceServer/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 6b0870cb92cf17709da4a058cd6d055189d0ab66 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 d4057e4c0..7b695d963 100644 --- a/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs +++ b/tests/ModelContextProtocol.AotCompatibility.TestApp/Program.cs @@ -17,8 +17,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(); @@ -56,7 +55,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 c424d4f58c31c144e6ca2f13392a48aae402adaf 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 4837a3ffc878a2f9c9b4baea51d2da070eba6ec3 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 and fix initialization regressions 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. Keep discovery probe time separate from OAuth work while preserving caller cancellation and configured budgets. Keep injected parameters out of wire schemas without relying on reflection-object identity. Use owned conformance-host readiness, bounded startup and cleanup, and deterministic cancellation-observation tests. 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 +- docs/concepts/skills/skills.md | 46 +- package-lock.json | 20 +- package.json | 2 +- samples/SkillsClient/README.md | 6 +- samples/SkillsServer/README.md | 2 + .../Authentication/ClientOAuthProvider.cs | 39 +- .../AutoDetectingClientSessionTransport.cs | 2 +- .../Client/DiscoverProbe.cs | 108 ++++ .../Client/McpClientImpl.cs | 40 +- .../Client/McpClientOptions.cs | 5 + .../Client/McpHttpClient.cs | 3 +- .../Client/SseClientSessionTransport.cs | 8 +- .../Protocol/JsonRpcMessageContext.cs | 2 + .../Server/AIFunctionMcpServerPrompt.cs | 75 +-- .../Server/AIFunctionMcpServerTool.cs | 72 +-- .../Client/McpSkillsClientExtensions.cs | 141 ++++- .../Client/SkillEntryRejection.cs | 23 + .../Client/SkillListingResult.cs | 23 + .../Protocol/ListSkillsResult.cs | 1 + .../Server/InMemoryMcpSkillCatalog.cs | 13 +- .../Server/McpServerSkill.cs | 87 +++- .../Server/McpSkillsBuilderExtensions.cs | 54 +- .../SkillValidation.cs | 62 ++- tests/Common/Utils/NodeHelpers.cs | 41 +- .../ConformanceServerFixture.cs | 170 ++++-- .../ConformanceServerFixtureTests.cs | 351 +++++++++++++ .../ServerConformanceTests.cs | 26 +- .../SkillsAuthorizationTests.cs | 151 ++++++ .../Program.cs | 8 +- .../Client/DiscoveryAuthorizationTests.cs | 493 ++++++++++++++++++ .../Client/McpSkillsClientValidationTests.cs | 190 ++++++- .../Client/McpSkillsEnumerationTests.cs | 190 +++++++ .../Server/InMemoryMcpSkillCatalogTests.cs | 109 +++- .../Server/InjectedParameterSchemaTests.cs | 191 +++++++ .../Server/McpServerSkillsCatalogTests.cs | 17 + .../McpServerSkillsFromDirectoryTests.cs | 30 ++ .../Server/McpSkillDirectorySelectionTests.cs | 107 ++++ ...SkillFrontmatterNodeClassificationTests.cs | 2 +- .../Server/SkillFrontmatterRegressionTests.cs | 26 +- .../Utils/ConformanceProcessTests.cs | 62 +++ 43 files changed, 2827 insertions(+), 277 deletions(-) create mode 100644 .github/scripts/prepare_markdown_links.py create mode 100644 .github/scripts/test_prepare_markdown_links.py create mode 100644 src/ModelContextProtocol.Core/Client/DiscoverProbe.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/DiscoveryAuthorizationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs create mode 100644 tests/ModelContextProtocol.Tests/Server/InjectedParameterSchemaTests.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/docs/concepts/skills/skills.md b/docs/concepts/skills/skills.md index 001761393..be4e09d37 100644 --- a/docs/concepts/skills/skills.md +++ b/docs/concepts/skills/skills.md @@ -62,11 +62,17 @@ 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. +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`: @@ -88,7 +94,7 @@ All of these validate structural entry and file constraints and throw 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 +261,10 @@ 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; the [HTTP integration example](https://github.com/modelcontextprotocol/csharp-sdk/blob/main/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs) + exercises both allowed and denied callers. Do not derive authorization from `resources/list` membership: + listings may intentionally be partial while an authorized direct read remains valid. - Treat MCP-served skill content as untrusted model input, and tag it with its originating server when it enters the model's context. - Digests are unsigned and come from the same server as the content. A match proves consistency between the entry 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/README.md b/samples/SkillsClient/README.md index fafdf6252..63d6269b3 100644 --- a/samples/SkillsClient/README.md +++ b/samples/SkillsClient/README.md @@ -6,8 +6,8 @@ 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. Enumerates the catalog with `skills/list` (`client.ListSkillsAsync()` follows up to 100 pages and 1,000 + entries by default; the per-page overload exposes the `ttlMs` and `cacheScope` hints). 3. Retrieves one skill by URI with `skills/get` (`client.GetSkillAsync(uri)`), the way a host confirms a URI it found in server instructions. 4. Reads the skill's files with `resources/read` and verifies manifest membership, URI, digest and size @@ -71,3 +71,5 @@ Expected output (abridged): does not support. A host that must accept such YAML can use a full YAML parser instead, but never the entry's own frontmatter. - This is not a complete host: approval binding, origin isolation, and permission decisions are left out. +- The ordinary list helpers remain strict. `ListSkillsWithDiagnosticsAsync` is an explicit alternative + for obtaining valid entries together with `RejectedEntries`; it never treats an invalid entry as loadable. diff --git a/samples/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.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index 785e3cc2e..d5e5aa213 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -191,14 +191,20 @@ public ClientOAuthProvider( }); } - internal override async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) + internal override async Task SendAsync( + HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null) { bool attemptedRefresh = false; + discoverProbe ??= message?.Context?.DiscoverProbe; - if (request.Headers.Authorization is null && request.RequestUri is not null) + if (request.Headers.Authorization is null && + request.RequestUri is not null) { string? accessToken; - (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + using (discoverProbe?.Suspend()) + { + (accessToken, attemptedRefresh) = await GetAccessTokenSilentAsync(request.RequestUri, cancellationToken).ConfigureAwait(false); + } if (!string.IsNullOrEmpty(accessToken)) { @@ -213,7 +219,7 @@ internal override async Task SendAsync(HttpRequestMessage r // Capture the token that produced this challenge so the retry path can detect whether // another concurrent caller already replaced it in the cache. var usedAccessToken = request.Headers.Authorization?.Parameter; - return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + return await HandleUnauthorizedResponseAsync(request, message, response, attemptedRefresh, usedAccessToken, discoverProbe, cancellationToken).ConfigureAwait(false); } return response; @@ -296,6 +302,7 @@ private async Task HandleUnauthorizedResponseAsync( HttpResponseMessage response, bool attemptedRefresh, string? usedAccessToken, + DiscoverProbe? discoverProbe, CancellationToken cancellationToken) { if (response.Headers.WwwAuthenticate.Count == 0) @@ -308,7 +315,11 @@ private async Task HandleUnauthorizedResponseAsync( throw new McpException($"The server does not support the '{BearerScheme}' authentication scheme. Server supports: [{serverSchemes}]."); } - var accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + string accessToken; + using (discoverProbe?.Suspend()) + { + accessToken = await GetAccessTokenAsync(response, attemptedRefresh, usedAccessToken, cancellationToken).ConfigureAwait(false); + } using var retryRequest = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri); @@ -514,7 +525,7 @@ private async Task GetAuthServerMetadataAsync(Uri a AuthorizationServerMetadata? metadata; try { - var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); + using var response = await _httpClient.GetAsync(wellKnownEndpoint, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { continue; @@ -568,14 +579,16 @@ private async Task GetAuthServerMetadataAsync(Uri a // below, its existence disqualifies the legacy fallback that would otherwise synthesize S256. metadataDocumentFound = true; } - catch (McpException) + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested) { - // Metadata validation failures are security signals and must not fall back to - // another well-known endpoint. - throw; + // HttpClient's per-request timeout does not cancel the caller's token. + // Another metadata endpoint may still respond within the overall budget. + LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); + continue; } - catch (Exception ex) + catch (Exception ex) when (ex is HttpRequestException or IOException or JsonException) { + cancellationToken.ThrowIfCancellationRequested(); LogErrorFetchingAuthServerMetadata(ex, wellKnownEndpoint); continue; } @@ -602,10 +615,10 @@ private async Task GetAuthServerMetadataAsync(Uri a $"Authorization server metadata from '{wellKnownEndpoint}' does not advertise required PKCE method 'S256' in 'code_challenge_methods_supported'. Advertised methods: {advertisedMethods}."); } } - catch (Exception ex) + catch (McpException ex) { LogAuthServerMetadataMissingPkceSupport(ex, wellKnownEndpoint); - pkceValidationFailure = ex as McpException ?? new McpException(ex.Message, ex); + pkceValidationFailure = ex; continue; } diff --git a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs index 7563acd10..ae69392b0 100644 --- a/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/AutoDetectingClientSessionTransport.cs @@ -161,7 +161,7 @@ private async Task InitializeSseTransportAsync(JsonRpcMessage message, HttpReque try { LogAttemptingSSE(_name); - await sseTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + await sseTransport.ConnectAsync(cancellationToken, message.Context?.DiscoverProbe).ConfigureAwait(false); await sseTransport.SendMessageAsync(message, cancellationToken).ConfigureAwait(false); LogUsingSSE(_name); diff --git a/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs new file mode 100644 index 000000000..6ea4b8390 --- /dev/null +++ b/src/ModelContextProtocol.Core/Client/DiscoverProbe.cs @@ -0,0 +1,108 @@ +using System.Diagnostics; + +namespace ModelContextProtocol.Client; + +/// Tracks server-wait time without charging OAuth work to the discovery probe. +internal sealed class DiscoverProbe : IDisposable +{ + private static readonly TimeSpan s_maxTimerInterval = TimeSpan.FromMilliseconds(uint.MaxValue - 1); + private readonly object _sync = new(); + private readonly CancellationTokenSource _cancellation; + private readonly Stopwatch _elapsed = Stopwatch.StartNew(); + private readonly TimeSpan _timeout; + private readonly Timer _timer; + private int _suspensions; + private bool _disposed; + + public DiscoverProbe(TimeSpan timeout, CancellationToken cancellationToken) + { + _timeout = timeout; + _cancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _timer = new Timer(_ => OnTimeout(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + ScheduleTimer(); + } + + public CancellationToken Token => _cancellation.Token; + + public IDisposable Suspend() + { + lock (_sync) + { + Token.ThrowIfCancellationRequested(); + if (_suspensions++ == 0) + { + _elapsed.Stop(); + _timer.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); + } + } + return new Suspension(this); + } + + private void Resume() + { + lock (_sync) + { + if (_disposed || + --_suspensions != 0) + { + return; + } + + _elapsed.Start(); + ScheduleTimer(); + } + } + + private void ScheduleTimer() + { + if (_timeout == Timeout.InfiniteTimeSpan) + { + return; + } + + var remaining = _timeout - _elapsed.Elapsed; + var interval = remaining > s_maxTimerInterval ? s_maxTimerInterval : remaining; + _timer.Change(interval > TimeSpan.Zero ? interval : TimeSpan.Zero, Timeout.InfiniteTimeSpan); + } + + private void OnTimeout() + { + // Serialize expiry with suspension and disposal, including a timer callback that was + // already queued when OAuth suspended the budget. + lock (_sync) + { + if (_disposed || + _suspensions != 0 || + _cancellation.IsCancellationRequested) + { + return; + } + + var remaining = _timeout - _elapsed.Elapsed; + if (remaining > TimeSpan.Zero) + { + ScheduleTimer(); + return; + } + + _cancellation.Cancel(); + } + } + + public void Dispose() + { + lock (_sync) + { + _disposed = true; + _timer.Dispose(); + _cancellation.Dispose(); + } + } + + private sealed class Suspension(DiscoverProbe probe) : IDisposable + { + private DiscoverProbe? _probe = probe; + + public void Dispose() => Interlocked.Exchange(ref _probe, null)?.Resume(); + } +} diff --git a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs index d1f2a9d7a..f293f43b6 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientImpl.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientImpl.cs @@ -309,18 +309,20 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // Apply a probe timeout so dual-path clients don't block forever waiting for an // initialize-handshake server that silently drops unknown methods (per stdio.mdx fallback rules). - // The probe timeout is configurable via McpClientOptions.DiscoverProbeTimeout and is - // always bounded by InitializationTimeout (only applied when it is the tighter bound). + // OAuth temporarily suspends only the server-wait budget. InitializationTimeout + // and caller cancellation still bound the entire connection, including authorization. var probeTimeout = _options.DiscoverProbeTimeout; - using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(initializationCts.Token); - if (_options.InitializationTimeout > probeTimeout) - { - probeCts.CancelAfter(probeTimeout); - } + bool initializationBoundsProbe = + _options.InitializationTimeout != Timeout.InfiniteTimeSpan && + (probeTimeout == Timeout.InfiniteTimeSpan || + probeTimeout >= _options.InitializationTimeout); + using var probe = new DiscoverProbe( + initializationBoundsProbe ? Timeout.InfiniteTimeSpan : probeTimeout, + initializationCts.Token); try { - discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false); } catch (UnsupportedProtocolVersionException ex) { @@ -346,7 +348,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) } discoverVersion = retryVersion; - discoverResult = await SendDiscoverAsync(discoverVersion, probeCts.Token).ConfigureAwait(false); + discoverResult = await SendDiscoverAsync(discoverVersion).ConfigureAwait(false); } else { @@ -391,7 +393,8 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // server, so fall back. Other statuses stay uncaught and surface to the caller. fallbackToInitialize = true; } - catch (OperationCanceledException) when (probeCts.IsCancellationRequested && !initializationCts.IsCancellationRequested) + catch (OperationCanceledException) when (probe.Token.IsCancellationRequested && + !initializationCts.IsCancellationRequested) { // Probe timeout elapsed without a response. Per stdio.mdx fallback rules, no // response within a reasonable timeout means the server requires initialize. Fall back. @@ -453,19 +456,22 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) _serverInstructions = discoverResult.Instructions; } - async Task SendDiscoverAsync(string protocolVersion, CancellationToken cancellationToken) + async Task SendDiscoverAsync(string protocolVersion) { // Eagerly set the negotiated version so InjectRequestMetaIfNeeded recognizes us as being // on a per-request metadata revision when SendRequestAsync is invoked for server/discover. _negotiatedProtocolVersion = protocolVersion; _sessionHandler.NegotiatedProtocolVersion = protocolVersion; - return await SendRequestAsync( - RequestMethods.ServerDiscover, - new DiscoverRequestParams(), - McpJsonUtilities.JsonContext.Default.DiscoverRequestParams, - McpJsonUtilities.JsonContext.Default.DiscoverResult, - cancellationToken: cancellationToken).ConfigureAwait(false); + var request = new JsonRpcRequest + { + Method = RequestMethods.ServerDiscover, + Params = JsonSerializer.SerializeToNode(new DiscoverRequestParams(), McpJsonUtilities.JsonContext.Default.DiscoverRequestParams), + Context = new() { DiscoverProbe = probe }, + }; + var response = await SendRequestAsync(request, probe.Token).ConfigureAwait(false); + return JsonSerializer.Deserialize(response.Result, McpJsonUtilities.JsonContext.Default.DiscoverResult) + ?? throw new JsonException("Unexpected JSON result in discovery response."); } } else diff --git a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs index 61a0613df..eb09c13dc 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientOptions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientOptions.cs @@ -121,6 +121,11 @@ public sealed class McpClientOptions /// greater than or equal to , the probe is effectively bounded by /// alone. /// + /// + /// Time spent acquiring OAuth tokens, including authorization metadata discovery and interactive + /// authorization, is excluded from the probe timeout but remains subject to + /// and caller cancellation. + /// /// /// /// The value is not positive and is not . diff --git a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs index f4df789c7..d2d830084 100644 --- a/src/ModelContextProtocol.Core/Client/McpHttpClient.cs +++ b/src/ModelContextProtocol.Core/Client/McpHttpClient.cs @@ -14,7 +14,8 @@ internal class McpHttpClient(HttpClient httpClient) { internal static readonly MediaTypeHeaderValue s_applicationJsonContentType = new("application/json"); - internal virtual async Task SendAsync(HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken) + internal virtual async Task SendAsync( + HttpRequestMessage request, JsonRpcMessage? message, CancellationToken cancellationToken, DiscoverProbe? discoverProbe = null) { Debug.Assert(request.Content is null, "The request body should only be supplied as a JsonRpcMessage"); Debug.Assert(message is null || request.Method == HttpMethod.Post, "All messages should be sent in POST requests."); diff --git a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs index 99bdc1eb9..a08cfd033 100644 --- a/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs +++ b/src/ModelContextProtocol.Core/Client/SseClientSessionTransport.cs @@ -49,13 +49,13 @@ public SseClientSessionTransport( } /// - public async Task ConnectAsync(CancellationToken cancellationToken = default) + public async Task ConnectAsync(CancellationToken cancellationToken = default, DiscoverProbe? discoverProbe = null) { Debug.Assert(!IsConnected); try { // Start message receiving loop - _receiveTask = ReceiveMessagesAsync(_connectionCts.Token); + _receiveTask = ReceiveMessagesAsync(_connectionCts.Token, discoverProbe); await _connectionEstablished.Task.WaitAsync(_options.ConnectionTimeout, cancellationToken).ConfigureAwait(false); } @@ -149,7 +149,7 @@ public override async ValueTask DisposeAsync() } } - private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) + private async Task ReceiveMessagesAsync(CancellationToken cancellationToken, DiscoverProbe? discoverProbe) { HttpStatusCode? failureStatusCode = null; try @@ -158,7 +158,7 @@ private async Task ReceiveMessagesAsync(CancellationToken cancellationToken) request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("text/event-stream")); StreamableHttpClientSessionTransport.CopyAdditionalHeaders(request.Headers, _options.AdditionalHeaders, sessionId: null, protocolVersion: null); - using var response = await _httpClient.SendAsync(request, message: null, cancellationToken).ConfigureAwait(false); + using var response = await _httpClient.SendAsync(request, message: null, cancellationToken, discoverProbe).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { diff --git a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs index 0b56caa29..478fab0ea 100644 --- a/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs +++ b/src/ModelContextProtocol.Core/Protocol/JsonRpcMessageContext.cs @@ -15,6 +15,8 @@ namespace ModelContextProtocol.Protocol; /// public sealed class JsonRpcMessageContext { + internal Client.DiscoverProbe? DiscoverProbe { get; set; } + /// /// Gets or sets the transport the was received on or should be sent over. /// diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index c755cd9e5..5ded4dac4 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -67,46 +67,59 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt } private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( - MethodInfo method, McpServerPromptCreateOptions? options) => - new() + MethodInfo method, McpServerPromptCreateOptions? options) + { + var schemaOptions = options?.SchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; + return new() { Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? AIFunctionMcpServerTool.DeriveName(method), Description = options?.Description, MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - JsonSchemaCreateOptions = options?.SchemaCreateOptions, - ConfigureParameterBinding = pi => + JsonSchemaCreateOptions = schemaOptions with { - if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || - (options?.Services?.GetService() is { } ispis && - ispis.IsService(pi.ParameterType))) - { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - args.Services?.GetService(pi.ParameterType) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } - - if (pi.GetCustomAttribute() is { } keyedAttr) - { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - (args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } - - return default; + // Match injected parameters by their binding rules, not ParameterInfo identity. + IncludeParameter = parameter => + !ConfigureParameterBinding(parameter).ExcludeFromSchema && + (schemaOptions.IncludeParameter?.Invoke(parameter)).GetValueOrDefault(true), }, + ConfigureParameterBinding = ConfigureParameterBinding, }; + AIFunctionFactoryOptions.ParameterBindingOptions ConfigureParameterBinding(ParameterInfo pi) + { + if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || + (options?.Services?.GetService() is { } ispis && + ispis.IsService(pi.ParameterType))) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + args.Services?.GetService(pi.ParameterType) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } + + if (pi.GetCustomAttribute() is { } keyedAttr) + { + return new() + { + ExcludeFromSchema = true, + BindParameter = (pi, args) => + (args.Services is IKeyedServiceProvider keyedServices + ? keyedServices.GetKeyedService(pi.ParameterType, keyedAttr.Key) + : null) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } + + return default; + } + } + /// Creates an that wraps the specified . public static new AIFunctionMcpServerPrompt Create(AIFunction function, McpServerPromptCreateOptions? options) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 82b6ceb9d..d4447dc12 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -69,45 +69,59 @@ internal sealed partial class AIFunctionMcpServerTool : McpServerTool } private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( - MethodInfo method, McpServerToolCreateOptions? options) => - new() + MethodInfo method, McpServerToolCreateOptions? options) + { + var schemaOptions = options?.SchemaCreateOptions ?? AIJsonSchemaCreateOptions.Default; + return new() { Name = options?.Name ?? method.GetCustomAttribute()?.Name ?? DeriveName(method), Description = options?.Description, MarshalResult = static (result, _, cancellationToken) => new ValueTask(result), SerializerOptions = options?.SerializerOptions ?? McpJsonUtilities.DefaultOptions, - JsonSchemaCreateOptions = options?.SchemaCreateOptions, - ConfigureParameterBinding = pi => + JsonSchemaCreateOptions = schemaOptions with + { + // Schema generation can enumerate distinct ParameterInfo instances, including + // when the runtime's cold parameter cache is initialized concurrently. + IncludeParameter = parameter => + !ConfigureParameterBinding(parameter).ExcludeFromSchema && + (schemaOptions.IncludeParameter?.Invoke(parameter)).GetValueOrDefault(true), + }, + ConfigureParameterBinding = ConfigureParameterBinding, + }; + + AIFunctionFactoryOptions.ParameterBindingOptions ConfigureParameterBinding(ParameterInfo pi) + { + if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || + (options?.Services?.GetService() is { } ispis && + ispis.IsService(pi.ParameterType))) { - if (RequestServiceProvider.IsAugmentedWith(pi.ParameterType) || - (options?.Services?.GetService() is { } ispis && - ispis.IsService(pi.ParameterType))) + return new() { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - args.Services?.GetService(pi.ParameterType) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } + ExcludeFromSchema = true, + BindParameter = (pi, args) => + args.Services?.GetService(pi.ParameterType) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } - if (pi.GetCustomAttribute() is { } keyedAttr) + if (pi.GetCustomAttribute() is { } keyedAttr) + { + return new() { - return new() - { - ExcludeFromSchema = true, - BindParameter = (pi, args) => - (args?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ?? - (pi.HasDefaultValue ? null : - throw new ArgumentException("No service of the requested type was found.")), - }; - } + ExcludeFromSchema = true, + BindParameter = (pi, args) => + (args.Services is IKeyedServiceProvider keyedServices + ? keyedServices.GetKeyedService(pi.ParameterType, keyedAttr.Key) + : null) ?? + (pi.HasDefaultValue ? null : + throw new ArgumentException("No service of the requested type was found.")), + }; + } - return default; - }, - }; + return default; + } + } /// Creates an that wraps the specified . public static new AIFunctionMcpServerTool Create(AIFunction function, McpServerToolCreateOptions? options) diff --git a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs index fb4a77d0a..2baa603fb 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Client/McpSkillsClientExtensions.cs @@ -1,6 +1,7 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Skills; @@ -36,7 +37,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 +48,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 +60,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."); } /// @@ -123,6 +178,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 = envelope.Deserialize(McpSkillsJsonContext.Default.ListSkillsResult) ?? + 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 = entries[index]?.Deserialize(McpSkillsJsonContext.Default.Skill); + 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. /// 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/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/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..c3f2e6526 100644 --- a/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs +++ b/src/ModelContextProtocol.Extensions.Skills/Server/McpServerSkill.cs @@ -160,7 +160,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 +177,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,11 +206,12 @@ 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 @@ -212,7 +228,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,11 +249,12 @@ 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 @@ -249,7 +277,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 +305,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 +315,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) { @@ -420,7 +466,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 +489,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 +498,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 +525,7 @@ private static void CollectFiles(string root, string directory, List /// 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. /// /// @@ -154,6 +154,27 @@ 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. + /// + /// 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 @@ -188,9 +209,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 +235,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) @@ -305,9 +338,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 +377,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/SkillValidation.cs b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs index cb3ac0168..1268cd78d 100644 --- a/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs +++ b/src/ModelContextProtocol.Extensions.Skills/SkillValidation.cs @@ -1,3 +1,5 @@ +using System.Globalization; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Skills; @@ -46,29 +48,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; } /// @@ -216,18 +235,27 @@ public static void Validate(Skill skill, string paramName) 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) + 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); + } + } } if (skill.Resources is null) diff --git a/tests/Common/Utils/NodeHelpers.cs b/tests/Common/Utils/NodeHelpers.cs index 98c225dd9..6d803a14d 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 with a five-minute timeout. /// /// Arguments to pass to the conformance runner. /// Optional callback invoked for each captured stdout/stderr line. @@ -407,13 +405,15 @@ 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. 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) { - var startInfo = ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); + var startInfo = processStartInfo ?? ConformanceTestStartInfo(arguments, appendProtocolVersionFromEnv); var outputBuilder = new StringBuilder(); var errorBuilder = new StringBuilder(); @@ -484,34 +484,13 @@ private static (Version Core, string[] Prerelease) SplitSemVer(string version) var stdoutText = outputBuilder.ToString(); var stderrText = errorBuilder.ToString(); - // 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); - - return (success, stdoutText, stderrText); - } - - /// - /// 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. - /// - private static bool ConformanceOutputIndicatesSuccess(string output) - { - // 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) + bool success = process.ExitCode == 0; + if (!success) { - return false; + stderrText += $"{Environment.NewLine}Conformance runner exited with code {process.ExitCode}."; } - int passed = int.Parse(match.Groups[1].Value); - int total = int.Parse(match.Groups[2].Value); - int failed = int.Parse(match.Groups[3].Value); - - return passed == total && failed == 0 && total > 0; + return (success, stdoutText, stderrText); } /// diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs index 21fb98c2e..6bd8c749b 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixture.cs @@ -1,4 +1,4 @@ -using System.Diagnostics; +using Microsoft.AspNetCore.Builder; using ModelContextProtocol.Tests.Utils; namespace ModelContextProtocol.ConformanceTests; @@ -7,34 +7,42 @@ namespace ModelContextProtocol.ConformanceTests; /// Shared fixture that starts a single ConformanceServer exposing both the legacy stateful MCP /// lifecycle (at , "/") and the SEP-2575 stateless lifecycle (at /// , "/stateless") on one port. A single long-lived server avoids -/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows, and -/// centralizes the port-binding logic that the stateful and stateless conformance tests previously -/// duplicated. Shared by and +/// the TCP TIME_WAIT conflicts that per-test restarts on a fixed port cause on Windows. Kestrel +/// assigns a loopback port, and initialization awaits this host's startup before publishing its URL. +/// Shared by and /// via . /// public sealed class ConformanceServerFixture : IAsyncLifetime { - // Use different ports for each target framework to allow parallel execution across the - // multi-targeted test processes. net10.0 -> 3001, net9.0 -> 3002, net8.0 -> 3003. - private static int GetPortForTargetFramework() + private readonly Func _createApplication; + private readonly TimeProvider _timeProvider; + private WebApplication? _app; + private string? _serverUrl; + private CancellationTokenSource? _startupTimeoutCts; + private CancellationTokenSource? _startupCts; + private Task? _startupTask; + private Task? _stopTask; + private Task? _disposeTask; + private bool _startupOutcomeObserved; + + public ConformanceServerFixture() + : this(() => new ConformanceServer.Program().CreateApplication(["--urls", "http://127.0.0.1:0"])) { - var testBinaryDir = AppContext.BaseDirectory; - var targetFramework = Path.GetFileName(testBinaryDir.TrimEnd(Path.DirectorySeparatorChar)); + } - return targetFramework switch - { - "net10.0" => 3001, - "net9.0" => 3002, - "net8.0" => 3003, - _ => 3001 // Default fallback - }; + internal ConformanceServerFixture(Func createApplication) + : this(createApplication, TimeProvider.System) + { } - private Task? _serverTask; - private CancellationTokenSource? _serverCts; + internal ConformanceServerFixture(Func createApplication, TimeProvider timeProvider) + { + _createApplication = createApplication; + _timeProvider = timeProvider; + } /// Base URL of the stateful MCP endpoint (mapped at "/"). - public string ServerUrl { get; } = $"http://localhost:{GetPortForTargetFramework()}"; + public string ServerUrl => _serverUrl ?? throw new InvalidOperationException("The conformance server has not started."); /// /// URL of the stateless MCP endpoint (mapped at "/stateless"), used by the 2026-07-28 @@ -42,57 +50,115 @@ private static int GetPortForTargetFramework() /// public string StatelessServerUrl => $"{ServerUrl}/stateless"; - public async ValueTask InitializeAsync() - { - _serverCts = new CancellationTokenSource(); - _serverTask = Task.Run(() => ConformanceServer.Program.MainAsync( - ["--urls", ServerUrl], cancellationToken: _serverCts.Token)); + public ValueTask InitializeAsync() => InitializeAsync(TestContext.Current.CancellationToken); - // Wait for server to be ready (retry for up to 30 seconds) - var timeout = TimeSpan.FromSeconds(30); - var stopwatch = Stopwatch.StartNew(); - using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientPollingTimeout }; + internal async ValueTask InitializeAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + _startupTimeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30), _timeProvider); + _startupCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _startupTimeoutCts.Token); + _app = _createApplication(); + _startupTask = _app.StartAsync(_startupCts.Token); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var registration = _startupCts.Token.Register(() => cancelled.TrySetResult(true)); - while (stopwatch.Elapsed < timeout) + try { - try - { - await httpClient.GetAsync($"{ServerUrl}/health"); - return; - } - catch (HttpRequestException) + await Task.WhenAny(_startupTask, cancelled.Task); + if (_startupTask.IsCompleted) { - // Connection refused means server not ready yet - } - catch (TaskCanceledException) - { - // Timeout means server might be processing, give it more time + _startupOutcomeObserved = true; + await _startupTask; } + _startupCts.Token.ThrowIfCancellationRequested(); + _startupTimeoutCts.CancelAfter(Timeout.InfiniteTimeSpan); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _startupCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer failed to start within 30 seconds.", error); + } + catch (OperationCanceledException error) when (!cancellationToken.IsCancellationRequested && + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed.", error); + } - await Task.Delay(500); + if (!_app.Lifetime.ApplicationStarted.IsCancellationRequested || + _app.Lifetime.ApplicationStopping.IsCancellationRequested) + { + throw new InvalidOperationException("ConformanceServer stopped before startup completed."); } - throw new InvalidOperationException("ConformanceServer failed to start within the timeout period"); + _serverUrl = _app.Urls.Single(); } public async ValueTask DisposeAsync() { - if (_serverCts != null) + if (_app is null) + { + DisposeStartupCancellation(); + return; + } + + using var shutdownCts = new CancellationTokenSource(TestConstants.DefaultTimeout, _timeProvider); + try { - _serverCts.Cancel(); - if (_serverTask != null) + if (_startupCts is not null) { - try - { - await _serverTask.WaitAsync(TestConstants.DefaultTimeout); - } - catch + await _startupCts.CancelAsync().WaitAsync(shutdownCts.Token); + } + + if (_startupTask is not null) + { + // Wait for completion without throwing its already-reported startup error. + // A timed-out, still-running startup retains ownership of the app and tokens. + await WaitForCompletionAsync(_startupTask, shutdownCts.Token); + } + + shutdownCts.Token.ThrowIfCancellationRequested(); + _stopTask ??= _app.StopAsync(shutdownCts.Token); + await WaitForCompletionAsync(_stopTask, shutdownCts.Token); + shutdownCts.Token.ThrowIfCancellationRequested(); + _disposeTask ??= _app.DisposeAsync().AsTask(); + await _disposeTask.WaitAsync(shutdownCts.Token); + + var startupOutcome = Task.CompletedTask; + if (!_startupOutcomeObserved && + _startupTask is not null) + { + _startupOutcomeObserved = true; + bool expectedCancellation = _startupTask.IsCanceled && + (_startupCts?.IsCancellationRequested).GetValueOrDefault(); + if (!expectedCancellation) { - // Ignore exceptions during shutdown + startupOutcome = _startupTask; } } - _serverCts.Dispose(); + + _app = null; + _serverUrl = null; + DisposeStartupCancellation(); + + // Observe failures only after safely releasing the host; neither a late startup + // fault nor a StopAsync failure is converted into successful cleanup. + await Task.WhenAll(_stopTask, startupOutcome); } + catch (OperationCanceledException error) when (shutdownCts.IsCancellationRequested) + { + throw new TimeoutException("ConformanceServer cleanup did not complete within 60 seconds.", error); + } + } + + private async Task WaitForCompletionAsync(Task task, CancellationToken cancellationToken) + { + await Task.WhenAny(task).WaitAsync(cancellationToken); + } + + private void DisposeStartupCancellation() + { + _startupCts?.Dispose(); + _startupTimeoutCts?.Dispose(); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs new file mode 100644 index 000000000..214269477 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ConformanceServerFixtureTests.cs @@ -0,0 +1,351 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Time.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.ConformanceTests; +using ModelContextProtocol.Tests.Utils; +using System.Net; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class ConformanceServerFixtureTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Fact] + public async Task Initialize_ActualConformanceHosts_OwnDistinctReadyEndpoints() + { + await using var first = new ConformanceServerFixture(); + await using var second = new ConformanceServerFixture(); + await first.InitializeAsync(TestContext.Current.CancellationToken); + await second.InitializeAsync(TestContext.Current.CancellationToken); + Assert.NotEqual(first.ServerUrl, second.ServerUrl); + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + foreach (var fixture in new[] { first, second }) + { + var address = new Uri(fixture.ServerUrl); + Assert.True(address.IsLoopback); + Assert.NotEqual(0, address.Port); + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal("Healthy", await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken)); + + await using var transport = new HttpClientTransport(new() { Endpoint = new Uri(fixture.StatelessServerUrl) }); + await using var client = await McpClient.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); + Assert.NotEmpty(await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + } + } + + [Fact] + public async Task Initialize_WaitsForStartupBeforePublishingUrl() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(true); + await release.Task.WaitAsync(cancellationToken); + }))); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.False(startup.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + } + finally + { + release.TrySetResult(true); + } + await startup; + + using var httpClient = new HttpClient { Timeout = TestConstants.HttpClientTimeout }; + using var response = await httpClient.GetAsync($"{fixture.ServerUrl}/health", TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Initialize_StartupFault_SurfacesOriginalError() + { + var expected = new InvalidOperationException("Synthetic host startup failure."); + await using var fixture = new ConformanceServerFixture(() => CreateApplication( + _ => new StartupService(_ => Task.FromException(expected)))); + + var actual = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Same(expected, actual); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_HostStopsDuringStartup_DoesNotReportReady() + { + await using var fixture = new ConformanceServerFixture(() => CreateApplication(services => + new StartupService(_ => + { + services.GetRequiredService().StopApplication(); + return Task.CompletedTask; + }))); + + var error = await Assert.ThrowsAsync(() => fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask()); + Assert.Contains("stopped before startup completed", error.Message); + Assert.Throws(() => fixture.ServerUrl); + } + + [Fact] + public async Task Initialize_CallerCancellation_PropagatesToHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => startup); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_StartupTimeout_CancelsHostStartup() + { + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var observerRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var cancelled = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + await using var fixture = new ConformanceServerFixture(() => CreateApplication(_ => new StartupService(async cancellationToken => + { + entered.SetResult(cancellationToken); + try + { + await release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + await observerRelease.Task; + cancelled.TrySetResult(true); + throw; + } + }))); + + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + try + { + var startupToken = await entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => startup); + Assert.Contains("30 seconds", error.Message); + Assert.True(startupToken.IsCancellationRequested); + Assert.False(cancelled.Task.IsCompleted); + Assert.Throws(() => fixture.ServerUrl); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + observerRelease.TrySetResult(true); + Assert.True(await cancelled.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken)); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + finally + { + observerRelease.TrySetResult(true); + release.TrySetResult(true); + } + } + + [Fact] + public async Task Initialize_NoncooperativeStartup_IsBoundedAndOwnedUntilDrain() + { + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + var startup = fixture.InitializeAsync(TestContext.Current.CancellationToken).AsTask(); + Task? disposal = null; + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + await service.CancellationObserved.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + var error = await Assert.ThrowsAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("ConformanceServer failed to start within 30 seconds", error.Message); + + disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + await disposal.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await Assert.ThrowsAsync(() => startup); + if (disposal is not null) + { + await disposal; + } + else + { + await fixture.DisposeAsync(); + } + } + } + + [Fact] + public async Task Dispose_NoncooperativeStartup_ReportsDeadlineAndRetainsOwnership() + { + var clock = new FakeTimeProvider(); + var service = new NoncooperativeStartupService(); + var fixture = new ConformanceServerFixture( + () => CreateApplication(_ => service, useNoSocketServer: true), clock); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(disposal.IsCompleted); + clock.Advance(TestConstants.DefaultTimeout); + var error = await Assert.ThrowsAsync(() => + disposal.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + Assert.Contains("cleanup did not complete within 60 seconds", error.Message); + Assert.False(service.Disposed); + + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + [Fact] + public async Task Dispose_LateStartupFault_IsReportedAfterSafeCleanup() + { + var expected = new InvalidOperationException("Synthetic late startup failure."); + var service = new NoncooperativeStartupService { Failure = expected }; + var fixture = new ConformanceServerFixture(() => CreateApplication(_ => service, useNoSocketServer: true)); + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var startup = fixture.InitializeAsync(cancellation.Token).AsTask(); + try + { + await service.Entered.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + startup.WaitAsync(TestConstants.HttpClientPollingTimeout, TestContext.Current.CancellationToken)); + + var disposal = fixture.DisposeAsync().AsTask(); + Assert.False(service.Disposed); + service.Release.TrySetResult(true); + var actual = await Assert.ThrowsAsync(() => disposal); + Assert.Same(expected, actual); + Assert.True(service.Disposed); + Assert.False(service.DisposedDuringStartup); + } + finally + { + service.Release.TrySetResult(true); + await fixture.DisposeAsync(); + } + } + + private WebApplication CreateApplication(Func serviceFactory, bool useNoSocketServer = false) + { + var builder = WebApplication.CreateBuilder(["--urls", "http://127.0.0.1:0"]); + builder.Logging.ClearProviders(); + builder.Logging.AddProvider(XunitLoggerProvider); + if (useNoSocketServer) + { + builder.Services.AddSingleton(); + } + builder.Services.AddSingleton(serviceFactory); + var app = builder.Build(); + app.MapGet("/health", () => "Healthy"); + return app; + } + + private sealed class StartupService(Func start) : IHostedService + { + public Task StartAsync(CancellationToken cancellationToken) => start(cancellationToken); + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } + + private sealed class NoncooperativeStartupService : IHostedService, IDisposable + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource CancellationObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public bool StartupCompleted { get; private set; } + public bool Disposed { get; private set; } + public bool DisposedDuringStartup { get; private set; } + public Exception? Failure { get; init; } + + public async Task StartAsync(CancellationToken cancellationToken) + { + using var registration = cancellationToken.Register(() => CancellationObserved.TrySetResult(true)); + Entered.TrySetResult(true); + await Release.Task; + StartupCompleted = true; + if (Failure is { } error) + { + throw error; + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public void Dispose() + { + DisposedDuringStartup = !StartupCompleted; + Disposed = true; + } + } + + private sealed class NoSocketServer : IServer + { + public IFeatureCollection Features { get; } = new FeatureCollection(); + public Task StartAsync(IHttpApplication application, CancellationToken cancellationToken) where TContext : notnull => Task.CompletedTask; + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() { } + } +} diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs index 2cb1d736e..a47332a0d 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ServerConformanceTests.cs @@ -15,6 +15,28 @@ public class ServerConformanceTests( ConformanceServerFixture fixture, ITestOutputHelper output) { + [Theory] + [InlineData("sep-2640-skills-enumeration", "2026-07-28")] + [InlineData("sep-2640-skills-manifest", "2026-07-28")] + [InlineData("sep-2640-skills-directory", "2026-07-28")] + [InlineData("sep-2640-skills-enumeration", "2025-11-25")] + [InlineData("sep-2640-skills-manifest", "2025-11-25")] + [InlineData("sep-2640-skills-directory", "2025-11-25")] + public async Task RunSkillsConformanceTest(string scenario, string protocolVersion) + { + Assert.SkipWhen(!NodeHelpers.IsNodeInstalled(), "Node.js is not installed. Skipping conformance tests."); + var url = protocolVersion == "2026-07-28" ? fixture.StatelessServerUrl : fixture.ServerUrl; + + var result = await RunConformanceTestsAsync( + $"server --url {url} --scenario {scenario} --spec-version {protocolVersion} --force", + appendProtocolVersionFromEnv: false); + + Assert.True(result.Success, + $"Skills scenario '{scenario}' for '{protocolVersion}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); + Assert.DoesNotContain("SKIPPED: scenario", result.Output); + Assert.Matches(@"Passed:\s*[1-9]\d*/[1-9]\d*,\s*0\s*failed", result.Output); + } + [Fact] public async Task RunConformanceTests() { @@ -152,11 +174,13 @@ public async Task RunTasksExtensionConformanceTest(string scenario) $"Tasks extension conformance test '{scenario}' failed.\n\nStdout:\n{result.Output}\n\nStderr:\n{result.Error}"); } - private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync(string arguments) + private async Task<(bool Success, string Output, string Error)> RunConformanceTestsAsync( + string arguments, bool appendProtocolVersionFromEnv = true) { return await NodeHelpers.RunServerConformanceAsync( arguments, line => { try { output.WriteLine(line); } catch { } }, + appendProtocolVersionFromEnv: appendProtocolVersionFromEnv, cancellationToken: TestContext.Current.CancellationToken); } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs new file mode 100644 index 000000000..3f93b2821 --- /dev/null +++ b/tests/ModelContextProtocol.AspNetCore.Tests/SkillsAuthorizationTests.cs @@ -0,0 +1,151 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.AspNetCore.Tests.Utils; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Security.Claims; + +namespace ModelContextProtocol.AspNetCore.Tests; + +public class SkillsAuthorizationTests(ITestOutputHelper output) : KestrelInMemoryTest(output) +{ + private const string SkillUri = "skill://private/report/SKILL.md"; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SharedPolicyGuardsCatalogAndResourcePaths(bool allowed) + { + await using var app = await StartServer(allowed); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + if (allowed) + { + Assert.Equal(SkillUri, Assert.Single(skills).Uri); + Assert.Equal(SkillUri, Assert.Single(resources).Uri); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + var content = await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken); + Assert.Single(content.Contents); + } + else + { + Assert.Empty(skills); + Assert.Empty(resources); + var metadataError = await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidParams, metadataError.ErrorCode); + var resourceError = await Assert.ThrowsAsync( + async () => await client.ReadResourceAsync(SkillUri, cancellationToken: TestContext.Current.CancellationToken)); + Assert.Equal(McpErrorCode.InvalidRequest, resourceError.ErrorCode); + } + } + + [Fact] + public async Task ResourceListingOmissionDoesNotOverrideAnAuthorizedDirectRead() + { + await using var app = await StartServer(allowed: true, hideResourceListing: true); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + var skill = await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken); + Assert.Equal(SkillUri, skill.Uri); + Assert.Single((await client.ReadSkillResourceAsync(skill, SkillUri, TestContext.Current.CancellationToken)).Contents); + } + + [Fact] + public async Task UnauthenticatedRoleClaimsDoNotAuthorizeSkillAccess() + { + await using var app = await StartServer(allowed: true, authenticated: false); + await using var transport = new HttpClientTransport( + new() { Endpoint = new Uri("http://localhost:5000") }, HttpClient, LoggerFactory); + await using var client = await McpClient.CreateAsync( + transport, cancellationToken: TestContext.Current.CancellationToken); + + Assert.Empty(await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync( + async () => await client.GetSkillAsync(SkillUri, TestContext.Current.CancellationToken)); + } + + private async Task StartServer( + bool allowed, bool hideResourceListing = false, bool authenticated = true) + { + var skill = McpServerSkill.Create(SkillUri, + [ + McpServerSkillFile.FromText("SKILL.md", "---\nname: report\ndescription: Private report\n---\n"), + ]); + var policy = new ReadPolicy(); + var catalog = new AuthorizedCatalog(new InMemoryMcpSkillCatalog([skill.ProtocolSkill]), policy); + Builder.Services.AddMcpServer() + .WithHttpTransport(options => options.Stateless = true) + .WithSkills(catalog) + .WithResources(skill.Resources) + .WithRequestFilters(filters => + { + filters.AddListResourcesFilter(next => async (context, cancellationToken) => + { + var result = await next(context, cancellationToken); + result.Resources = result.Resources + .Where(resource => !hideResourceListing && policy.CanRead(context.User, resource.Uri)) + .ToList(); + return result; + }); + filters.AddReadResourceFilter(next => (context, cancellationToken) => + { + if (!policy.CanRead(context.User, context.Params.Uri)) + { + throw new McpProtocolException("Access forbidden.", McpErrorCode.InvalidRequest); + } + return next(context, cancellationToken); + }); + }); + + var app = Builder.Build(); + app.Use(async (context, next) => + { + context.User = new ClaimsPrincipal(new ClaimsIdentity( + [new Claim(ClaimTypes.Role, allowed ? "reader" : "viewer")], authenticated ? "Test" : null)); + await next(context); + }); + app.MapMcp(); + await app.StartAsync(TestContext.Current.CancellationToken); + return app; + } + + private sealed class ReadPolicy + { + public bool CanRead(ClaimsPrincipal? user, string uri) => + uri.StartsWith("skill://private/", StringComparison.Ordinal) && + (user?.Identity?.IsAuthenticated).GetValueOrDefault() && + (user?.IsInRole("reader")).GetValueOrDefault(); + } + + private sealed class AuthorizedCatalog(InMemoryMcpSkillCatalog catalog, ReadPolicy policy) : IMcpSkillCatalog + { + public async ValueTask ListAsync( + string? cursor, McpSkillRequestContext context, CancellationToken cancellationToken) + { + var page = await catalog.ListAsync(cursor, context, cancellationToken); + return new McpSkillPage + { + Skills = page.Skills.Where(skill => policy.CanRead(context.User, skill.Uri)).ToArray(), + NextCursor = page.NextCursor, + }; + } + + public ValueTask GetAsync( + string uri, McpSkillRequestContext context, CancellationToken cancellationToken) => + policy.CanRead(context.User, uri) + ? catalog.GetAsync(uri, context, cancellationToken) + : new ValueTask((Skill?)null); + } +} diff --git a/tests/ModelContextProtocol.ConformanceServer/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.Tests/Client/DiscoveryAuthorizationTests.cs b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs new file mode 100644 index 000000000..23f82add5 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/DiscoveryAuthorizationTests.cs @@ -0,0 +1,493 @@ +using ModelContextProtocol.Authentication; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Tests.Utils; +using System.IO.Pipelines; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ModelContextProtocol.Tests.Client; + +public class DiscoveryAuthorizationTests(ITestOutputHelper output) : LoggedTest(output) +{ + [Fact] + public async Task Discover_AuthorizesOncePerClient() + { + using var server = new AuthorizationServer(); + await ConnectTwiceAsync(server); + + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData(HttpTransportMode.AutoDetect)] + [InlineData(HttpTransportMode.StreamableHttp)] + public async Task Discover_MetadataHttpTimeout_TriesNextEndpoint(HttpTransportMode transportMode) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = "metadata" }; + using var httpClient = new HttpClient(server) { Timeout = TimeSpan.FromMilliseconds(400) }; + using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + caller.CancelAfter(TimeSpan.FromSeconds(8)); + await using var transport = CreateTransport(server, httpClient, transportMode); + var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(3) }; + + await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token); + + Assert.False(caller.IsCancellationRequested); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(2, server.MetadataRequests); + Assert.Single(server.AuthorizationStates); + Assert.Equal(1, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("large", false)] + [InlineData("maximum", false)] + [InlineData("infinite", false)] + [InlineData("large", true)] + [InlineData("maximum", true)] + [InlineData("infinite", true)] + public async Task Discover_AcceptsFullProbeTimeoutRange(string probeKind, bool infiniteInitialization) + { + using var server = new AuthorizationServer(); + using var httpClient = new HttpClient(server); + using var caller = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + caller.CancelAfter(TestConstants.DefaultTimeout); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions + { + DiscoverProbeTimeout = GetProbeTimeout(probeKind), + InitializationTimeout = infiniteInitialization ? Timeout.InfiniteTimeSpan : TimeSpan.FromSeconds(3), + }; + + await using var client = await McpClient.CreateAsync(transport, options, LoggerFactory, caller.Token); + + Assert.Equal("2026-07-28", client.NegotiatedProtocolVersion); + Assert.Equal(1, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("large")] + [InlineData("maximum")] + [InlineData("infinite")] + public async Task Discover_LargeOrInfiniteProbe_StillHonorsInitializationTimeout(string probeKind) + { + using var server = new AuthorizationServer { SilentBeforeAuthorization = true }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions + { + DiscoverProbeTimeout = GetProbeTimeout(probeKind), + InitializationTimeout = TimeSpan.FromMilliseconds(250), + }; + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.Equal(1, server.SilentProbeCancellations); + Assert.Equal(0, server.InitializeRequests); + } + + private TimeSpan GetProbeTimeout(string probeKind) => probeKind switch + { + "large" => TimeSpan.FromDays(60), + "maximum" => TimeSpan.MaxValue, + "infinite" => Timeout.InfiniteTimeSpan, + _ => throw new ArgumentOutOfRangeException(nameof(probeKind)), + }; + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_AuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + var connection = ConnectTwiceAsync(server); + try + { + await WaitForAuthorizationAsync(gate, connection); + // Keep the first authorization pending for longer than the configured probe. + // This timer exercises the deadline; the gate controls when authorization can complete. + await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout); + } + finally + { + gate.Release.TrySetResult(true); + } + await connection; + + Assert.Equal(0, gate.Cancellations); + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_CallerCancellationDuringAuthorization_Propagates(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + + var connection = McpClient.CreateAsync(transport, loggerFactory: LoggerFactory, cancellationToken: cancellation.Token); + await WaitForAuthorizationAsync(gate, connection); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync(() => connection); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(1, server.MetadataRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_InitializationTimeoutDuringAuthorization_Propagates(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions { InitializationTimeout = TimeSpan.FromSeconds(1) }; + + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken)); + + Assert.True(gate.Entered.Task.IsCompleted); + Assert.Equal(1, gate.Cancellations); + Assert.Equal(1, server.MetadataRequests); + Assert.Equal(0, server.InitializeRequests); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Discover_SilentServer_StillFallsBackWithinProbeBudget(bool afterAuthorization) + { + using var server = new AuthorizationServer { SilentBeforeAuthorization = !afterAuthorization, SilentAfterAuthorization = afterAuthorization }; + using var httpClient = new HttpClient(server); + await using var transport = CreateTransport(server, httpClient); + var options = new McpClientOptions { DiscoverProbeTimeout = TimeSpan.FromMilliseconds(250) }; + + await using var client = await McpClient.CreateAsync( + transport, options, LoggerFactory, TestContext.Current.CancellationToken); + + Assert.Equal("2025-11-25", client.NegotiatedProtocolVersion); + Assert.Equal(1, server.InitializeRequests); + Assert.Single(server.AuthorizationStates); + Assert.Equal(1, server.SilentProbeCancellations); + } + + [Theory] + [InlineData("callback")] + [InlineData("metadata")] + public async Task Discover_SseAuthorizationOutlivesProbe_WithoutRestartingAuthorization(string stage) + { + using var gate = new AuthorizationGate(); + using var server = new AuthorizationServer { Gate = gate, GatedStage = stage, UseLegacySse = true }; + var connection = ConnectTwiceAsync(server); + try + { + await WaitForAuthorizationAsync(gate, connection); + await gate.HoldPastProbeAsync(new McpClientOptions().DiscoverProbeTimeout); + } + finally + { + gate.Release.TrySetResult(true); + } + await connection; + + Assert.Equal(0, gate.Cancellations); + Assert.Equal(2, server.AuthorizationStates.Count); + Assert.Equal(2, server.AuthorizationStates.Distinct(StringComparer.Ordinal).Count()); + Assert.Equal(2, server.AuthorizedDiscoverRequests); + Assert.Equal(2, server.InitializeRequests); + } + + private async Task WaitForAuthorizationAsync(AuthorizationGate gate, Task connection) + { + await Task.WhenAny(gate.Entered.Task, connection).WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + if (connection.IsCompleted) + { + await connection; + } + Assert.True(gate.Entered.Task.IsCompleted, "The connection completed without entering authorization."); + } + + private async Task ConnectTwiceAsync(AuthorizationServer server) + { + using var httpClient = new HttpClient(server) { Timeout = TestConstants.DefaultTimeout }; + for (int connection = 0; connection < 2; connection++) + { + await using var transport = CreateTransport(server, httpClient); + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + } + + private HttpClientTransport CreateTransport( + AuthorizationServer server, HttpClient httpClient, HttpTransportMode transportMode = HttpTransportMode.AutoDetect) + { + return new HttpClientTransport(new() + { + Endpoint = new Uri("http://localhost/mcp"), + TransportMode = transportMode, + OAuth = new() + { + ClientId = "synthetic-client", + RedirectUri = new Uri("http://localhost/callback"), + AuthorizationCallbackHandler = server.AuthorizeAsync, + }, + }, httpClient, LoggerFactory); + } + + private sealed class AuthorizationGate : IDisposable + { + public TaskCompletionSource Entered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public int Cancellations { get; private set; } + + public async Task WaitAsync(CancellationToken cancellationToken) + { + if (!Entered.TrySetResult(true)) + { + return; + } + + try + { + await Release.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + Cancellations++; + throw; + } + } + + public async Task HoldPastProbeAsync(TimeSpan probeTimeout) + { + var elapsed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var timer = new Timer(_ => elapsed.TrySetResult(true), null, + probeTimeout + TimeSpan.FromSeconds(1), Timeout.InfiniteTimeSpan); + await elapsed.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken); + } + + public void Dispose() => Release.TrySetResult(true); + } + + private sealed class AuthorizationServer : HttpMessageHandler + { + private readonly List _ssePipes = []; + private Pipe? _ssePipe; + + public AuthorizationGate? Gate { get; init; } + public string? GatedStage { get; init; } + public bool UseLegacySse { get; init; } + public bool SilentBeforeAuthorization { get; init; } + public bool SilentAfterAuthorization { get; init; } + public List AuthorizationStates { get; } = []; + public int MetadataRequests { get; private set; } + public int AuthorizedDiscoverRequests { get; private set; } + public int InitializeRequests { get; private set; } + public int SilentProbeCancellations { get; private set; } + + public async Task AuthorizeAsync(AuthorizationCallbackContext context, CancellationToken cancellationToken) + { + var stateParameter = context.AuthorizationUri.Query.TrimStart('?').Split('&') + .Single(parameter => parameter.StartsWith("state=", StringComparison.Ordinal)); + var state = Uri.UnescapeDataString(stateParameter.Substring("state=".Length)); + AuthorizationStates.Add(state); + if (GatedStage == "callback") + { + await Assert.IsType(Gate).WaitAsync(cancellationToken); + } + return new AuthorizationResult { Code = "synthetic-code", State = state }; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var uri = Assert.IsType(request.RequestUri); + Assert.True(uri.IsLoopback); + switch (uri.AbsolutePath) + { + case "/.well-known/oauth-protected-resource": + return JsonResponse("""{"resource":"http://localhost/mcp","authorization_servers":["http://localhost"]}"""); + + case "/.well-known/oauth-authorization-server": + case "/.well-known/openid-configuration": + MetadataRequests++; + if (GatedStage == "metadata") + { + await Assert.IsType(Gate).WaitAsync(cancellationToken); + } + return JsonResponse(""" + {"issuer":"http://localhost","authorization_endpoint":"http://localhost/authorize", + "token_endpoint":"http://localhost/token","code_challenge_methods_supported":["S256"]} + """); + + case "/token": + return JsonResponse("""{"access_token":"synthetic-token","token_type":"Bearer","expires_in":300}"""); + + case "/mcp": + case "/messages": + if (UseLegacySse && + uri.AbsolutePath == "/mcp" && + request.Method == HttpMethod.Post) + { + return EmptyResponse(HttpStatusCode.MethodNotAllowed); + } + + if (UseLegacySse && + request.Method == HttpMethod.Get && + request.Headers.Authorization is not null) + { + _ssePipe = new Pipe(); + _ssePipes.Add(_ssePipe); + await _ssePipe.Writer.WriteAsync(Encoding.UTF8.GetBytes("event: endpoint\ndata: /messages\n\n"), cancellationToken); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(_ssePipe.Reader.AsStream()) + { + Headers = { ContentType = new MediaTypeHeaderValue("text/event-stream") }, + }, + }; + } + + JsonRpcMessage? message = null; + if (request.Content is { } content) + { + var body = await content.ReadAsStringAsync(cancellationToken); + message = JsonSerializer.Deserialize(body, McpJsonUtilities.DefaultOptions); + } + + if (SilentBeforeAuthorization && + message is JsonRpcRequest { Method: RequestMethods.ServerDiscover }) + { + await WaitForProbeCancellationAsync(cancellationToken); + } + + if (request.Headers.Authorization is null) + { + var challenge = EmptyResponse(HttpStatusCode.Unauthorized); + challenge.Headers.WwwAuthenticate.Add(new AuthenticationHeaderValue("Bearer", "resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")); + return challenge; + } + + if (message is JsonRpcNotification) + { + return EmptyResponse(HttpStatusCode.Accepted); + } + var rpc = Assert.IsType(message); + switch (rpc.Method) + { + case RequestMethods.ServerDiscover: + AuthorizedDiscoverRequests++; + if (SilentAfterAuthorization) + { + await WaitForProbeCancellationAsync(cancellationToken); + } + if (UseLegacySse) + { + return await ReplyAsync(new JsonRpcError + { + Id = rpc.Id, + Error = new() { Code = -32601, Message = "Method not found" }, + }, cancellationToken); + } + return await ReplyAsync(RpcResponse(rpc, new DiscoverResult + { + SupportedVersions = ["2026-07-28"], + Capabilities = new(), + }), cancellationToken); + + case RequestMethods.Initialize: + InitializeRequests++; + return await ReplyAsync(RpcResponse(rpc, new InitializeResult + { + ProtocolVersion = "2025-11-25", + ServerInfo = new() { Name = "synthetic-server", Version = "1.0" }, + Capabilities = new(), + }), cancellationToken); + } + throw new InvalidOperationException($"Unexpected RPC: {rpc.Method}"); + + default: + throw new InvalidOperationException($"Unexpected synthetic request: {request.Method} {uri}"); + } + } + + private async Task WaitForProbeCancellationAsync(CancellationToken cancellationToken) + { + var noResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + try + { + await noResponse.Task.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + SilentProbeCancellations++; + throw; + } + throw new InvalidOperationException("A silent probe must be cancelled, not completed."); + } + + private JsonRpcResponse RpcResponse(JsonRpcRequest request, TResult result) + { + return new JsonRpcResponse + { + Id = request.Id, + Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions), + }; + } + + private async Task ReplyAsync(JsonRpcMessage response, CancellationToken cancellationToken) + { + var json = JsonSerializer.Serialize(response, McpJsonUtilities.DefaultOptions); + if (UseLegacySse) + { + var pipe = Assert.IsType(_ssePipe); + await pipe.Writer.WriteAsync(Encoding.UTF8.GetBytes($"event: message\ndata: {json}\n\n"), cancellationToken); + return EmptyResponse(HttpStatusCode.Accepted); + } + return JsonResponse(json); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + foreach (var pipe in _ssePipes) + { + pipe.Writer.Complete(); + } + _ssePipes.Clear(); + } + base.Dispose(disposing); + } + + private HttpResponseMessage JsonResponse(string json) => new(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + + private HttpResponseMessage EmptyResponse(HttpStatusCode status) => new(status) + { + Content = new StringContent(string.Empty), + }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs index 2fa4222e0..6501275c5 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsClientValidationTests.cs @@ -16,6 +16,8 @@ namespace ModelContextProtocol.Tests.Client; /// public class McpSkillsClientValidationTests : ClientServerTestBase { + private JsonObject? _listResponse; + public McpSkillsClientValidationTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { @@ -32,12 +34,27 @@ 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 { @@ -74,13 +91,145 @@ 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); + } + [Fact] - public async Task ListSkillsAsync_RejectsAPageWithoutASkillsArray() + public async Task ListSkillsAsync_RejectsTitlecaseNames() + { + _listResponse = CreateListResponse("\u01c5"); + await using McpClient client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public async Task ListSkillsAsync_RejectsNonStringMetadataValues(string value) { + _listResponse = CreateListResponse("alpha", new JsonObject { ["value"] = JsonNode.Parse(value) }); await using McpClient client = await CreateMcpClientForServer(); + var exception = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("metadata", exception.Message); + } + + [Fact] + public async Task ListSkillsAsync_PreservesStringMetadataAndStructuredExtensionFields() + { + _listResponse = CreateListResponse("alpha", new JsonObject { ["version"] = "2.1" }); + var frontmatter = _listResponse["skills"]![0]!["frontmatter"]!.AsObject(); + frontmatter["custom"] = new JsonObject { ["count"] = 1, ["values"] = new JsonArray(true) }; + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.True(JsonNode.DeepEquals(frontmatter, Assert.Single(result).Frontmatter)); + } + + [Fact] + public async Task DiagnosticListingKeepsValidEntriesAndReportsTheInvalidOne() + { + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Equal("good", Assert.Single(result.Page.Skills).Name); + var rejected = Assert.Single(result.RejectedEntries); + Assert.Equal(1, rejected.Index); + Assert.IsType(rejected.Error); + } + + [Fact] + public async Task DiagnosticListingReportsMalformedEntriesAndPreservesPageHints() + { + _listResponse = CreateListResponse("alpha"); + var entries = _listResponse["skills"]!.AsArray(); + entries.Add(null); + entries.Add(JsonNode.Parse("42")); + entries.Add(new JsonObject()); + _listResponse["nextCursor"] = "next-page"; + _listResponse["ttlMs"] = 3000; + _listResponse["cacheScope"] = "public"; + _listResponse["resultType"] = "complete"; + await using var client = await CreateMcpClientForServer(); + + var result = await client.ListSkillsWithDiagnosticsAsync( + new ListSkillsRequestParams(), TestContext.Current.CancellationToken); + + Assert.Single(result.Page.Skills); + Assert.Equal(new[] { 1, 2, 3 }, result.RejectedEntries.Select(rejected => rejected.Index)); + Assert.Equal("next-page", result.Page.NextCursor); + Assert.Equal(TimeSpan.FromSeconds(3), result.Page.TimeToLive); + Assert.Equal(CacheScope.Public, result.Page.CacheScope); + Assert.Equal("complete", result.Page.ResultType); + Assert.Equal(4, entries.Count); + } + + [Theory] + [InlineData("no-skills-array")] + [InlineData("missing-skills-array")] + public async Task DiagnosticListingStillRejectsAnInvalidPageEnvelope(string cursor) + { + await using var client = await CreateMcpClientForServer(); + await Assert.ThrowsAsync( - async () => await client.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); } [Fact] @@ -111,4 +260,29 @@ public async Task GetSkillAsync_RejectsAnEntryWhoseManifestEscapesTheSkill() Assert.Contains("skills/get", exception.Message); Assert.Contains("'..'", exception.Message); } + + private JsonObject CreateListResponse(string name, JsonObject? metadata = null) + { + var uri = $"skill://catalog/{name}/SKILL.md"; + var frontmatter = new JsonObject { ["name"] = name, ["description"] = "d" }; + if (metadata is not null) + { + frontmatter["metadata"] = metadata; + } + + return new JsonObject + { + ["skills"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["frontmatter"] = frontmatter, + ["resources"] = new JsonArray(new JsonObject + { + ["uri"] = uri, + ["digest"] = "sha256:" + new string('a', 64), + ["size"] = 1, + }), + }), + }; + } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs new file mode 100644 index 000000000..3bdfa0c33 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpSkillsEnumerationTests.cs @@ -0,0 +1,190 @@ +#pragma warning disable MCPEXP002 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Skills; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class McpSkillsEnumerationTests : ClientServerTestBase +{ + private Func? _page; + private readonly List _cursors = []; + + public McpSkillsEnumerationTests(ITestOutputHelper output) : base(output) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder builder) + { + services.Configure(options => + { + options.Capabilities ??= new ServerCapabilities(); + options.Capabilities.Extensions ??= new Dictionary(); + options.Capabilities.Extensions[SkillsProtocol.ExtensionId] = new JsonObject(); + options.RequestHandlers ??= []; + options.RequestHandlers.Add(new McpServerRequestHandler + { + Method = SkillsProtocol.MethodSkillsList, + Handler = (request, _) => + { + _cursors.Add(request.Params?["cursor"]?.GetValue()); + return new ValueTask(_page!(_cursors.Count)); + }, + }); + }); + } + + [Theory] + [InlineData("repeat")] + [InlineData("")] + public async Task RepeatedCursorFailsInsteadOfLooping(string cursor) + { + _page = number => Page(cursor, $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("repeated", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task CursorCyclesAreRejectedEvenWhenTheRepeatIsNotAdjacent() + { + _page = number => Page(number == 2 ? "second" : "first", $"skill-{number}"); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task EmptyOpaqueCursorIsUsedBeforeNullEndsTheListing() + { + _page = number => number == 1 ? Page("", "first") : Page(null, "second"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(TestContext.Current.CancellationToken); + + Assert.Equal(new[] { "first", "second" }, skills.Select(skill => skill.Name)); + Assert.Equal(2, _cursors.Count); + Assert.Null(_cursors[0]); + Assert.Equal("", _cursors[1]); + } + + [Fact] + public async Task EverChangingCursorsStopAtTheRequestedPageLimit() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(3, 10, TestContext.Current.CancellationToken)); + + Assert.Contains("3 pages", error.Message); + Assert.Equal(3, _cursors.Count); + } + + [Fact] + public async Task DefaultPageLimitIsOneHundred() + { + _page = number => Page($"cursor-{number}"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + + Assert.Contains("100 pages", error.Message); + Assert.Equal(100, _cursors.Count); + } + + [Fact] + public async Task EntryLimitFailsWithoutReturningATruncatedList() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c", "d"); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(10, 3, TestContext.Current.CancellationToken)); + + Assert.Contains("3 entries", error.Message); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task LastPageMayExactlyReachBothLimits() + { + _page = number => number == 1 ? Page("next", "a", "b") : Page(null, "c"); + await using var client = await CreateMcpClientForServer(); + + var skills = await client.ListSkillsAsync(2, 3, TestContext.Current.CancellationToken); + + Assert.Equal(3, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Fact] + public async Task DefaultEntryLimitIsOneThousandAndCanBeChangedExplicitly() + { + _page = _ => Page(null, Enumerable.Range(0, 1001).Select(index => $"skill-{index}").ToArray()); + await using var client = await CreateMcpClientForServer(); + + var error = await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(TestContext.Current.CancellationToken)); + Assert.Contains("1000 entries", error.Message); + + var skills = await client.ListSkillsAsync(1, 1001, TestContext.Current.CancellationToken); + Assert.Equal(1001, skills.Count); + Assert.Equal(2, _cursors.Count); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-1, 1)] + [InlineData(1, 0)] + [InlineData(1, -1)] + public async Task InvalidLimitsFailBeforeRequestingAPage(int maxPages, int maxSkills) + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync( + async () => await client.ListSkillsAsync(maxPages, maxSkills, TestContext.Current.CancellationToken)); + Assert.Empty(_cursors); + } + + [Fact] + public async Task CancellationStopsBeforeRequestingAPage() + { + _page = _ => Page(null); + await using var client = await CreateMcpClientForServer(); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + await Assert.ThrowsAnyAsync( + async () => await client.ListSkillsAsync(cancellation.Token)); + Assert.Empty(_cursors); + } + + private JsonObject Page(string? nextCursor, params string[] names) + { + var entries = new JsonArray(); + foreach (var name in names) + { + entries.Add(new JsonObject + { + ["uri"] = $"skill://{name}/SKILL.md", + ["frontmatter"] = new JsonObject { ["name"] = name, ["description"] = "d" }, + ["resources"] = "dynamic", + }); + } + return new JsonObject { ["skills"] = entries, ["nextCursor"] = nextCursor }; + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/InMemoryMcpSkillCatalogTests.cs index 18b4caae0..25ecbd5e2 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; @@ -107,13 +110,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); } @@ -177,11 +184,67 @@ public async Task ReturnedEntries_AreCopies_SoMutatingThemDoesNotAffectLaterResp [InlineData("café")] [InlineData("数据分析")] [InlineData("données-2024")] + [InlineData("\U00010428")] + [InlineData("\U00020000")] public void Constructor_AcceptsUnicodeLowercaseNames(string name) { Assert.Equal(1, new InMemoryMcpSkillCatalog([CreateSkill(name)]).Count); } + [Theory] + [InlineData("\u01c5")] + [InlineData("\U00010400")] + public void Constructor_RejectsUppercaseOrTitlecaseNames(string name) + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(0xd801)] + [InlineData(0xdc28)] + public void Constructor_RejectsUnpairedSurrogates(int codeUnit) + { + var name = new string((char)codeUnit, 1); + + Assert.Throws(() => new InMemoryMcpSkillCatalog([CreateSkill(name)])); + } + + [Theory] + [InlineData(64, true)] + [InlineData(65, false)] + public void Constructor_CountsNameLengthByUnicodeScalar(int length, bool valid) + { + var name = string.Concat(Enumerable.Repeat("\U00010428", length)); + var skill = CreateSkill(name); + skill.Uri = $"skill://catalog/{name}/SKILL.md"; + skill.Resources = SkillResources.FromResources([new SkillResource { Uri = skill.Uri, Digest = s_validDigest, Size = 10 }]); + + if (valid) + { + Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); + } + else + { + Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + } + } + + [Theory] + [InlineData("1")] + [InlineData("true")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("null")] + public void Constructor_RejectsNonStringMetadataValues(string value) + { + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = JsonNode.Parse(value) }; + + var exception = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", exception.Message); + } + [Fact] public void Constructor_WithDuplicateUris_Throws() { @@ -213,15 +276,49 @@ public void Constructor_AcceptsFullAgentSkillsFrontmatter() Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); } + [Fact] + public void Constructor_ValidatesTheOwnedSnapshotAfterCustomSerialization() + { + var options = new JsonSerializerOptions { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; + var typeInfo = JsonMetadataServices.CreateValueInfo(options, new ChangingMetadataConverter()); + var value = JsonValue.Create(1, typeInfo)!; + Assert.Equal(JsonValueKind.String, value.GetValueKind()); + var skill = CreateSkill("alpha"); + skill.Frontmatter["metadata"] = new JsonObject { ["value"] = value }; + + var error = Assert.Throws(() => new InMemoryMcpSkillCatalog([skill])); + + Assert.Contains("metadata", error.Message); + } + + private sealed class ChangingMetadataConverter : JsonConverter + { + private int _writes; + + public override int Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => + throw new NotSupportedException(); + + public override void Write(Utf8JsonWriter writer, int value, JsonSerializerOptions options) + { + if (++_writes == 1) + { + writer.WriteStringValue("initial-string"); + } + else + { + writer.WriteNumberValue(value); + } + } + } + [Fact] public void Constructor_PassesThroughFieldsHostsDoNotActOn() { - // 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 retain structured values; the known metadata map has its own string-value requirement. var skill = CreateSkill("alpha"); skill.Frontmatter["allowed-tools"] = new JsonArray("Read", "Write"); skill.Frontmatter["license"] = null; - skill.Frontmatter["metadata"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; + skill.Frontmatter["custom"] = new JsonObject { ["version"] = 2.1, ["tags"] = new JsonArray("a") }; Assert.Equal(1, new InMemoryMcpSkillCatalog([skill]).Count); } 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/McpServerSkillsCatalogTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs index f3ae48a55..deba3df28 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsCatalogTests.cs @@ -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); } diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerSkillsFromDirectoryTests.cs index 7919fe04f..9fb27bbe3 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() { 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..8041ae7ee 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"]}""")] diff --git a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs index b3a0fbe72..f61e27231 100644 --- a/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/SkillFrontmatterRegressionTests.cs @@ -63,10 +63,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 +75,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 +90,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 +105,7 @@ public void SeparatedCommentsAndPlainHashes_RemainValid( { ["name"] = "comparison", ["description"] = expectedDescription, - ["metadata"] = new JsonObject { ["version"] = 1 }, + ["metadata"] = new JsonObject { ["version"] = "1" }, ["tags"] = new JsonArray("read"), }; diff --git a/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs new file mode 100644 index 000000000..c13181163 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Utils/ConformanceProcessTests.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; + +namespace ModelContextProtocol.Tests.Utils; + +public class ConformanceProcessTests +{ + [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] + 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); + } + + private ProcessStartInfo CreateStartInfo(int exitCode, bool passingSummary) + { + var summary = passingSummary + ? "Passed: 3/3, 0 failed, 0 warnings" + : "Passed: 2/3, 1 failed, 0 warnings"; + var script = $"console.log('Test Results:'); console.log('{summary}'); console.error('synthetic stderr'); process.exitCode = {exitCode};"; + return new ProcessStartInfo + { + FileName = "node", + Arguments = $"-e \"{script}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + } +}