fix: task-mode tool calls failed with 'unknown error'#406
Conversation
Add resilient error handling so MCP task-mode calls complete with a
structured error result instead of propagating unhandled exceptions
as opaque 'unknown error'.
Changes:
- Program.cs: catch unhandled exceptions in CallTool filter and return
CallToolResult { IsError = true } with a meaningful message
- DotNetCliTools.Core.cs: add ReportProgressSafe() to prevent broken
progress channels from crashing tool execution
- McpConformanceTests.cs: add two task lifecycle conformance tests that
exercise CallToolAsTaskAsync → GetTaskResultAsync → GetTaskAsync
- DotNetMcp.Tests.csproj: add ModelContextProtocol package for task APIs
| catch (Exception ex) | ||
| { | ||
| // Convert unhandled exceptions to a structured error result so the MCP task | ||
| // lifecycle completes with a proper error instead of "unknown error". | ||
| return new CallToolResult | ||
| { | ||
| IsError = true, | ||
| Content = [new TextContentBlock { Text = $"Internal error in {toolName}: {ex.Message}" }] | ||
| }; | ||
| } |
Check notice
Code scanning / CodeQL
Generic catch clause Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
General approach: Replace the generic catch (Exception ex) with catches that are limited to expected, recoverable exception types, and ensure that more serious process-level exceptions are not swallowed. In a boundary/adapter layer like this, keeping a fairly broad but still typed filter list is reasonable, but we should avoid a blanket Exception.
Best concrete fix here without changing user-visible behavior for ordinary errors:
- Change the
catch (Exception ex)into a filtered catch that ignores non-recoverable exceptions (letting them propagate) and only handles “regular” exceptions as structured tool errors. - In C#, this can be elegantly expressed with a when-filter:
catch (Exception ex) when (ex is not OutOfMemoryException && ex is not StackOverflowException && ex is not ThreadAbortException). - This preserves the current behavior for the vast majority of exceptions tools will throw (argument errors, IO errors, etc.), but stops us from swallowing catastrophic runtime failures.
- No imports or additional methods are required; all used exception types are in
Systemand available by default.
Concretely, in DotNetMcp/Program.cs, at the catch (Exception ex) block starting at line 131, change it to a filtered catch as described above, keeping the catch body the same.
| @@ -128,7 +128,12 @@ | ||
| // Let MCP protocol exceptions propagate — these carry proper JSON-RPC error codes. | ||
| throw; | ||
| } | ||
| catch (Exception ex) | ||
| catch (Exception ex) when (ex is not OutOfMemoryException | ||
| && ex is not StackOverflowException | ||
| #if !NETSTANDARD2_0 && !NETFRAMEWORK | ||
| && ex is not ThreadAbortException | ||
| #endif | ||
| ) | ||
| { | ||
| // Convert unhandled exceptions to a structured error result so the MCP task | ||
| // lifecycle completes with a proper error instead of "unknown error". |
| catch (Exception) | ||
| { | ||
| // Progress is best-effort; never let it break tool execution. | ||
| } |
Check notice
Code scanning / CodeQL
Generic catch clause Note
Show autofix suggestion
Hide autofix suggestion
Copilot Autofix
AI 11 days ago
In general, to fix overly broad catch (Exception) blocks, you either (1) catch only the specific, expected exception types, or (2) if you truly need a broad catch for robustness, you still avoid swallowing critical system exceptions and optionally log unexpected ones. Here, the method is explicitly “best-effort” and should not throw to callers, so we should keep the behavior of not letting progress failures break tool execution, but refine which exceptions are caught.
The best targeted fix without changing external behavior is to:
- Narrow the catch from
Exceptionto exclude severe system-level exceptions such asOutOfMemoryException,ThreadAbortException, andThreadInterruptedException. - Re-throw these critical exceptions while still swallowing ordinary ones.
- Optionally, we could log non-critical exceptions, but that would change behavior (by adding logging output) and might not be desired; the current comment suggests silence is acceptable, so we’ll keep it silent.
Concretely, in DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs, in ReportProgressSafe, replace catch (Exception) with a catch (Exception ex) that checks for system-level exceptions and rethrows them, while swallowing everything else. This keeps the “best-effort; never let it break tool execution” contract for normal failures yet avoids suppressing critical runtime exceptions.
| @@ -230,7 +230,9 @@ | ||
| { | ||
| progress?.Report(new ProgressNotificationValue { Progress = value, Total = 1, Message = message }); | ||
| } | ||
| catch (Exception) | ||
| catch (Exception ex) when (ex is not OutOfMemoryException | ||
| && ex is not ThreadAbortException | ||
| && ex is not ThreadInterruptedException) | ||
| { | ||
| // Progress is best-effort; never let it break tool execution. | ||
| } |
There was a problem hiding this comment.
Pull request overview
Improves MCP task-mode reliability so tool invocations return a structured CallToolResult instead of surfacing an opaque "unknown error" when exceptions occur, aligning behavior with VS Code’s MCP task lifecycle.
Changes:
- Add a resilient
CallToolFilterthat converts unexpected exceptions intoCallToolResult { IsError = true }so task-mode calls always complete with a result. - Make progress reporting best-effort by swallowing exceptions thrown from progress notifications.
- Add conformance tests that exercise
CallToolAsTaskAsync+GetTaskResultAsyncand task listing behavior.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs | Wrap progress reporting in a safe helper so progress-channel failures don’t crash tool/task execution. |
| DotNetMcp/Program.cs | Extend the telemetry/tool filter to catch unexpected exceptions and return an error CallToolResult instead of throwing through the task pipeline. |
| DotNetMcp.Tests/Server/McpConformanceTests.cs | Add task-mode lifecycle tests to reproduce/guard against the VS Code task pipeline failure mode. |
| DotNetMcp.Tests/DotNetMcp.Tests.csproj | Add MCP SDK package reference for task-mode APIs used by new tests. |
Comments suppressed due to low confidence (1)
DotNetMcp/Program.cs:139
$"Internal error in {toolName}: {ex.Message}"can be unhelpful whenex.Messageis empty and loses important context like exception type / inner exception. Consider includingex.GetType().Nameand surfacing the most relevant inner message (while still keeping it concise) so clients can triage failures more easily.
Content = [new TextContentBlock { Text = $"Internal error in {toolName}: {ex.Message}" }]
};
You can also share your feedback on Copilot code review. Take the survey.
DotNetMcp/Program.cs
Outdated
| return new CallToolResult | ||
| { | ||
| IsError = true, | ||
| Content = [new TextContentBlock { Text = $"Internal error in {toolName}: {ex.Message}" }] |
| // The key validation: the task lifecycle completes with a proper result JSON | ||
| // instead of throwing "unknown error". The result may report a build failure | ||
| // (nonexistent project) but the task itself must not fail. | ||
| Assert.NotEqual(default, resultJson); | ||
|
|
||
| // Verify the task reached a terminal state | ||
| var finalTask = await _client.GetTaskAsync(mcpTask.TaskId, cancellationToken: TestContext.Current.CancellationToken); | ||
| Assert.NotNull(finalTask); | ||
| Assert.True( | ||
| finalTask.Status == McpTaskStatus.Completed || finalTask.Status == McpTaskStatus.Failed, | ||
| $"Task should be in a terminal state but was: {finalTask.Status}"); |
|
@copilot address comments |
…k conformance test comment Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
Addressed both review comments in commit
|
Problem
When VS Code's MCP client invokes
dotnet_projectwith task support enabled (MCP SDK v1.1.0), the call fails with:This happens because the SDK's task infrastructure wraps tool calls in a task lifecycle. If the tool throws an unhandled exception, the SDK has no
CallToolResultto return — so it surfaces an opaque 'unknown error' to the client.Root Cause
Two issues compound:
Fix
Resilient error-handling filter (
Program.cs)The existing telemetry
CallToolFilternow catches unhandled exceptions (excludingOperationCanceledExceptionandMcpException) and converts them into aCallToolResult { IsError = true }with a meaningful error message. This ensures the MCP task lifecycle always completes with a proper result.Defensive progress reporting (
DotNetCliTools.Core.cs)Added
ReportProgressSafe()that wraps progress notifications in try/catch so broken or unsupported progress channels never crash tool execution.Task lifecycle conformance tests (
McpConformanceTests.cs)Two new tests exercise the full task lifecycle that VS Code uses:
Server_DotnetProject_TaskMode_ShouldCompleteSuccessfully— fullCallToolAsTaskAsync→GetTaskResultAsync→GetTaskAsynccycleServer_DotnetProject_TaskMode_ShouldAppearInTaskList— verifies task tracking viaListTasksAsyncTesting