From 8079795ddcbebbaee5b956f0151f5090cf7eb0f6 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 16 Oct 2026 17:56:19 +0100 Subject: [PATCH 1/3] .NET: Add pagination to MCP-listed skill discovery Follow skills/list continuation cursors and bound paginated discovery by configurable page, skill, and combined result-size limits. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ae2c03-d223-4731-bc81-d3021698265c --- .../Skills/AgentMcpSkillsSource.cs | 8 +- .../Skills/AgentMcpSkillsSourceOptions.cs | 34 +- ...AgentSkillsProviderBuilderMcpExtensions.cs | 7 +- .../Skills/Listed/McpListedSkillsLoader.cs | 125 +++++- .../Skills/Listed/McpSkillsListResult.cs | 5 + .../AgentMcpListedSkillsSerializationTests.cs | 27 ++ .../Listed/AgentMcpListedSkillsTests.cs | 383 +++++++++++++++++- 7 files changed, 557 insertions(+), 32 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs index 07ccfb73eb9..1014ebe8bdc 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs @@ -27,15 +27,17 @@ namespace Microsoft.Agents.AI; /// /// Listed skill discovery is based on the Skills extension specification incorporating the /// working group's decision dated 2026-09-08. -/// Support currently covers a single skills/list result with resource lists and reading skill files, -/// not the full extension. +/// Support currently includes skill discovery through skills/list and reading listed skill resources, +/// but not the full extension. /// /// /// 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; SKILL.md must contain text. /// Discovery through skills/list leaves existing archive directories untouched. -/// Listed resource sizes are bounded by . +/// Page counts, total skill counts, combined list-result JSON size, and individual resource sizes +/// are bounded by . 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. /// /// diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs index 5d5570f5014..275ee32d95d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs @@ -8,7 +8,8 @@ namespace Microsoft.Agents.AI; /// Configuration options for . /// /// -/// The listed resource size limit bounds resource content processed by this source. +/// Listed discovery limits bound pages, skill counts, and combined list-result JSON size. +/// The listed resource size limit separately bounds resource content processed by this source. /// These checks do not limit incoming messages before the supplied client receives and parses them. /// Security consideration: The archive limits (, /// , ) bound how much a @@ -18,6 +19,37 @@ namespace Microsoft.Agents.AI; /// public sealed class AgentMcpSkillsSourceOptions { + /// + /// Gets or sets the maximum number of skills/list pages requested per discovery. + /// + /// + /// When , defaults to 10. 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. + /// + public int? ListedMaxPageCount { get; set; } + + /// + /// Gets or sets the maximum number of skills accepted across all skills/list pages per discovery. + /// + /// + /// When , defaults to 100. Must be positive. + /// Every entry counts, including duplicate skill URIs. Discovery fails if the limit is exceeded. + /// + public int? ListedMaxSkillCount { get; set; } + + /// + /// Gets or sets the maximum combined size, in bytes, of skills/list results per discovery. + /// + /// + /// When , defaults to 10 MiB (10,485,760 bytes). Must be positive. + /// Counts the compact UTF-8 JSON representation of each result, including unknown fields, + /// not the original wire bytes or runtime object sizes. Discovery fails if the limit is exceeded. + /// The check occurs after the supplied MCP client receives and parses each result, but before + /// typed metadata deserialization and skill construction. There is no separate resource-count limit per skill. + /// + public long? ListedMaxResultSizeBytes { get; set; } + /// /// Gets or sets the maximum size, in bytes, of a resource discovered through skills/list, /// including SKILL.md. diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs index 7a8eef5491c..ac8de13933f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs @@ -23,14 +23,15 @@ public static class AgentSkillsProviderBuilderMcpExtensions /// io.modelcontextprotocol/skills extension. Otherwise, discovers skills through /// skill://index.json. /// Listed skills fetch instructions and supporting resources when requested. - /// Their resource sizes are bounded by . + /// Page counts, total skill counts, combined list-result JSON size, and individual resource sizes + /// are bounded by . /// These checks do not limit incoming messages before the supplied client receives and parses them. /// /// /// Listed skill discovery is based on the Skills extension specification incorporating the /// working group's decision dated 2026-09-08. - /// Support currently covers a single skills/list result with resource lists and reading skill files, - /// not the full extension. + /// Support currently includes skill discovery through skills/list and reading listed skill resources, + /// but not the full extension. /// /// /// Index-based discovery and archive-distributed skills are compatibility features and will be deprecated diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs index b2342ab9947..04f290b5ea8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -28,74 +29,154 @@ internal sealed partial class McpListedSkillsLoader { private const string CompleteResultType = "complete"; + private const int DefaultMaxPageCount = 10; + private const int DefaultMaxSkillCount = 100; + private const long DefaultMaxResultSizeBytes = 10 * 1024 * 1024; + private readonly McpClient _client; private readonly ILogger _logger; private readonly long _maxResourceSizeBytes; + private readonly int _maxPageCount; + private readonly int _maxSkillCount; + private readonly long _maxResultSizeBytes; /// /// Initializes a loader for skills discovered through skills/list. /// /// The MCP client used to discover skills and fetch their content. /// Optional logger for frontmatter parsing diagnostics. - /// Optional size limit for listed resources. + /// Optional discovery limits and size limit for listed resources. /// is . - /// A configured size limit is not positive. + /// A configured limit is not positive. 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; + this._maxResultSizeBytes = options?.ListedMaxResultSizeBytes ?? DefaultMaxResultSizeBytes; ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxResourceSizeBytes, nameof(options.ListedResourceMaxSizeBytes)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxPageCount, nameof(options.ListedMaxPageCount)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxSkillCount, nameof(options.ListedMaxSkillCount)); + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxResultSizeBytes, nameof(options.ListedMaxResultSizeBytes)); } /// - /// 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. /// /// The token used to cancel discovery. /// The skills discovered through skills/list. /// The response cannot be deserialized into a skill list. /// The response is missing or contains an unsupported result type, - /// invalid skill metadata or resource manifests, or a resource exceeding the configured size limit. + /// invalid skill metadata or resource manifests, a repeated pagination cursor, or a configured limit is exceeded. /// The MCP request fails. /// Discovery is canceled. public async Task> DiscoverAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - JsonRpcResponse response = await this._client.SendRequestAsync(new JsonRpcRequest + List skillEntries = await this.ListSkillEntriesAsync(cancellationToken).ConfigureAwait(false); + List 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 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 skills = []; + cancellationToken.ThrowIfCancellationRequested(); + + return skills; + } + + private async Task> ListSkillEntriesAsync(CancellationToken cancellationToken) + { + List skillEntries = []; + HashSet cursors = new(StringComparer.Ordinal); + string? cursor = null; + int pageCount = 0; + long totalResultSizeBytes = 0; - foreach (McpListedSkillEntry? entry in result.Skills) + 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 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})."); + } + + using (Utf8JsonWriter writer = new(Stream.Null)) + { + resultNode.WriteTo(writer); + writer.Flush(); + + if (writer.BytesCommitted > this._maxResultSizeBytes - totalResultSizeBytes) + { + throw new InvalidOperationException($"Skill discovery exceeds the maximum allowed combined list-result size ({this._maxResultSizeBytes} bytes)."); + } + + totalResultSizeBytes += writer.BytesCommitted; + } + + 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) diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpSkillsListResult.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpSkillsListResult.cs index 7fecfbd22a7..0cd6b6e2fcd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpSkillsListResult.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpSkillsListResult.cs @@ -19,6 +19,11 @@ internal sealed class McpSkillsListResult /// Gets the skills listed by the server. /// public required IReadOnlyList Skills { get; init; } + + /// + /// Gets the opaque cursor to pass to the next request, or when no more pages are available. + /// + public string? NextCursor { get; init; } } /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsSerializationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsSerializationTests.cs index 948e67aff41..73e7250b9ac 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsSerializationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsSerializationTests.cs @@ -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 @@ -38,6 +42,28 @@ public void Deserialize_InvalidResult_Throws(string json) Assert.Throws(() => 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")] @@ -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] diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs index fb6c0a75bf6..cecd754bdf0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs @@ -56,6 +56,372 @@ public async Task GetSkillsAsync_ListRequest_ProjectsFrontmatterAndIncludesReque Assert.NotNull(request["_meta"]![MetaKeys.ClientInfo]); } + [Theory] + [InlineData("")] + [InlineData("opaque cursor:+/=\u00e9")] + public async Task GetSkillsAsync_PaginatedList_FollowsOpaqueCursorsAndEmptyPagesAsync(string firstCursor) + { + // Arrange + List requests = []; + List reads = []; + await using var server = CreateServer((request, _) => + { + requests.Add(request.Params?.DeepClone()); + JsonObject result = requests.Count switch + { + 1 => ListResult(Entry("skill://first/unit-converter/")), + 2 => ListResult(), + _ => ListResult(Entry("skill://second/unit-converter/")), + }; + if (requests.Count < 3) + { + result["nextCursor"] = requests.Count == 1 ? firstCursor : "last-page"; + } + + return ValueTask.FromResult(result); + }, (uri, _) => + { + reads.Add(uri); + return ValueTask.FromResult(Text(uri, "Checklist.")); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Equal(3, requests.Count); + Assert.Null(requests[0]!["cursor"]); + Assert.Equal(firstCursor, requests[1]!["cursor"]!.GetValue()); + Assert.Equal("last-page", requests[2]!["cursor"]!.GetValue()); + foreach (JsonNode? request in requests) + { + Assert.Equal(client.NegotiatedProtocolVersion, request!["_meta"]![MetaKeys.ProtocolVersion]!.GetValue()); + Assert.NotNull(request["_meta"]![MetaKeys.ClientCapabilities]); + Assert.NotNull(request["_meta"]![MetaKeys.ClientInfo]); + } + + Assert.Equal(2, skills.Count); + Assert.Empty(reads); + Assert.NotNull(await skills[0].GetResourceAsync("references/checklist.md")); + Assert.NotNull(await skills[1].GetResourceAsync("references/checklist.md")); + Assert.Equal( + ["skill://first/unit-converter/references/checklist.md", "skill://second/unit-converter/references/checklist.md"], + reads); + } + + [Theory] + [InlineData(null, 10, false)] + [InlineData(null, 10, true)] + [InlineData(2, 2, false)] + [InlineData(2, 2, true)] + [InlineData(11, 11, false)] + [InlineData(11, 11, true)] + public async Task GetSkillsAsync_PageLimit_IncludesEmptyPagesAndDoesNotSendExtraRequestAsync(int? limit, int expectedPages, bool exceedsLimit) + { + // Arrange + int calls = 0; + await using var server = CreateServer((_, _) => + { + JsonObject result = ListResult(); + calls++; + if (exceedsLimit || calls < expectedPages) + { + result["nextCursor"] = $"page-{calls}"; + } + + return ValueTask.FromResult(result); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client, new() { ListedMaxPageCount = limit }); + + // Act & Assert + if (exceedsLimit) + { + var exception = await Assert.ThrowsAsync(() => source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + Assert.Contains($"page count ({expectedPages})", exception.Message); + } + else + { + Assert.Empty(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + } + + Assert.Equal(expectedPages, calls); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetSkillsAsync_RepeatedCursor_ThrowsAsync(bool cycle) + { + // Arrange + int calls = 0; + await using var server = CreateServer((_, _) => + { + calls++; + JsonObject result = ListResult(Entry()); + result["nextCursor"] = cycle && calls == 2 ? "b" : "a"; + return ValueTask.FromResult(result); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client); + + // Act + var exception = await Assert.ThrowsAsync(() => source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + + // Assert + Assert.Contains("repeated pagination cursor", exception.Message); + Assert.Equal(cycle ? 3 : 2, calls); + } + + [Fact] + public async Task GetSkillsAsync_CursorsDifferingOnlyByCase_AreDistinctAsync() + { + // Arrange + int calls = 0; + await using var server = CreateServer((_, _) => + { + JsonObject result = ListResult(); + calls++; + result["nextCursor"] = calls switch { 1 => "A", 2 => "a", _ => null }; + return ValueTask.FromResult(result); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client); + + // Act + var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()); + + // Assert + Assert.Empty(skills); + Assert.Equal(3, calls); + } + + [Theory] + [InlineData("{}", typeof(JsonException))] + [InlineData("""{"resultType":"unknown","skills":[]}""", typeof(InvalidOperationException))] + [InlineData("""{"resultType":"complete","skills":[null]}""", typeof(InvalidOperationException))] + [InlineData("""{"resultType":"complete","skills":[],"nextCursor":42}""", typeof(JsonException))] + public async Task GetSkillsAsync_InvalidLaterPage_ThrowsInsteadOfReturningPartialCatalogAsync(string invalidPage, Type exceptionType) + { + // Arrange + int calls = 0; + List reads = []; + await using var server = CreateServer((_, _) => + { + if (++calls > 1) + { + return ValueTask.FromResult(JsonNode.Parse(invalidPage)); + } + + JsonObject result = ListResult(Entry()); + result["nextCursor"] = "next"; + return ValueTask.FromResult(result); + }, (uri, _) => + { + reads.Add(uri); + return ValueTask.FromResult(Text(uri, "Must not be read.")); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client); + + // Act & Assert + await Assert.ThrowsAsync(exceptionType, () => source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + Assert.Equal(2, calls); + Assert.Empty(reads); + } + + [Fact] + public async Task GetSkillsAsync_LaterPageError_PropagatesWithoutReadingIndexAsync() + { + // Arrange + int calls = 0; + List reads = []; + await using var server = CreateServer((_, _) => + { + if (++calls > 1) + { + throw new McpProtocolException("Cannot list next page.", McpErrorCode.InternalError); + } + + JsonObject result = ListResult(Entry()); + result["nextCursor"] = "next"; + return ValueTask.FromResult(result); + }, (uri, _) => + { + reads.Add(uri); + return ValueTask.FromResult(Text(uri, "Must not be read.")); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client); + + // Act & Assert + await Assert.ThrowsAsync(() => source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + Assert.Equal(2, calls); + Assert.Empty(reads); + } + + [Theory] + [InlineData(null, 99, false)] + [InlineData(null, 100, false)] + [InlineData(null, 101, true)] + [InlineData(2, 2, false)] + [InlineData(2, 3, true)] + [InlineData(101, 101, false)] + public async Task GetSkillsAsync_SkillLimit_CountsEntriesAcrossPagesIncludingDuplicatesAsync(int? limit, int count, bool exceedsLimit) + { + // Arrange + int calls = 0; + await using var server = CreateServer((_, _) => + { + calls++; + JsonObject result = ListResult(); + var entries = Assert.IsType(result["skills"]); + for (int i = 0; i < (calls == 1 ? 1 : count - 1); i++) + { + entries.Add(Entry()); + } + + if (calls == 1) + { + result["nextCursor"] = "next"; + } + + return ValueTask.FromResult(result); + }); + await using var client = await server.CreateClientAsync(); + using var source = new AgentMcpSkillsSource(client, new() { ListedMaxSkillCount = limit }); + + // Act & Assert + if (exceedsLimit) + { + var exception = await Assert.ThrowsAsync(() => source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())); + Assert.Contains($"skill count ({limit ?? 100})", exception.Message); + } + else + { + Assert.Equal(count, (await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create())).Count); + } + + Assert.Equal(2, calls); + } + + [Theory] + [InlineData(-1)] + [InlineData(0)] + [InlineData(1)] + public async Task DiscoverAsync_ResultSizeLimit_CountsAllPagesAndUnknownFieldsAsync(int difference) + { + // Arrange + JsonObject first = ListResult(Entry()); + first["nextCursor"] = "next"; + JsonObject second = ListResult(Entry()); + second["unknown"] = new string('\u00e9', 100); + long size = Encoding.UTF8.GetByteCount(first.ToJsonString()) + Encoding.UTF8.GetByteCount(second.ToJsonString()); + var client = new Mock(); + client.SetupSequence(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = first }) + .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(2), Result = second }); + var loader = new McpListedSkillsLoader(client.Object, options: new() { ListedMaxResultSizeBytes = size + difference }); + + // Act & Assert + if (difference < 0) + { + var exception = await Assert.ThrowsAsync(() => loader.DiscoverAsync(CancellationToken.None)); + Assert.Contains($"combined list-result size ({size + difference} bytes)", exception.Message); + } + else + { + Assert.Equal(2, (await loader.DiscoverAsync(CancellationToken.None)).Count); + } + + client.Verify(c => c.SendRequestAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task DiscoverAsync_DefaultResultSizeLimit_RejectsOversizedResultBeforeTypedDeserializationAsync() + { + // Arrange + JsonObject result = ListResult(); + result["padding"] = new string('x', 10_485_760); + result["skills"] = "invalid"; + var client = new Mock(); + client.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = result }); + var loader = new McpListedSkillsLoader(client.Object); + + // Act + var exception = await Assert.ThrowsAsync(() => loader.DiscoverAsync(CancellationToken.None)); + + // Assert + Assert.Contains("combined list-result size (10485760 bytes)", exception.Message); + } + + [Fact] + public async Task DiscoverAsync_SkillLimit_RejectsExcessEntriesBeforeTypedDeserializationAsync() + { + // Arrange + var client = new Mock(); + client.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = ListResult(Entry(), new JsonObject()) }); + var loader = new McpListedSkillsLoader(client.Object, options: new() { ListedMaxSkillCount = 1 }); + + // Act + var exception = await Assert.ThrowsAsync(() => loader.DiscoverAsync(CancellationToken.None)); + + // Assert + Assert.Contains("skill count (1)", exception.Message); + } + + [Fact] + public async Task DiscoverAsync_LargeResourceManifest_HasNoSeparateResourceCountLimitAsync() + { + // Arrange + JsonObject entry = Entry(); + var resources = Assert.IsType(entry["resources"]); + for (int i = 0; i < 101; i++) + { + resources.Add(Resource($"skill://unit-converter/file-{i}.txt")); + } + + var client = new Mock(); + client.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = ListResult(entry) }); + var loader = new McpListedSkillsLoader(client.Object); + + // Act + var skills = await loader.DiscoverAsync(CancellationToken.None); + + // Assert + Assert.Single(skills); + } + + [Theory] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxPageCount), 0)] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxPageCount), -1)] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount), 0)] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount), -1)] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes), 0)] + [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes), -1)] + public void Constructor_InvalidDiscoveryLimit_Throws(string option, int limit) + { + // Arrange + var client = new Mock(); + var options = new AgentMcpSkillsSourceOptions + { + ListedMaxPageCount = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxPageCount) ? limit : null, + ListedMaxSkillCount = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount) ? limit : null, + ListedMaxResultSizeBytes = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes) ? limit : null, + }; + + // Act + var exception = Assert.Throws(() => new AgentMcpSkillsSource(client.Object, options)); + + // Assert + Assert.Equal(option, exception.ParamName); + } + [Fact] public async Task GetSkillsAsync_Refresh_KeepsPreviouslyReturnedManifestAsync() { @@ -210,14 +576,24 @@ public async Task GetSkillsAsync_ListError_DoesNotReadIndexAsync() Assert.Empty(reads); } - [Fact] - public async Task GetSkillsAsync_ListCancellation_PropagatesAsync() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GetSkillsAsync_ListCancellation_PropagatesAsync(bool laterPage) { // Arrange var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + int calls = 0; await using var server = CreateServer(async (_, token) => { + if (++calls == 1 && laterPage) + { + JsonObject result = ListResult(Entry()); + result["nextCursor"] = "next"; + return result; + } + started.SetResult(); await release.Task.WaitAsync(token); return ListResult(Entry()); @@ -235,6 +611,7 @@ public async Task GetSkillsAsync_ListCancellation_PropagatesAsync() // Assert await Assert.ThrowsAnyAsync(() => operation.WaitAsync(TimeSpan.FromSeconds(10))); + Assert.Equal(laterPage ? 2 : 1, calls); } finally { @@ -768,7 +1145,7 @@ public async Task GetSkillsAsync_UnknownFieldsAndCaseVariants_AreIgnoredAsync() JsonObject listResult = ListResult(entry); listResult["ttlMs"] = 300000; listResult["cacheScope"] = "public"; - listResult["nextCursor"] = "next-page"; + listResult["NextCursor"] = "ignored-case-variant"; listResult["future-field"] = true; await using var server = CreateServer((_, _) => ValueTask.FromResult(listResult)); await using var client = await server.CreateClientAsync(); From 5e738a3bce6693777666cd3159a015d4b800ef6e Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 16 Oct 2026 19:54:45 +0100 Subject: [PATCH 2/3] .NET: Remove MCP skill list JSON-size limit Remove ListedMaxResultSizeBytes and its counting pass. Document server trust and application-level resource-limit responsibilities, and cover large list responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ae2c03-d223-4731-bc81-d3021698265c --- .../Skills/AgentMcpSkillsSource.cs | 10 +++- .../Skills/AgentMcpSkillsSourceOptions.cs | 19 ++----- ...AgentSkillsProviderBuilderMcpExtensions.cs | 10 +++- .../Skills/Listed/McpListedSkillsLoader.cs | 19 ------- .../Listed/AgentMcpListedSkillsTests.cs | 55 ++++++------------- 5 files changed, 39 insertions(+), 74 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs index 1014ebe8bdc..33e8ad5ab37 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSource.cs @@ -35,7 +35,7 @@ namespace Microsoft.Agents.AI; /// 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; SKILL.md must contain text. /// Discovery through skills/list leaves existing archive directories untouched. -/// Page counts, total skill counts, combined list-result JSON size, and individual resource sizes +/// Page counts, total skill counts, and individual resource sizes /// are bounded by . 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. @@ -97,6 +97,14 @@ namespace Microsoft.Agents.AI; /// not trustworthiness: a server controlling both the index and archive can replace both or omit /// the digest. /// +/// +/// This source does not impose a byte limit on individual or combined skills/list 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. +/// /// internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource { diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs index 275ee32d95d..29c4913a9c1 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkillsSourceOptions.cs @@ -8,9 +8,12 @@ namespace Microsoft.Agents.AI; /// Configuration options for . /// /// -/// Listed discovery limits bound pages, skill counts, and combined list-result JSON size. +/// Listed discovery limits bound page counts and total skill counts. /// The listed resource size limit separately bounds resource content processed by this source. -/// These checks do not limit incoming messages before the supplied client receives and parses them. +/// 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 for security considerations. /// Security consideration: The archive limits (, /// , ) bound how much a /// single MCP server response can extract to local disk. Since the server supplying these archives is @@ -38,18 +41,6 @@ public sealed class AgentMcpSkillsSourceOptions /// public int? ListedMaxSkillCount { get; set; } - /// - /// Gets or sets the maximum combined size, in bytes, of skills/list results per discovery. - /// - /// - /// When , defaults to 10 MiB (10,485,760 bytes). Must be positive. - /// Counts the compact UTF-8 JSON representation of each result, including unknown fields, - /// not the original wire bytes or runtime object sizes. Discovery fails if the limit is exceeded. - /// The check occurs after the supplied MCP client receives and parses each result, but before - /// typed metadata deserialization and skill construction. There is no separate resource-count limit per skill. - /// - public long? ListedMaxResultSizeBytes { get; set; } - /// /// Gets or sets the maximum size, in bytes, of a resource discovered through skills/list, /// including SKILL.md. diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs index ac8de13933f..8f5e69b33b5 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentSkillsProviderBuilderMcpExtensions.cs @@ -23,7 +23,7 @@ public static class AgentSkillsProviderBuilderMcpExtensions /// io.modelcontextprotocol/skills extension. Otherwise, discovers skills through /// skill://index.json. /// Listed skills fetch instructions and supporting resources when requested. - /// Page counts, total skill counts, combined list-result JSON size, and individual resource sizes + /// Page counts, total skill counts, and individual resource sizes /// are bounded by . /// These checks do not limit incoming messages before the supplied client receives and parses them. /// @@ -45,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. /// + /// + /// This source does not impose a byte limit on individual or combined skills/list 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. + /// /// public static AgentSkillsProviderBuilder UseMcpSkills(this AgentSkillsProviderBuilder builder, McpClient client, AgentMcpSkillsSourceOptions? options = null) { diff --git a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs index 04f290b5ea8..1ce32a3e08c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs +++ b/dotnet/src/Microsoft.Agents.AI.Mcp/Skills/Listed/McpListedSkillsLoader.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; -using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; @@ -31,14 +30,12 @@ internal sealed partial class McpListedSkillsLoader private const int DefaultMaxPageCount = 10; private const int DefaultMaxSkillCount = 100; - private const long DefaultMaxResultSizeBytes = 10 * 1024 * 1024; private readonly McpClient _client; private readonly ILogger _logger; private readonly long _maxResourceSizeBytes; private readonly int _maxPageCount; private readonly int _maxSkillCount; - private readonly long _maxResultSizeBytes; /// /// Initializes a loader for skills discovered through skills/list. @@ -55,11 +52,9 @@ public McpListedSkillsLoader(McpClient client, ILogger? logger = null, AgentMcpS this._maxResourceSizeBytes = options?.ListedResourceMaxSizeBytes ?? AgentMcpListedSkill.DefaultMaxResourceSizeBytes; this._maxPageCount = options?.ListedMaxPageCount ?? DefaultMaxPageCount; this._maxSkillCount = options?.ListedMaxSkillCount ?? DefaultMaxSkillCount; - this._maxResultSizeBytes = options?.ListedMaxResultSizeBytes ?? DefaultMaxResultSizeBytes; ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxResourceSizeBytes, nameof(options.ListedResourceMaxSizeBytes)); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxPageCount, nameof(options.ListedMaxPageCount)); ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxSkillCount, nameof(options.ListedMaxSkillCount)); - ArgumentOutOfRangeException.ThrowIfNegativeOrZero(this._maxResultSizeBytes, nameof(options.ListedMaxResultSizeBytes)); } /// @@ -100,7 +95,6 @@ private async Task> ListSkillEntriesAsync(Cancellation HashSet cursors = new(StringComparer.Ordinal); string? cursor = null; int pageCount = 0; - long totalResultSizeBytes = 0; do { @@ -136,19 +130,6 @@ private async Task> ListSkillEntriesAsync(Cancellation throw new InvalidOperationException($"Skill discovery exceeds the maximum allowed skill count ({this._maxSkillCount})."); } - using (Utf8JsonWriter writer = new(Stream.Null)) - { - resultNode.WriteTo(writer); - writer.Flush(); - - if (writer.BytesCommitted > this._maxResultSizeBytes - totalResultSizeBytes) - { - throw new InvalidOperationException($"Skill discovery exceeds the maximum allowed combined list-result size ({this._maxResultSizeBytes} bytes)."); - } - - totalResultSizeBytes += writer.BytesCommitted; - } - McpSkillsListResult result = resultNode.Deserialize(McpListedSkillsJsonContext.Default.McpSkillsListResult) ?? throw new InvalidOperationException("The server returned no skills/list result."); diff --git a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs index cecd754bdf0..77b4fdb2589 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/Listed/AgentMcpListedSkillsTests.cs @@ -308,54 +308,34 @@ public async Task GetSkillsAsync_SkillLimit_CountsEntriesAcrossPagesIncludingDup } [Theory] - [InlineData(-1)] - [InlineData(0)] [InlineData(1)] - public async Task DiscoverAsync_ResultSizeLimit_CountsAllPagesAndUnknownFieldsAsync(int difference) + [InlineData(2)] + public async Task DiscoverAsync_LargeUnknownFields_AreAcceptedAcrossPagesAsync(int pageCount) { // Arrange - JsonObject first = ListResult(Entry()); - first["nextCursor"] = "next"; - JsonObject second = ListResult(Entry()); - second["unknown"] = new string('\u00e9', 100); - long size = Encoding.UTF8.GetByteCount(first.ToJsonString()) + Encoding.UTF8.GetByteCount(second.ToJsonString()); + string padding = new('x', 10 * 1024 * 1024 / pageCount); var client = new Mock(); - client.SetupSequence(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = first }) - .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(2), Result = second }); - var loader = new McpListedSkillsLoader(client.Object, options: new() { ListedMaxResultSizeBytes = size + difference }); - - // Act & Assert - if (difference < 0) - { - var exception = await Assert.ThrowsAsync(() => loader.DiscoverAsync(CancellationToken.None)); - Assert.Contains($"combined list-result size ({size + difference} bytes)", exception.Message); - } - else + var responses = client.SetupSequence(c => c.SendRequestAsync(It.IsAny(), It.IsAny())); + for (int page = 0; page < pageCount; page++) { - Assert.Equal(2, (await loader.DiscoverAsync(CancellationToken.None)).Count); - } + JsonObject result = ListResult(Entry()); + result["unknown"] = padding; + if (page < pageCount - 1) + { + result["nextCursor"] = $"page-{page + 1}"; + } - client.Verify(c => c.SendRequestAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); - } + responses.ReturnsAsync(new JsonRpcResponse { Id = new RequestId(page + 1), Result = result }); + } - [Fact] - public async Task DiscoverAsync_DefaultResultSizeLimit_RejectsOversizedResultBeforeTypedDeserializationAsync() - { - // Arrange - JsonObject result = ListResult(); - result["padding"] = new string('x', 10_485_760); - result["skills"] = "invalid"; - var client = new Mock(); - client.Setup(c => c.SendRequestAsync(It.IsAny(), It.IsAny())) - .ReturnsAsync(new JsonRpcResponse { Id = new RequestId(1), Result = result }); var loader = new McpListedSkillsLoader(client.Object); // Act - var exception = await Assert.ThrowsAsync(() => loader.DiscoverAsync(CancellationToken.None)); + var skills = await loader.DiscoverAsync(CancellationToken.None); // Assert - Assert.Contains("combined list-result size (10485760 bytes)", exception.Message); + Assert.Equal(pageCount, skills.Count); + client.Verify(c => c.SendRequestAsync(It.IsAny(), It.IsAny()), Times.Exactly(pageCount)); } [Fact] @@ -402,8 +382,6 @@ public async Task DiscoverAsync_LargeResourceManifest_HasNoSeparateResourceCount [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxPageCount), -1)] [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount), 0)] [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount), -1)] - [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes), 0)] - [InlineData(nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes), -1)] public void Constructor_InvalidDiscoveryLimit_Throws(string option, int limit) { // Arrange @@ -412,7 +390,6 @@ public void Constructor_InvalidDiscoveryLimit_Throws(string option, int limit) { ListedMaxPageCount = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxPageCount) ? limit : null, ListedMaxSkillCount = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxSkillCount) ? limit : null, - ListedMaxResultSizeBytes = option == nameof(AgentMcpSkillsSourceOptions.ListedMaxResultSizeBytes) ? limit : null, }; // Act From 9ea632953a607aeb2f8621618770cf7df1de8841 Mon Sep 17 00:00:00 2001 From: SergeyMenshykh Date: Fri, 16 Oct 2026 20:07:10 +0100 Subject: [PATCH 3/3] .NET: Update MessagePack to 3.1.12 Match the dependency pin on main to address GHSA-qhrr-8q5h-9q3h and unblock PR builds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 53ae2c03-d223-4731-bc81-d3021698265c --- dotnet/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 87413586448..dd7d30dd3f2 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -22,7 +22,7 @@ - +