From 981223555bb6d86d708bfb82b6ec4d8db3a9431d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:48:19 +0000 Subject: [PATCH 1/3] Initial plan From 3e4435d5d04a63631a4134368c66cd0c6cc4bb28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:57:25 +0000 Subject: [PATCH 2/3] Fix WithMeta+WithProgress by using DeepClone instead of JsonObject constructor Replace `new JsonObject(meta)` with `(JsonObject)meta.DeepClone()` in both SendRequestWithProgressAsync and SendRequestWithProgressAsTaskAsync to prevent InvalidOperationException when JsonNode values already have a parent. Add tests covering progress with meta, InvokeAsync path, mutation safety, chaining all With* methods, and merging RequestOptions meta with progress. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/d4ec72e6-6d91-429a-92ae-84f8eaa41bbc --- .../Client/McpClient.Methods.cs | 4 +- .../Client/McpClientToolTests.cs | 159 ++++++++++++++++++ 2 files changed, 161 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 057831a4e..6490967d0 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -877,7 +877,7 @@ async ValueTask SendRequestWithProgressAsync( return default; }).ConfigureAwait(false); - JsonObject metaWithProgress = meta is not null ? new(meta) : []; + JsonObject metaWithProgress = meta is not null ? (JsonObject)meta.DeepClone() : []; metaWithProgress["progressToken"] = progressToken.ToString(); return await CallToolAsync( @@ -1007,7 +1007,7 @@ async ValueTask SendTaskAugmentedCallToolRequestWithProgressAsync( return default; }).ConfigureAwait(false); - JsonObject metaWithProgress = meta is not null ? new(meta) : []; + JsonObject metaWithProgress = meta is not null ? (JsonObject)meta.DeepClone() : []; metaWithProgress["progressToken"] = progressToken.ToString(); var result = await SendRequestAsync( diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index e17fb6bc9..54f4c4b28 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -854,4 +854,163 @@ public async Task CallToolAsync_WithAnonymousTypeArguments_Works() var textBlock = Assert.IsType(result.Content[0]); Assert.Contains("coordinates", textBlock.Text); } + + [Fact] + public async Task CallAsync_WithProgress_ProgressTokenInMeta() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + // Pass progress directly to CallAsync + var result = await tool.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Single(result.Content); + + var textBlock = Assert.IsType(result.Content[0]); + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); + } + + [Fact] + public async Task CallAsync_WithMeta_WithProgress_BothMetaAndProgressTokenPresent() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + // WithMeta on the tool, progress passed to CallAsync + var result = await tool + .WithMeta(new() { ["traceId"] = "trace-123" }) + .CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.Single(result.Content); + + var textBlock = Assert.IsType(result.Content[0]); + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.Equal("trace-123", receivedMetadata["traceId"]?.GetValue()); + Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); + } + + [Fact] + public async Task InvokeAsync_WithMeta_WithProgress_BothMetaAndProgressTokenPresent() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + // Both WithMeta and WithProgress on the tool, invoked via InvokeAsync (the AIFunction path) + var result = await tool + .WithMeta(new() { ["traceId"] = "trace-456" }) + .WithProgress(progress) + .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + + // InvokeAsync returns a JsonElement for results with meta + Assert.NotNull(result); + } + + [Fact] + public async Task CallAsync_WithMeta_WithProgress_DoesNotMutateOriginalMeta() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + JsonObject originalMeta = new() + { + ["traceId"] = "trace-789" + }; + + var toolWithMeta = tool.WithMeta(originalMeta); + + // Call multiple times with progress to ensure original meta is not mutated + await toolWithMeta.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + await toolWithMeta.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + + // Original meta should not contain progressToken + Assert.Single(originalMeta); + Assert.Equal("trace-789", originalMeta["traceId"]?.GetValue()); + Assert.False(originalMeta.ContainsKey("progressToken")); + } + + [Fact] + public async Task CallAsync_WithMeta_WithProgress_WithName_WithDescription_AllChained() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + var modifiedTool = tool + .WithName("custom_name") + .WithDescription("Custom description") + .WithMeta(new() { ["chainedKey"] = "chainedValue" }); + + Assert.Equal("custom_name", modifiedTool.Name); + Assert.Equal("Custom description", modifiedTool.Description); + + var result = await modifiedTool.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var textBlock = Assert.IsType(result.Content[0]); + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.Equal("chainedValue", receivedMetadata["chainedKey"]?.GetValue()); + Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); + } + + [Fact] + public async Task CallAsync_WithMeta_WithProgress_WithRequestOptionsMeta_AllMerged() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var progressValues = new List(); + var progress = new Progress(p => progressValues.Add(p)); + + RequestOptions requestOptions = new() + { + Meta = new() + { + ["requestKey"] = "requestValue" + } + }; + + var result = await tool + .WithMeta(new() { ["toolKey"] = "toolValue" }) + .CallAsync(progress: progress, options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + var textBlock = Assert.IsType(result.Content[0]); + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.Equal("toolValue", receivedMetadata["toolKey"]?.GetValue()); + Assert.Equal("requestValue", receivedMetadata["requestKey"]?.GetValue()); + Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); + } } From 01a69ff1bcb380ed35f1f8d2acb5a9c832686fec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:11:26 +0000 Subject: [PATCH 3/3] Consolidate WithProgress tests into Theory tests to reduce duplication Use [Theory] with useInvokeAsync parameter and a shared helper method to test both CallAsync and InvokeAsync paths without code duplication. Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Agent-Logs-Url: https://github.com/modelcontextprotocol/csharp-sdk/sessions/9ab96e2e-534b-4f1d-a047-5cb7f49cd078 --- .../Client/McpClientToolTests.cs | 152 +++++------------- 1 file changed, 44 insertions(+), 108 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index 54f4c4b28..6d241f6dc 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -855,162 +855,98 @@ public async Task CallToolAsync_WithAnonymousTypeArguments_Works() Assert.Contains("coordinates", textBlock.Text); } - [Fact] - public async Task CallAsync_WithProgress_ProgressTokenInMeta() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithProgress_ProgressTokenInMeta(bool useInvokeAsync) { await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var tool = tools.Single(t => t.Name == "metadata_echo_tool"); - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); - - // Pass progress directly to CallAsync - var result = await tool.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.Single(result.Content); - - var textBlock = Assert.IsType(result.Content[0]); - var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + var receivedMetadata = await CallMetadataEchoToolWithProgressAsync(tool, useInvokeAsync); Assert.NotNull(receivedMetadata); Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); } - [Fact] - public async Task CallAsync_WithMeta_WithProgress_BothMetaAndProgressTokenPresent() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithMeta_WithProgress_BothMetaAndProgressTokenPresent(bool useInvokeAsync) { await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + var tool = tools.Single(t => t.Name == "metadata_echo_tool") + .WithMeta(new() { ["traceId"] = "trace-123" }); - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); - - // WithMeta on the tool, progress passed to CallAsync - var result = await tool - .WithMeta(new() { ["traceId"] = "trace-123" }) - .CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); - - Assert.NotNull(result); - Assert.Single(result.Content); - - var textBlock = Assert.IsType(result.Content[0]); - var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + var receivedMetadata = await CallMetadataEchoToolWithProgressAsync(tool, useInvokeAsync); Assert.NotNull(receivedMetadata); Assert.Equal("trace-123", receivedMetadata["traceId"]?.GetValue()); Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); } - [Fact] - public async Task InvokeAsync_WithMeta_WithProgress_BothMetaAndProgressTokenPresent() - { - await using McpClient client = await CreateMcpClientForServer(); - - var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var tool = tools.Single(t => t.Name == "metadata_echo_tool"); - - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); - - // Both WithMeta and WithProgress on the tool, invoked via InvokeAsync (the AIFunction path) - var result = await tool - .WithMeta(new() { ["traceId"] = "trace-456" }) - .WithProgress(progress) - .InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); - - // InvokeAsync returns a JsonElement for results with meta - Assert.NotNull(result); - } - - [Fact] - public async Task CallAsync_WithMeta_WithProgress_DoesNotMutateOriginalMeta() + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task WithMeta_WithProgress_DoesNotMutateOriginalMeta(bool useInvokeAsync) { await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); var tool = tools.Single(t => t.Name == "metadata_echo_tool"); - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); - - JsonObject originalMeta = new() - { - ["traceId"] = "trace-789" - }; - + JsonObject originalMeta = new() { ["traceId"] = "trace-789" }; var toolWithMeta = tool.WithMeta(originalMeta); - // Call multiple times with progress to ensure original meta is not mutated - await toolWithMeta.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); - await toolWithMeta.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + await CallMetadataEchoToolWithProgressAsync(toolWithMeta, useInvokeAsync); + await CallMetadataEchoToolWithProgressAsync(toolWithMeta, useInvokeAsync); - // Original meta should not contain progressToken Assert.Single(originalMeta); Assert.Equal("trace-789", originalMeta["traceId"]?.GetValue()); Assert.False(originalMeta.ContainsKey("progressToken")); } [Fact] - public async Task CallAsync_WithMeta_WithProgress_WithName_WithDescription_AllChained() + public async Task WithMeta_WithProgress_WithRequestOptionsMeta_AllMerged() { await using McpClient client = await CreateMcpClientForServer(); var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var tool = tools.Single(t => t.Name == "metadata_echo_tool"); - - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); - - var modifiedTool = tool - .WithName("custom_name") - .WithDescription("Custom description") - .WithMeta(new() { ["chainedKey"] = "chainedValue" }); - - Assert.Equal("custom_name", modifiedTool.Name); - Assert.Equal("Custom description", modifiedTool.Description); + var tool = tools.Single(t => t.Name == "metadata_echo_tool") + .WithMeta(new() { ["toolKey"] = "toolValue" }); - var result = await modifiedTool.CallAsync(progress: progress, cancellationToken: TestContext.Current.CancellationToken); + RequestOptions requestOptions = new() + { + Meta = new() { ["requestKey"] = "requestValue" } + }; - Assert.NotNull(result); - var textBlock = Assert.IsType(result.Content[0]); - var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + var receivedMetadata = await CallMetadataEchoToolWithProgressAsync(tool, useInvokeAsync: false, requestOptions); Assert.NotNull(receivedMetadata); - Assert.Equal("chainedValue", receivedMetadata["chainedKey"]?.GetValue()); + Assert.Equal("toolValue", receivedMetadata["toolKey"]?.GetValue()); + Assert.Equal("requestValue", receivedMetadata["requestKey"]?.GetValue()); Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); } - [Fact] - public async Task CallAsync_WithMeta_WithProgress_WithRequestOptionsMeta_AllMerged() + private static async Task CallMetadataEchoToolWithProgressAsync( + McpClientTool tool, bool useInvokeAsync, RequestOptions? options = null) { - await using McpClient client = await CreateMcpClientForServer(); - - var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); - var tool = tools.Single(t => t.Name == "metadata_echo_tool"); - - var progressValues = new List(); - var progress = new Progress(p => progressValues.Add(p)); + var progress = new Progress(); + string text; - RequestOptions requestOptions = new() + if (useInvokeAsync) { - Meta = new() - { - ["requestKey"] = "requestValue" - } - }; - - var result = await tool - .WithMeta(new() { ["toolKey"] = "toolValue" }) - .CallAsync(progress: progress, options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + tool = tool.WithProgress(progress); + var result = await tool.InvokeAsync(cancellationToken: TestContext.Current.CancellationToken); + text = Assert.IsType(result).Text; + } + else + { + var result = await tool.CallAsync(progress: progress, options: options, cancellationToken: TestContext.Current.CancellationToken); + text = Assert.IsType(result.Content.Single()).Text; + } - Assert.NotNull(result); - var textBlock = Assert.IsType(result.Content[0]); - var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); - Assert.NotNull(receivedMetadata); - Assert.Equal("toolValue", receivedMetadata["toolKey"]?.GetValue()); - Assert.Equal("requestValue", receivedMetadata["requestKey"]?.GetValue()); - Assert.NotNull(receivedMetadata["progressToken"]?.GetValue()); + return JsonNode.Parse(text)?.AsObject(); } }