Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dotnet/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<PackageVersion Include="Aspire.Hosting.Azure.CognitiveServices" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="Aspire.Microsoft.Azure.Cosmos" Version="$(AspireAppHostSdkVersion)" />
<PackageVersion Include="CommunityToolkit.Aspire.OllamaSharp" Version="13.5.0" />
<PackageVersion Include="MessagePack" Version="3.1.7" /> <!-- Transitive dependency of Aspire pinned to newer version due to vulnerability in 2.5.192 -->
<PackageVersion Include="MessagePack" Version="3.1.12" /> <!-- Transitive dependency of Aspire pinned to newer version due to vulnerability in 2.5.192 -->
<!-- Azure.* -->
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.29" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.6" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@ namespace Microsoft.Agents.AI;
/// <para>
/// Listed skill discovery is based on the Skills extension specification incorporating the
/// working group's decision dated <c>2026-09-08</c>.
/// Support currently covers a single <c>skills/list</c> result with resource lists and reading skill files,
/// not the full extension.
/// Support currently includes skill discovery through <c>skills/list</c> and reading listed skill resources,
/// but not the full extension.
/// </para>
/// <para>
/// Listed skills provide a name, description, and list of resources. Instructions and supporting resources
/// are fetched from the MCP server when requested. Only supporting resources listed by the server are available.
/// Each listed resource read must return exactly one text or binary content block; <c>SKILL.md</c> must contain text.
/// Discovery through <c>skills/list</c> leaves existing archive directories untouched.
/// Listed resource sizes are bounded by <see cref="AgentMcpSkillsSourceOptions"/>.
/// Page counts, total skill counts, and individual resource sizes
/// are bounded by <see cref="AgentMcpSkillsSourceOptions"/>. Discovery follows continuation cursors
/// until exhausted and fails on repeated cursors, invalid pages, or exceeded limits instead of returning a partial catalog.
/// These checks do not limit incoming messages before the supplied client receives and parses them.
/// </para>
/// <para>
Expand Down Expand Up @@ -95,6 +97,14 @@ namespace Microsoft.Agents.AI;
/// not trustworthiness: a server controlling both the index and archive can replace both or omit
/// the digest.
/// </para>
/// <para>
/// This source does not impose a byte limit on individual or combined <c>skills/list</c> responses.
/// Page-count, skill-count, and resource-size limits do not bound incoming message size or total
/// memory consumption. A malicious, compromised, or misconfigured server can return oversized
/// responses that cause excessive memory consumption. Application developers are responsible for
/// configuring appropriate input-size and resource limits through their chosen MCP client, transport,
/// or hosting environment.
/// </para>
/// </remarks>
internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ namespace Microsoft.Agents.AI;
/// Configuration options for <see cref="AgentMcpSkillsSource"/>.
/// </summary>
/// <remarks>
/// The listed resource size limit bounds resource content processed by this source.
/// These checks do not limit incoming messages before the supplied client receives and parses them.
/// Listed discovery limits bound page counts and total skill counts.
/// The listed resource size limit separately bounds resource content processed by this source.
/// These limits do not bound incoming message size, list-response JSON size, or total memory consumption.
/// Application developers are responsible for evaluating server trustworthiness and configuring appropriate
/// input-size and resource limits through their chosen MCP client, transport, or hosting environment.
/// See <see cref="AgentSkillsProviderBuilderMcpExtensions.UseMcpSkills"/> for security considerations.
/// <strong>Security consideration:</strong> The archive limits (<see cref="ArchiveMaxFileCount"/>,
/// <see cref="ArchiveMaxSizeBytes"/>, <see cref="ArchiveMaxUncompressedSizeBytes"/>) bound how much a
/// single MCP server response can extract to local disk. Since the server supplying these archives is
Expand All @@ -18,6 +22,25 @@ namespace Microsoft.Agents.AI;
/// </remarks>
public sealed class AgentMcpSkillsSourceOptions
{
/// <summary>
/// Gets or sets the maximum number of <c>skills/list</c> pages requested per discovery.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, defaults to <c>10</c>. Must be positive.
/// Empty pages count toward this limit. Discovery fails rather than returning a partial catalog
/// when the last allowed page includes a continuation cursor.
/// </remarks>
public int? ListedMaxPageCount { get; set; }

/// <summary>
/// Gets or sets the maximum number of skills accepted across all <c>skills/list</c> pages per discovery.
/// </summary>
/// <remarks>
/// When <see langword="null"/>, defaults to <c>100</c>. Must be positive.
/// Every entry counts, including duplicate skill URIs. Discovery fails if the limit is exceeded.
/// </remarks>
public int? ListedMaxSkillCount { get; set; }

/// <summary>
/// Gets or sets the maximum size, in bytes, of a resource discovered through <c>skills/list</c>,
/// including <c>SKILL.md</c>.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@ public static class AgentSkillsProviderBuilderMcpExtensions
/// <c>io.modelcontextprotocol/skills</c> extension. Otherwise, discovers skills through
/// <c>skill://index.json</c>.
/// Listed skills fetch instructions and supporting resources when requested.
/// Their resource sizes are bounded by <see cref="AgentMcpSkillsSourceOptions"/>.
/// Page counts, total skill counts, and individual resource sizes
/// are bounded by <see cref="AgentMcpSkillsSourceOptions"/>.
/// These checks do not limit incoming messages before the supplied client receives and parses them.
/// </para>
/// <para>
/// Listed skill discovery is based on the Skills extension specification incorporating the
/// working group's decision dated <c>2026-09-08</c>.
/// Support currently covers a single <c>skills/list</c> result with resource lists and reading skill files,
/// not the full extension.
/// Support currently includes skill discovery through <c>skills/list</c> and reading listed skill resources,
/// but not the full extension.
/// </para>
/// <para>
/// Index-based discovery and archive-distributed skills are compatibility features and will be deprecated
Expand All @@ -44,6 +45,14 @@ public static class AgentSkillsProviderBuilderMcpExtensions
/// or to exfiltrate data through instructions or scripts the agent is induced to run. Only connect
/// to MCP servers you trust and have evaluated, and treat their responses as untrusted input.
/// </para>
/// <para>
/// This source does not impose a byte limit on individual or combined <c>skills/list</c> responses.
/// Page-count, skill-count, and resource-size limits do not bound incoming message size or total
/// memory consumption. A malicious, compromised, or misconfigured server can return oversized
/// responses that cause excessive memory consumption. Application developers are responsible for
/// configuring appropriate input-size and resource limits through their chosen MCP client, transport,
/// or hosting environment.
/// </para>
/// </remarks>
public static AgentSkillsProviderBuilder UseMcpSkills(this AgentSkillsProviderBuilder builder, McpClient client, AgentMcpSkillsSourceOptions? options = null)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,74 +28,136 @@ internal sealed partial class McpListedSkillsLoader
{
private const string CompleteResultType = "complete";

private const int DefaultMaxPageCount = 10;
private const int DefaultMaxSkillCount = 100;

private readonly McpClient _client;
private readonly ILogger _logger;
private readonly long _maxResourceSizeBytes;
private readonly int _maxPageCount;
private readonly int _maxSkillCount;

/// <summary>
/// Initializes a loader for skills discovered through <c>skills/list</c>.
/// </summary>
/// <param name="client">The MCP client used to discover skills and fetch their content.</param>
/// <param name="logger">Optional logger for frontmatter parsing diagnostics.</param>
/// <param name="options">Optional size limit for listed resources.</param>
/// <param name="options">Optional discovery limits and size limit for listed resources.</param>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException">A configured size limit is not positive.</exception>
/// <exception cref="ArgumentOutOfRangeException">A configured limit is not positive.</exception>
public McpListedSkillsLoader(McpClient client, ILogger? logger = null, AgentMcpSkillsSourceOptions? options = null)
{
this._client = Throw.IfNull(client);
this._logger = logger ?? NullLogger.Instance;
this._maxResourceSizeBytes = options?.ListedResourceMaxSizeBytes ?? AgentMcpListedSkill.DefaultMaxResourceSizeBytes;
this._maxPageCount = options?.ListedMaxPageCount ?? DefaultMaxPageCount;
this._maxSkillCount = options?.ListedMaxSkillCount ?? DefaultMaxSkillCount;
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxResourceSizeBytes, nameof(options.ListedResourceMaxSizeBytes));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxPageCount, nameof(options.ListedMaxPageCount));
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxSkillCount, nameof(options.ListedMaxSkillCount));
}

/// <summary>
/// Gets the server's listed skills without fetching their instructions or resources.
/// Gets the server's listed skills across all pages without fetching their instructions or resources.
/// </summary>
/// <param name="cancellationToken">The token used to cancel discovery.</param>
/// <returns>The skills discovered through <c>skills/list</c>.</returns>
/// <exception cref="JsonException">The response cannot be deserialized into a skill list.</exception>
/// <exception cref="InvalidOperationException">The response is missing or contains an unsupported result type,
/// invalid skill metadata or resource manifests, or a resource exceeding the configured size limit.</exception>
/// invalid skill metadata or resource manifests, a repeated pagination cursor, or a configured limit is exceeded.</exception>
/// <exception cref="McpException">The MCP request fails.</exception>
/// <exception cref="OperationCanceledException">Discovery is canceled.</exception>
public async Task<IList<AgentSkill>> DiscoverAsync(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();

JsonRpcResponse response = await this._client.SendRequestAsync(new JsonRpcRequest
List<McpListedSkillEntry> skillEntries = await this.ListSkillEntriesAsync(cancellationToken).ConfigureAwait(false);
List<AgentSkill> skills = [];

foreach (McpListedSkillEntry entry in skillEntries)
{
Method = "skills/list",
Params = new JsonObject(),
}, cancellationToken).ConfigureAwait(false);
cancellationToken.ThrowIfCancellationRequested();

McpSkillsListResult result = response.Result?.Deserialize(McpListedSkillsJsonContext.Default.McpSkillsListResult)
?? throw new InvalidOperationException("The server returned no skills/list result.");
AgentSkillFrontmatter frontmatter = CreateFrontmatter(entry);
List<McpListedSkillResourceEntry> resources = this.CreateSupportingResources(entry);

if (result.ResultType != CompleteResultType)
{
throw new InvalidOperationException($"A skills/list result must have resultType '{CompleteResultType}'. Received resultType '{result.ResultType}'.");
skills.Add(new AgentMcpListedSkill(frontmatter, entry.Uri, resources, this._client, this._logger, this._maxResourceSizeBytes));
}

List<AgentSkill> skills = [];
cancellationToken.ThrowIfCancellationRequested();

foreach (McpListedSkillEntry? entry in result.Skills)
return skills;
}

private async Task<List<McpListedSkillEntry>> ListSkillEntriesAsync(CancellationToken cancellationToken)
{
List<McpListedSkillEntry> skillEntries = [];
HashSet<string> cursors = new(StringComparer.Ordinal);
string? cursor = null;
int pageCount = 0;

do
{
cancellationToken.ThrowIfCancellationRequested();

// Nullable annotations do not reject null collection elements during deserialization.
if (entry is null)
if (pageCount >= this._maxPageCount)
{
throw new InvalidOperationException("The skills/list result contains a null skill entry.");
throw new InvalidOperationException($"Skill discovery exceeds the maximum allowed page count ({this._maxPageCount}).");
}

AgentSkillFrontmatter frontmatter = CreateFrontmatter(entry);
JsonObject parameters = new();
if (cursor is not null)
{
parameters["cursor"] = cursor;
}

List<McpListedSkillResourceEntry> resources = this.CreateSupportingResources(entry);
JsonRpcResponse response = await this._client.SendRequestAsync(new JsonRpcRequest
{
Method = "skills/list",
Params = parameters,
}, cancellationToken).ConfigureAwait(false);

skills.Add(new AgentMcpListedSkill(frontmatter, entry.Uri, resources, this._client, this._logger, this._maxResourceSizeBytes));
pageCount++;

cancellationToken.ThrowIfCancellationRequested();

JsonNode resultNode = response.Result
?? throw new InvalidOperationException("The server returned no skills/list result.");

// Reject excessive catalogs before deserializing their typed metadata or constructing skills.
if (resultNode is JsonObject resultObject && resultObject["skills"] is JsonArray entries && entries.Count > this._maxSkillCount - skillEntries.Count)
{
throw new InvalidOperationException($"Skill discovery exceeds the maximum allowed skill count ({this._maxSkillCount}).");
}

McpSkillsListResult result = resultNode.Deserialize(McpListedSkillsJsonContext.Default.McpSkillsListResult)
?? throw new InvalidOperationException("The server returned no skills/list result.");

if (result.ResultType != CompleteResultType)
{
throw new InvalidOperationException($"A skills/list result must have resultType '{CompleteResultType}'. Received resultType '{result.ResultType}'.");
}

cursor = result.NextCursor;
if (cursor is not null && !cursors.Add(cursor))
{
throw new InvalidOperationException("The skills/list result contains a repeated pagination cursor.");
}

foreach (McpListedSkillEntry? entry in result.Skills)
{
// Nullable annotations do not reject null collection elements during deserialization.
if (entry is null)
{
throw new InvalidOperationException("The skills/list result contains a null skill entry.");
}

skillEntries.Add(entry);
}
}
while (cursor is not null);

return skills;
return skillEntries;
}

private static AgentSkillFrontmatter CreateFrontmatter(McpListedSkillEntry entry)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ internal sealed class McpSkillsListResult
/// Gets the skills listed by the server.
/// </summary>
public required IReadOnlyList<McpListedSkillEntry?> Skills { get; init; }

/// <summary>
/// Gets the opaque cursor to pass to the next request, or <see langword="null"/> when no more pages are available.
/// </summary>
public string? NextCursor { get; init; }
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ public sealed class AgentMcpListedSkillsSerializationTests
[InlineData("""{"resultType":[],"skills":[]}""")]
[InlineData("""{"ResultType":"complete","skills":[]}""")]
[InlineData("""{"resultType":"complete","Skills":[]}""")]
[InlineData("""{"resultType":"complete","skills":[],"nextCursor":42}""")]
[InlineData("""{"resultType":"complete","skills":[],"nextCursor":true}""")]
[InlineData("""{"resultType":"complete","skills":[],"nextCursor":[]}""")]
[InlineData("""{"resultType":"complete","skills":[],"nextCursor":{}}""")]
public void Deserialize_InvalidResult_Throws(string json)
{
// Arrange
Expand All @@ -38,6 +42,28 @@ public void Deserialize_InvalidResult_Throws(string json)
Assert.Throws<JsonException>(() => JsonSerializer.Deserialize(json, context.McpSkillsListResult));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("opaque cursor:+/=\u00e9")]
public void Deserialize_NextCursor_PreservesValue(string? cursor)
{
// Arrange
JsonObject node = new()
{
["resultType"] = "complete",
["skills"] = new JsonArray(),
["nextCursor"] = cursor,
};

// Act
var result = node.Deserialize(McpListedSkillsJsonContext.Default.McpSkillsListResult);

// Assert
Assert.NotNull(result);
Assert.Equal(cursor, result.NextCursor);
}

[Theory]
[InlineData("uri", "null")]
[InlineData("uri", "42")]
Expand Down Expand Up @@ -168,6 +194,7 @@ public void Deserialize_CaseVariants_IgnoresUnknownCaseVariants()
Assert.NotNull(result);
Assert.Equal("complete", result.ResultType);
Assert.Empty(result.Skills);
Assert.Null(result.NextCursor);
}

[Theory]
Expand Down
Loading
Loading