Skip to content

fix: task-mode tool calls failed with 'unknown error'#406

Merged
jongalloway merged 2 commits intomainfrom
fix/task-mode-resilient-error-handling
Mar 18, 2026
Merged

fix: task-mode tool calls failed with 'unknown error'#406
jongalloway merged 2 commits intomainfrom
fix/task-mode-resilient-error-handling

Conversation

@jongalloway
Copy link
Copy Markdown
Owner

Problem

When VS Code's MCP client invokes dotnet_project with task support enabled (MCP SDK v1.1.0), the call fails with:

Task 08de8470c7f494c50000000000000002 failed: unknown error

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 CallToolResult to return — so it surfaces an opaque 'unknown error' to the client.

Root Cause

Two issues compound:

  1. Unhandled exceptions in tool execution propagate through the task pipeline without a structured error result
  2. Progress channel failures (e.g., when the client doesn't support progress notifications) can throw during tool execution

Fix

Resilient error-handling filter (Program.cs)

The existing telemetry CallToolFilter now catches unhandled exceptions (excluding OperationCanceledException and McpException) and converts them into a CallToolResult { 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 — full CallToolAsTaskAsyncGetTaskResultAsyncGetTaskAsync cycle
  • Server_DotnetProject_TaskMode_ShouldAppearInTaskList — verifies task tracking via ListTasksAsync

Testing

  • All 1,319 unit tests pass
  • All 58 conformance tests pass (including 2 new task lifecycle tests)

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
Comment on lines +131 to +140
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

Generic catch clause.

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 System and 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.

Suggested changeset 1
DotNetMcp/Program.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/DotNetMcp/Program.cs b/DotNetMcp/Program.cs
--- a/DotNetMcp/Program.cs
+++ b/DotNetMcp/Program.cs
@@ -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".
EOF
@@ -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".
Copilot is powered by AI and may make mistakes. Always verify output.
Comment on lines +233 to +236
catch (Exception)
{
// Progress is best-effort; never let it break tool execution.
}

Check notice

Code scanning / CodeQL

Generic catch clause Note

Generic catch clause.

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 Exception to exclude severe system-level exceptions such as OutOfMemoryException, ThreadAbortException, and ThreadInterruptedException.
  • 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.

Suggested changeset 1
DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs b/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
--- a/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
+++ b/DotNetMcp/Tools/Cli/DotNetCliTools.Core.cs
@@ -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.
         }
EOF
@@ -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.
}
Copilot is powered by AI and may make mistakes. Always verify output.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CallToolFilter that converts unexpected exceptions into CallToolResult { 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 + GetTaskResultAsync and 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 when ex.Message is empty and loses important context like exception type / inner exception. Consider including ex.GetType().Name and 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.

Comment on lines +135 to +138
return new CallToolResult
{
IsError = true,
Content = [new TextContentBlock { Text = $"Internal error in {toolName}: {ex.Message}" }]
Comment on lines +801 to +811
// 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}");
@jongalloway
Copy link
Copy Markdown
Owner Author

@copilot address comments

…k conformance test comment

Co-authored-by: jongalloway <68539+jongalloway@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 18, 2026

@copilot address comments

Addressed both review comments in commit 4918f12:

  1. Program.cs: The exception handler now runs both toolName and ex.Message through SecretRedactor.Redact() before including them in the error result, consistent with how CLI output is redacted elsewhere in the codebase.

  2. McpConformanceTests.cs: Updated the comment to accurately reflect that the test validates the task infrastructure returns a structured result (instead of "unknown error") — the tool itself may legitimately fail (e.g., nonexistent project), so both Completed and Failed are valid terminal states.

@jongalloway jongalloway merged commit f11e0de into main Mar 18, 2026
4 checks passed
@jongalloway jongalloway deleted the fix/task-mode-resilient-error-handling branch March 18, 2026 01:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants