Skip to content

Commit d282b1c

Browse files
authored
Merge pull request #411 from jongalloway/fix/disable-task-store-di-scope-bug
fix: disable InMemoryMcpTaskStore to avoid ObjectDisposedException (upstream SDK bug)
2 parents dec77db + 5117fca commit d282b1c

5 files changed

Lines changed: 36 additions & 97 deletions

File tree

DotNetMcp.Tests/Server/McpConformanceTests.cs

Lines changed: 22 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -713,34 +713,26 @@ public async Task Server_CompleteHandler_ShouldReturnConfigurationSuggestions()
713713

714714
#endregion
715715

716-
#region MCP Task Support Tests
716+
#region MCP Task Support Tests (Graceful Degradation)
717717

718-
[Fact]
719-
public void Server_ShouldAdvertiseTasksCapability()
720-
{
721-
// Arrange
722-
Assert.NotNull(_client);
723-
724-
// Assert - server should expose tasks capability (InMemoryMcpTaskStore is registered)
725-
Assert.NotNull(_client.ServerCapabilities);
726-
Assert.NotNull(_client.ServerCapabilities.Tasks);
727-
}
718+
// Task support is intentionally disabled: MCP SDK v1.1.0's ExecuteToolAsTaskAsync
719+
// disposes the DI scope before the background task resolves services, causing
720+
// ObjectDisposedException on every tool call. Without InMemoryMcpTaskStore,
721+
// the server runs tools synchronously and does not advertise task capabilities.
728722

729723
[Fact]
730-
public void Server_TasksCapability_ShouldSupportListAndCancel()
724+
public void Server_ShouldNotAdvertiseTasksCapability_WhenTaskStoreNotRegistered()
731725
{
732726
// Arrange
733727
Assert.NotNull(_client);
734728

735-
// Assert
736-
var tasks = _client.ServerCapabilities?.Tasks;
737-
Assert.NotNull(tasks);
738-
Assert.NotNull(tasks.List);
739-
Assert.NotNull(tasks.Cancel);
729+
// Assert - no task store means no tasks capability advertised
730+
Assert.NotNull(_client.ServerCapabilities);
731+
Assert.Null(_client.ServerCapabilities.Tasks);
740732
}
741733

742734
[Fact]
743-
public async Task Server_DotnetProject_ShouldHaveTaskSupport()
735+
public async Task Server_DotnetProject_ShouldDeclareTaskSupportOptional()
744736
{
745737
// Arrange
746738
Assert.NotNull(_client);
@@ -749,77 +741,24 @@ public async Task Server_DotnetProject_ShouldHaveTaskSupport()
749741
var tools = await _client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
750742
var projectTool = tools.FirstOrDefault(t => t.Name == "dotnet_project");
751743

752-
// Assert - dotnet_project should declare TaskSupport = Optional so clients can run
753-
// long operations (build, test, publish) as async tasks
744+
// Assert - the tool attribute declares TaskSupport = Optional so it's ready when
745+
// task support is re-enabled by un-commenting the IMcpTaskStore registration in Program.cs
746+
// (pending fix of https://github.com/modelcontextprotocol/csharp-sdk/issues/1430).
747+
// Without a task store registered, the SDK runs it synchronously inline (graceful degradation).
754748
Assert.NotNull(projectTool);
755749
var execution = projectTool.ProtocolTool.Execution;
756750
Assert.NotNull(execution);
757751
Assert.Equal(ModelContextProtocol.Protocol.ToolTaskSupport.Optional, execution.TaskSupport);
758752
}
759753

760754
[Fact]
761-
public async Task Server_TaskList_ShouldReturnEmptyWhenNoTasksRunning()
755+
public async Task Server_DotnetProject_ShouldWorkSynchronously()
762756
{
763-
// Arrange
757+
// Arrange - verify tools work reliably via normal tools/call (no tasks)
764758
Assert.NotNull(_client);
765759

766-
// Act
767-
var tasks = await _client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
768-
769-
// Assert - no tasks should be running at server start
770-
Assert.NotNull(tasks);
771-
}
772-
773-
[Fact]
774-
public async Task Server_DotnetProject_TaskMode_ShouldCompleteSuccessfully()
775-
{
776-
// Arrange - exercise the full task lifecycle: create task → poll → get result.
777-
// Uses Build with a nonexistent project so the operation is fast but still
778-
// exercises the entire task pipeline including the error-handling filter.
779-
Assert.NotNull(_client);
780-
781-
// Act - invoke dotnet_project as task (the dedicated CallToolAsTaskAsync API)
782-
var mcpTask = await _client.CallToolAsTaskAsync(
783-
"dotnet_project",
784-
new Dictionary<string, object?>
785-
{
786-
["action"] = "Build",
787-
["project"] = "nonexistent.csproj"
788-
},
789-
cancellationToken: TestContext.Current.CancellationToken);
790-
791-
// Assert - task was created successfully
792-
Assert.NotNull(mcpTask);
793-
Assert.NotNull(mcpTask.TaskId);
794-
Assert.NotEmpty(mcpTask.TaskId);
795-
796-
// Poll for completion — GetTaskResultAsync blocks until terminal state
797-
var resultJson = await _client.GetTaskResultAsync(
798-
mcpTask.TaskId,
799-
cancellationToken: TestContext.Current.CancellationToken);
800-
801-
// The key validation: the task infrastructure returns a structured result JSON
802-
// instead of throwing "unknown error". The underlying tool may report a build
803-
// failure (nonexistent project), so the task status may be Completed or Failed —
804-
// what matters is that a proper result is returned rather than an opaque error.
805-
Assert.NotEqual(default, resultJson);
806-
807-
// Verify the task reached a terminal state
808-
var finalTask = await _client.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
809-
Assert.NotNull(finalTask);
810-
Assert.True(
811-
finalTask.Status == McpTaskStatus.Completed || finalTask.Status == McpTaskStatus.Failed,
812-
$"Task should be in a terminal state but was: {finalTask.Status}");
813-
}
814-
815-
[Fact]
816-
public async Task Server_DotnetProject_TaskMode_ShouldAppearInTaskList()
817-
{
818-
// Arrange - verify that a task-mode call is tracked in the task store.
819-
Assert.NotNull(_client);
820-
821-
// Act - start a fast task-mode operation
822-
var mcpTask = await _client.CallToolAsTaskAsync(
760+
// Act - standard synchronous tool call
761+
var result = await _client.CallToolAsync(
823762
"dotnet_project",
824763
new Dictionary<string, object?>
825764
{
@@ -828,15 +767,10 @@ public async Task Server_DotnetProject_TaskMode_ShouldAppearInTaskList()
828767
},
829768
cancellationToken: TestContext.Current.CancellationToken);
830769

831-
Assert.NotNull(mcpTask);
832-
833-
// Wait for it to complete before checking the list
834-
await _client.GetTaskResultAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken);
835-
836-
// Assert - the completed task should still be in the list
837-
var allTasks = await _client.ListTasksAsync(cancellationToken: TestContext.Current.CancellationToken);
838-
Assert.NotNull(allTasks);
839-
Assert.Contains(allTasks, t => t.TaskId == mcpTask.TaskId);
770+
// Assert - tool returns a result (may be an error about missing project, but
771+
// the call itself should not throw ObjectDisposedException)
772+
Assert.NotNull(result);
773+
Assert.NotEmpty(result.Content);
840774
}
841775

842776
#endregion

DotNetMcp.Tests/Server/ServerCapabilitiesTests.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ public async Task DotnetServerCapabilities_SdkVersions_Lts_IsNet100()
210210
}
211211

212212
[Fact]
213-
public async Task DotnetServerCapabilities_Supports_AsyncTasks_IsTrue()
213+
public async Task DotnetServerCapabilities_Supports_AsyncTasks_IsFalse()
214214
{
215215
// Act
216216
var result = (await _tools.DotnetServerCapabilities()).GetText();
@@ -220,8 +220,8 @@ public async Task DotnetServerCapabilities_Supports_AsyncTasks_IsTrue()
220220
.GetProperty("asyncTasks")
221221
.GetBoolean();
222222

223-
// Assert - MCP Task support is enabled via InMemoryMcpTaskStore
224-
Assert.True(asyncTasks);
223+
// Assert - MCP Task support disabled due to SDK v1.1.0 DI scope lifetime bug
224+
Assert.False(asyncTasks);
225225
}
226226

227227
[Fact]
@@ -262,7 +262,7 @@ public async Task DotnetServerCapabilities_JsonSchema_MatchesExpectedStructure()
262262
Assert.True(capabilities.Supports.MachineReadable);
263263
Assert.True(capabilities.Supports.Cancellation);
264264
Assert.True(capabilities.Supports.Telemetry);
265-
Assert.True(capabilities.Supports.AsyncTasks);
265+
Assert.False(capabilities.Supports.AsyncTasks);
266266
Assert.True(capabilities.Supports.Prompts);
267267
Assert.True(capabilities.Supports.Elicitation);
268268
Assert.True(capabilities.Supports.Completions);

DotNetMcp/Program.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,12 @@
2121
// Register ProcessSessionManager as a singleton
2222
builder.Services.AddSingleton<ProcessSessionManager>();
2323

24-
// Register InMemoryMcpTaskStore to enable MCP Task support for long-running operations.
25-
// This allows AI clients to run build/test/publish as async tasks with polling and cancellation.
26-
builder.Services.AddSingleton<IMcpTaskStore, InMemoryMcpTaskStore>();
24+
// TODO: Re-enable InMemoryMcpTaskStore once the MCP SDK fixes the DI scope lifetime bug.
25+
// Upstream issue: https://github.com/modelcontextprotocol/csharp-sdk/issues/1430
26+
// MCP SDK v1.1.0's ExecuteToolAsTaskAsync disposes the request-scoped IServiceProvider
27+
// before the background task resolves services, causing ObjectDisposedException on every
28+
// tool call. Without a task store, the SDK runs tools synchronously inline.
29+
// builder.Services.AddSingleton<IMcpTaskStore, InMemoryMcpTaskStore>();
2730

2831
// Register ToolMetricsAccumulator for in-memory telemetry collection.
2932
// The accumulator is also captured by the telemetry filter added below.

DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public sealed partial class DotNetCliTools
2121
private readonly ProcessSessionManager _processSessionManager;
2222
private readonly ToolMetricsAccumulator? _metricsAccumulator;
2323
private readonly ResourceSubscriptionManager? _subscriptions;
24+
private readonly IMcpTaskStore? _taskStore;
2425

2526
// Constants for server capability discovery
2627
private const string DefaultServerVersion = "1.0.0";
@@ -30,14 +31,15 @@ public sealed partial class DotNetCliTools
3031
private const int MaxSamplingPromptLength = 4000;
3132
private const int MaxSamplingResponseTokens = 256;
3233

33-
public DotNetCliTools(ILogger<DotNetCliTools> logger, ConcurrencyManager concurrencyManager, ProcessSessionManager processSessionManager, ToolMetricsAccumulator? metricsAccumulator = null, ResourceSubscriptionManager? subscriptions = null)
34+
public DotNetCliTools(ILogger<DotNetCliTools> logger, ConcurrencyManager concurrencyManager, ProcessSessionManager processSessionManager, ToolMetricsAccumulator? metricsAccumulator = null, ResourceSubscriptionManager? subscriptions = null, IMcpTaskStore? taskStore = null)
3435
{
3536
// DI guarantees logger is never null
3637
_logger = logger!;
3738
_concurrencyManager = concurrencyManager!;
3839
_processSessionManager = processSessionManager!;
3940
_metricsAccumulator = metricsAccumulator;
4041
_subscriptions = subscriptions;
42+
_taskStore = taskStore;
4143
}
4244

4345
private async Task<string> ExecuteDotNetCommand(string arguments, CancellationToken cancellationToken = default, string? workingDirectory = null)

DotNetMcp/Tools/Cli/DotNetCliTools.Misc.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public async partial Task<CallToolResult> DotnetServerCapabilities()
7171
Cancellation = true,
7272
Telemetry = true, // SDK v0.6+ supports request duration logging and OpenTelemetry semantic conventions
7373
Metrics = true, // In-memory per-tool metrics via MCP message filter (dotnet_server_metrics tool)
74-
AsyncTasks = true, // MCP Task support enabled: long-running operations (build, test, publish) can run as async tasks
74+
AsyncTasks = _taskStore != null, // Derived from DI: true when IMcpTaskStore is registered; currently false (MCP SDK DI scope bug https://github.com/modelcontextprotocol/csharp-sdk/issues/1430)
7575
Prompts = true, // Predefined prompt catalog: create_new_webapi, add_package_and_restore, run_tests_with_coverage
7676
Elicitation = true, // Elicitation for confirmation before destructive ops (Clean, solution Remove)
7777
McpLogging = true, // MCP log notifications sent to client during key operations (build, test, publish, restore, package add/update)

0 commit comments

Comments
 (0)