Skip to content
Draft
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
19 changes: 19 additions & 0 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1139,6 +1139,19 @@ private async ValueTask<CallToolResult> 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<IServiceScopeFactory>()?.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
{
Expand Down Expand Up @@ -1234,6 +1247,12 @@ private async ValueTask<CallToolResult> 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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IMcpTaskStore>(taskStore);
services.Configure<McpServerOptions>(options => options.TaskStore = taskStore);

// Register a scoped service; resolving it through a disposed scope was the bug.
services.AddScoped<ITaskToolDiService, TaskToolDiService>();

// 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<McpServerTool>(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()
{
Expand Down Expand Up @@ -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";
}

/// <summary>
/// Helper fixture for creating server-client pairs with custom configuration.
/// </summary>
Expand Down
Loading