From de3f02e881c0f0a0c32bd1539b49a080f3a1cef0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:56:44 +0000 Subject: [PATCH 1/2] Initial plan From 75e004ea4b6a1eb5e06607cf455946edefaf19ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:14:58 +0000 Subject: [PATCH 2/2] Fix: create fresh DI scope in ExecuteToolAsTaskAsync background task; add regression test Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/McpServerImpl.cs | 19 +++++ .../McpServerTaskAugmentedValidationTests.cs | 76 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 753d91667..eec2a2156 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -1139,6 +1139,19 @@ private async ValueTask ExecuteToolAsTaskAsync( // Execute the tool asynchronously in the background _ = Task.Run(async () => { + // When per-request service scoping is enabled, InvokeHandlerAsync creates a new + // IServiceScope and disposes it once the handler returns. Since ExecuteToolAsTaskAsync + // returns immediately (before the tool runs), the scope is disposed before the tool + // gets a chance to resolve any DI services. Create a fresh scope here, tied to this + // background task's lifetime, so the tool's DI resolution uses a live provider. + var taskScope = _servicesScopePerRequest + ? Services?.GetService()?.CreateAsyncScope() + : null; + if (taskScope is not null) + { + request.Services = taskScope.Value.ServiceProvider; + } + // Set up the task execution context for automatic input_required status tracking TaskExecutionContext.Current = new TaskExecutionContext { @@ -1234,6 +1247,12 @@ private async ValueTask ExecuteToolAsTaskAsync( // Clean up task cancellation tracking _taskCancellationTokenProvider!.Complete(mcpTask.TaskId); + + // Dispose the per-task service scope (if one was created) + if (taskScope is not null) + { + await taskScope.Value.DisposeAsync().ConfigureAwait(false); + } } }, CancellationToken.None); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs index b7734d76e..4c045cb21 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs @@ -275,6 +275,72 @@ public async Task CallToolAsTask_Succeeds_WhenToolHasRequiredTaskSupport() Assert.NotNull(result.Task.TaskId); } + [Fact] + public async Task CallToolAsTask_WithRequiredTaskSupport_CanResolveScopedServicesFromDI() + { + // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1430: + // ExecuteToolAsTaskAsync fires Task.Run and returns immediately, so the request-scoped + // IServiceProvider owned by InvokeHandlerAsync is disposed before the background task + // calls tool.InvokeAsync. The fix creates a fresh scope inside the Task.Run body so the + // tool can resolve DI services without hitting ObjectDisposedException. + var taskStore = new InMemoryMcpTaskStore(); + string? capturedValue = null; + + await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) => + { + services.AddSingleton(taskStore); + services.Configure(options => options.TaskStore = taskStore); + + // Register a scoped service; resolving it through a disposed scope was the bug. + services.AddScoped(); + + // Register the tool via the factory pattern so that Services = sp is threaded + // through, enabling DI parameter binding at tool-creation time. + builder.Services.AddSingleton(sp => McpServerTool.Create( + async (ITaskToolDiService svc, CancellationToken ct) => + { + await Task.Delay(10, ct); + capturedValue = svc.GetValue(); + return capturedValue; + }, + new McpServerToolCreateOptions + { + Name = "di-required-task-tool", + Services = sp, + Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required } + })); + }); + + await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken); + + var result = await client.CallToolAsync( + new CallToolRequestParams + { + Name = "di-required-task-tool", + Task = new McpTaskMetadata() + }, + TestContext.Current.CancellationToken); + + Assert.NotNull(result.Task); + string taskId = result.Task.TaskId; + + // Poll until the background task reaches a terminal state. + McpTask taskStatus; + int attempts = 0; + do + { + await Task.Delay(50, TestContext.Current.CancellationToken); + taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken); + attempts++; + } + while (taskStatus.Status == McpTaskStatus.Working && attempts < 50); + + // Without the fix, the background task would fail with ObjectDisposedException when + // resolving ITaskToolDiService, causing the task to reach McpTaskStatus.Failed. + Assert.Equal(McpTaskStatus.Completed, taskStatus.Status); + Assert.Equal("hello-from-di", capturedValue); + } + [Fact] public async Task CallToolAsTaskAsync_WithProgress_CreatesTaskSuccessfully() { @@ -857,6 +923,16 @@ public async Task NormalRequest_Succeeds_WhenTasksNotSupported() #endregion + private interface ITaskToolDiService + { + string GetValue(); + } + + private sealed class TaskToolDiService : ITaskToolDiService + { + public string GetValue() => "hello-from-di"; + } + /// /// Helper fixture for creating server-client pairs with custom configuration. ///