fix: return debugger errors without a stack trace (#118)

## What this changes

Tool calls that fail with a `DebuggerError` (timeouts, a debugger that
exited, failed connects) now reach the caller as the error message
alone. Unexpected exceptions still include the stack trace.

## Why

`_dispatch_tool` appended `traceback.format_exc()` to every error. The
last line of that trace repeats the exception message, so since #114 a
timeout carrying partial output returned it twice: about 130 KB against
a real kernel target, instead of the 64 KiB cap. The trace itself tells
the model nothing it can act on.

## How it was verified

- New hermetic test through the registered tool handler (cdb and kd):
fails before the fix, passes after.
- Full suite: 132 passed, 2 skipped.
- Against a real KDNET target (TESTBOX-1): both kernel scenarios pass,
and `!process 0 7` with an 8 s timeout now returns 61 KB with the
partial output once and no traceback.
This commit is contained in:
Sven Scharmentke
2026-09-29 23:53:52 +02:00
committed by GitHub
parent 5bf4213313
commit c3a8d29bc3
3 changed files with 45 additions and 1 deletions
+4
View File
@@ -24,6 +24,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
instead of every pending command, `wait_for_break`, or session open sitting out
its full timeout and then misreporting a timeout (#115). Thanks to
@RamanaIntel for the fix.
- Debugger errors (timeouts, a debugger that exited, failed connects) reach the
caller as their message alone, without a Python stack trace. The trace
repeated the message, so a timeout with partial output returned that output
twice.
## [1.3.0] - 2026-09-10
+5 -1
View File
@@ -9,7 +9,7 @@ from typing import Dict, Optional
from contextlib import asynccontextmanager
from .cdb_session import CDBSession
from .debug_session import DEFAULT_WAIT_FOR_BREAK_TIMEOUT
from .debug_session import DEFAULT_WAIT_FOR_BREAK_TIMEOUT, DebuggerError
from .kd_session import KDSession
from .filter_script import FilterScript, load_filter_script
from .prompts import load_prompt
@@ -517,6 +517,10 @@ def _create_server(
except MCPError:
raise
except DebuggerError as e:
# An expected debugger failure: its message is the whole story, and a
# stack trace would repeat it (partial output included) a second time.
raise MCPError(INTERNAL_ERROR, f"Error executing tool {name}: {e}")
except Exception as e:
traceback_str = traceback.format_exc()
raise MCPError(INTERNAL_ERROR, f"Error executing tool {name}: {str(e)}\n{traceback_str}")
@@ -9,6 +9,7 @@ import pytest
from mcp.types import CallToolRequestParams
from mcp_windbg import server as server_module
from mcp_windbg.debug_session import DebuggerError
@pytest.fixture
@@ -278,3 +279,38 @@ async def test_break_in_racing_with_close_reports_debugger_error(tool_handler, k
name="send_ctrl_break", arguments={"session_id": "test-session"}
),
)
@pytest.mark.anyio
@pytest.mark.parametrize("kind", ["cdb", "kd"])
async def test_debugger_error_reaches_caller_without_traceback(tool_handler, kind):
class TimingOutSession:
is_live_session = True
def send_command(self, command, timeout):
raise DebuggerError(
"Command timed out after 8 seconds: !process 0 7\n"
"Partial output before timeout:\nPROCESS ffffc48d14520040",
partial_output=["PROCESS ffffc48d14520040"],
)
server_module._sessions["test-session"] = {
"session": TimingOutSession(),
"kind": kind,
"label": "test target",
}
with pytest.raises(server_module.MCPError) as exc:
await tool_handler(
None,
CallToolRequestParams(
name=f"run_{kind}_command",
arguments={"session_id": "test-session", "command": "!process 0 7"},
),
)
# The message is for the model: a stack trace adds nothing, and its last
# line would repeat the (possibly 64 KiB of) partial output a second time.
message = exc.value.message
assert "Command timed out" in message
assert "Traceback" not in message
assert message.count("PROCESS ffffc48d14520040") == 1