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