Skip to content

Commit cf63f0e

Browse files
Copilotstephentoub
andauthored
Fix: create per-task DI scope in ExecuteToolAsTaskAsync to prevent ObjectDisposedException (#1433)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent 7dcdbaf commit cf63f0e

File tree

2 files changed

+95
-0
lines changed

2 files changed

+95
-0
lines changed

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1138,6 +1138,19 @@ private async ValueTask<CallToolResult> ExecuteToolAsTaskAsync(
11381138
// Execute the tool asynchronously in the background
11391139
_ = Task.Run(async () =>
11401140
{
1141+
// When per-request service scoping is enabled, InvokeHandlerAsync creates a new
1142+
// IServiceScope and disposes it once the handler returns. Since ExecuteToolAsTaskAsync
1143+
// returns immediately (before the tool runs), the scope is disposed before the tool
1144+
// gets a chance to resolve any DI services. Create a fresh scope here, tied to this
1145+
// background task's lifetime, so the tool's DI resolution uses a live provider.
1146+
var taskScope = _servicesScopePerRequest
1147+
? Services?.GetService<IServiceScopeFactory>()?.CreateAsyncScope()
1148+
: null;
1149+
if (taskScope is not null)
1150+
{
1151+
request.Services = taskScope.Value.ServiceProvider;
1152+
}
1153+
11411154
// Set up the task execution context for automatic input_required status tracking
11421155
TaskExecutionContext.Current = new TaskExecutionContext
11431156
{
@@ -1233,6 +1246,12 @@ private async ValueTask<CallToolResult> ExecuteToolAsTaskAsync(
12331246

12341247
// Clean up task cancellation tracking
12351248
_taskCancellationTokenProvider!.Complete(mcpTask.TaskId);
1249+
1250+
// Dispose the per-task service scope (if one was created)
1251+
if (taskScope is not null)
1252+
{
1253+
await taskScope.Value.DisposeAsync().ConfigureAwait(false);
1254+
}
12361255
}
12371256
}, CancellationToken.None);
12381257

tests/ModelContextProtocol.Tests/Server/McpServerTaskAugmentedValidationTests.cs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,72 @@ public async Task CallToolAsTask_Succeeds_WhenToolHasRequiredTaskSupport()
275275
Assert.NotNull(result.Task.TaskId);
276276
}
277277

278+
[Fact]
279+
public async Task CallToolAsTask_WithRequiredTaskSupport_CanResolveScopedServicesFromDI()
280+
{
281+
// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/1430:
282+
// ExecuteToolAsTaskAsync fires Task.Run and returns immediately, so the request-scoped
283+
// IServiceProvider owned by InvokeHandlerAsync is disposed before the background task
284+
// calls tool.InvokeAsync. The fix creates a fresh scope inside the Task.Run body so the
285+
// tool can resolve DI services without hitting ObjectDisposedException.
286+
var taskStore = new InMemoryMcpTaskStore();
287+
string? capturedValue = null;
288+
289+
await using var fixture = new ServerClientFixture(LoggerFactory, configureServer: (services, builder) =>
290+
{
291+
services.AddSingleton<IMcpTaskStore>(taskStore);
292+
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);
293+
294+
// Register a scoped service; resolving it through a disposed scope was the bug.
295+
services.AddScoped<ITaskToolDiService, TaskToolDiService>();
296+
297+
// Register the tool via the factory pattern so that Services = sp is threaded
298+
// through, enabling DI parameter binding at tool-creation time.
299+
builder.Services.AddSingleton<McpServerTool>(sp => McpServerTool.Create(
300+
async (ITaskToolDiService svc, CancellationToken ct) =>
301+
{
302+
await Task.Delay(10, ct);
303+
capturedValue = svc.GetValue();
304+
return capturedValue;
305+
},
306+
new McpServerToolCreateOptions
307+
{
308+
Name = "di-required-task-tool",
309+
Services = sp,
310+
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required }
311+
}));
312+
});
313+
314+
await using var client = await fixture.CreateClientAsync(TestContext.Current.CancellationToken);
315+
316+
var result = await client.CallToolAsync(
317+
new CallToolRequestParams
318+
{
319+
Name = "di-required-task-tool",
320+
Task = new McpTaskMetadata()
321+
},
322+
TestContext.Current.CancellationToken);
323+
324+
Assert.NotNull(result.Task);
325+
string taskId = result.Task.TaskId;
326+
327+
// Poll until the background task reaches a terminal state.
328+
McpTask taskStatus;
329+
int attempts = 0;
330+
do
331+
{
332+
await Task.Delay(50, TestContext.Current.CancellationToken);
333+
taskStatus = await client.GetTaskAsync(taskId, cancellationToken: TestContext.Current.CancellationToken);
334+
attempts++;
335+
}
336+
while (taskStatus.Status == McpTaskStatus.Working && attempts < 50);
337+
338+
// Without the fix, the background task would fail with ObjectDisposedException when
339+
// resolving ITaskToolDiService, causing the task to reach McpTaskStatus.Failed.
340+
Assert.Equal(McpTaskStatus.Completed, taskStatus.Status);
341+
Assert.Equal("hello-from-di", capturedValue);
342+
}
343+
278344
[Fact]
279345
public async Task CallToolAsTaskAsync_WithProgress_CreatesTaskSuccessfully()
280346
{
@@ -857,6 +923,16 @@ public async Task NormalRequest_Succeeds_WhenTasksNotSupported()
857923

858924
#endregion
859925

926+
private interface ITaskToolDiService
927+
{
928+
string GetValue();
929+
}
930+
931+
private sealed class TaskToolDiService : ITaskToolDiService
932+
{
933+
public string GetValue() => "hello-from-di";
934+
}
935+
860936
/// <summary>
861937
/// Helper fixture for creating server-client pairs with custom configuration.
862938
/// </summary>

0 commit comments

Comments
 (0)