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); + } +}