windbg-mcp
Health Uyari
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Low visibility — Only 6 GitHub stars
Code Basarisiz
- eval() — Dynamic code execution via eval() in examples/flareauthenticator/solve.js
- fs module — File system access in examples/flareauthenticator/solve.js
Permissions Gecti
- Permissions — No dangerous permissions requested
Bu listing icin henuz AI raporu yok.
MCP server exposing WinDbg/DbgEng (live user-mode, kernel, crash dumps, Time Travel Debugging) to AI agents over stdio
windbg-mcp
An MCP server that exposes WinDbg/DbgEng to AI agents
(Claude Code, Claude Desktop, Cursor, …) over stdio. It drives a live debugger engine for
user-mode, kernel-mode, crash-dump, and Time Travel Debugging (TTD) workflows.
The low-level engine bindings live in win-kexp
(src/dbgeng.rs); this crate adds process-per-session engine supervision and the rmcp tool
surface on top.
Architecture
One debug session per process. dbgeng.dll holds a single debuggee session per process — that is
why .opendump replaces the target rather than opening a second one — so this server runs the MCP
protocol in a supervisor process and each open target in its own engine worker child
process. Two things follow, and they are why it is built this way:
A session that cannot be unwound costs a process, not the server. A live-kernel attach waits
for its target withWaitForEvent(INFINITE), and nothing can interrupt a wait that has not yet
connected — so a guest that never dials in blocks forever. That blocks one worker, whichend_sessioncan terminate. It used to block the server's only engine thread, and the only
recovery was restarting the server.Sessions are concurrent. Triage a crash dump while a kernel attach is live; keep a TTD trace
open while you look at another. Up to four at once.engine.rs— the supervisor: the session registry, worker spawn/teardown, and the routing
that turns asession_idinto "which worker". Each session has one queue with one consumer, so
calls against a session are serialized — one runs at a time, and the one running finishes
before the next starts. Serialized is not ordered: two calls submitted before either has
answered reach that queue in whichever order wins the race, so await each result before sending
the next call that depends on it.worker.rs— the child process. TheDebugEngineis created on, and confined to, one OS
thread inside it (DbgEng requires serialized, single-thread access, andWaitForEventmust run on
the session-owning thread). Acatch_unwindguard turns a panic in one operation into a failed
call rather than a dead session. The request reader is a second thread that only ever reads and
hands on, so it is never blocked by the engine — which is what makesinterruptand the
abandon-a-batch signal deliverable to a worker that is busy.proto.rs— the line-delimited JSON protocol between the two. A closure cannot cross a
process boundary, so what used to be closures marshalled onto the engine thread are now
serializable operations — deliberately tool-shaped rather than DbgEng-shaped, so a tool that is
several engine calls (reachable_from_dispatch's call-graph walk) stays one indivisible job. It
travels on a pair of anonymous pipes the worker inherits, not on its standard handles: an
extension DLL that prints to the console writes to the worker's stdout, which is drained into the
log and carries nothing else.server.rs— the MCP tools (see below), built withrmcp's#[tool_router]/#[tool_handler].kdconn.rs— kernel connection strings, the one tool argument that is a secret: profile
resolution, and theConnectiontype whoseDebug/Displayare redacted so a key can only be
unwrapped deliberately (see Kernel connection profiles).ttd.rs— locatesTTD.exeand launches trace recording.main.rs— role selection (supervisor or worker), tokio + stdio transport. Logs go to
stderr (stdout is the JSON-RPC channel); workers inherit the supervisor's stderr, so everything
lands in the same place. Workers never outlive the connection: a disconnect asks every session
to release its target — all of them concurrently — waits five seconds, and terminates only
the workers that have not finished by then; a worker also exits on its own once its request
channel closes. A session running adebug_batchis told to abandon it by that same request, and
then gets as long as the batch says it still needs on top of the grace — the only case where a
disconnect waits longer, and never longer than the batch's own budget allowed.
Which of those two endings a session gets matters for a live kernel. DbgEng leaves a
detached-but-halted kernel frozen, so a worker that releases its target leaves the machine
running, while a worker that is terminated leaves it stopped. Five seconds is enough for an
idle session and for most busy ones, but a session in the middle of long work may not make it,
so end a live kernel session withend_session— which allows considerably longer — rather
than relying on the disconnect.
MCP protocol revision: built on rmcp 3.x, this server accepts every revision that SDK knows —2026-07-28 and the initialize-handshake ("legacy") era before it (2025-11-25, 2025-06-18,2025-03-26, 2024-11-05) — and serves whichever the client selects. A 2026-07-28 client gets the
stateless, per-request model (server/discover, resultType, per-request _meta) and may open withserver/discover instead of initialize; older clients keep the handshake, and a client that offers
an unknown revision is answered with 2025-11-25. That revision also makes SEP-2549's cache fields
mandatory on a paginated result, so tools/list answers a 2026-07-28 client with ttlMs: 0 andcacheScope: public, and omits both for the older revisions, which never defined them. This is why
the rmcp dependency has a 3.1.1 floor: every 3.x before it omitted the fields on every revision,
and a client that validates against the spec schema then rejects the whole tool list.
Requirements
- Windows x64 (host bitness must match the target).
dbgeng.dll/dbghelp.dll— present inSystem32on modern Windows 11 (verified with10.0.26100). This is enough for live user-mode/kernel debugging and crash-dump analysis.- For crash-dump
!analyze(and any other!-extension command), the engine needs the
WinDbgwinext\extensions bundled next to the binary — System32's engine ships none, so!analyzewould return "No export analyze found". See Bundling the WinDbg engine below. - For the kernel driver tools (
driver_object/device_object/irp_stack), the engine
needswinxp\kdexts.dllbundled next to the binary — it exports!drvobj/!devobj/!irp, which
System32 doesn't ship, so without it those three return "No export drvobj found" even though the
kernel attach itself succeeded. See Bundling the WinDbg engine below. - For Time Travel Debugging (
.run) replay, the System32 engine is not enough — it rejects.runtraces (0x80070057). You need the WinDbg engine (which bundles the TTD replay
components) loaded next to the binary — see TTD engine below. TTD.exe(the standalone Time Travel Debugging recorder) forrecord_trace— ships with the
WinDbg / TTD store packages; put it onPATH.- A reachable symbol server (e.g.
srv*https://msdl.microsoft.com/download/symbols) for symbol-name
queries likettd_calls("ucrtbase!_stdio_common_vfprintf"). Offline, address-based queries and the
data model still work; symbol names won't resolve. - Administrator for live kernel debugging and TTD recording (not for replay).
Build or download
Prebuilt Windows x64 binaries are attached to each
GitHub release aswindbg-mcp-vX.Y.Z-windows-x64.zip (with a SHA256SUMS.txt to verify the download
against — the skill's setup.md snippet does this for you) — no Rust toolchain needed.
To build from source instead:
cargo build --release
win-kexp is fetched automatically as a git dependency from glslang/win-kexp — no sibling checkout needed.
cargo test covers the unit tests plus an end-to-end smoke test that drives the built binary over
stdio. Run it after a dependency bump or an MCP spec revision — seedocs/smoke-test.md.
Install with Scoop
A community-maintained Scoop manifest lives ingitfool/scoop-dungeon
(#109), which turns the download — and every
later update — into one command:
scoop bucket add dungeon https://github.com/gitfool/scoop-dungeon
scoop install windbg-mcp
scoop update windbg-mcp # later; the manifest's checkver/autoupdate tracks releases here
It unpacks the release zip and — when the Microsoft.WinDbg store package is installed on your
machine — its post_install copies that package's engine DLLs and its ttd\, winext\ andwinxp\ subdirectories next to the binary: the whole
Bundling the WinDbg engine step below, done for you on install and
on every update. That copy is local, from a WinDbg you already installed; neither the release zip
nor the bucket redistributes Microsoft's engine. Without that package you get the in-box System32
engine: live and crash-dump debugging work, TTD .run replay, !analyze and the kernel driver
tools don't. The copy happens at install time, so if you add WinDbg afterwards, re-runpost_install with scoop update --force windbg-mcp (scoop reset won't — it only re-links) or
copy the files by hand as below, with $dst set to …\scoop\apps\windbg-mcp\current.
Point your MCP client at the current junction, so an update needs no config change:
{
"mcpServers": {
"windbg": { "command": "C:\\Users\\<you>\\scoop\\apps\\windbg-mcp\\current\\windbg-mcp.exe" }
}
}
A connected client holds that binary open (and each engine worker re-executes the same image), so
disconnect the windbg server — /mcp in Claude Code — before scoop update, then reconnect to
actually run the new build.
The bucket is community-maintained — not by this repo, and not audited by it. A Scoop manifest
is code, not just a URL: post_install is arbitrary PowerShell, run against whatever download URL
and hash the manifest carries at the time, and autoupdate rewrites both on each new release. So
installing from it is a trust decision about that bucket, taken on your own judgement.
Scoop is not required either way. It installs the same
release asset described above, which you can
download directly, check against the published SHA256SUMS.txt, and verify withgh attestation verify — see Releasing — none of which a third-party manifest can
promise on this project's behalf.
Bundling the WinDbg engine
Needed for three things: TTD .run replay (System32's engine rejects traces with 0x80070057),
crash-dump !analyze (which lives in the winext\ extensions that System32 doesn't ship), and the
kernel driver tools driver_object/device_object/irp_stack (which need winxp\kdexts.dll).
So a live-kernel-only user needs this section too, even though the attach itself works on the
System32 engine. DebugCreate binds to whichever dbgeng.dll the loader finds first, and the app
directory is searched before System32, so the copied WinDbg engine (which replays TTD traces
and ships the extensions) wins. One-time, from the installed WinDbg store package:
$wd = (Get-AppxPackage Microsoft.WinDbg).InstallLocation + "\amd64"
$dst = "C:\workspace\windbg-mcp\target\release"
Copy-Item "$wd\dbgeng.dll","$wd\dbghelp.dll","$wd\dbgcore.dll","$wd\dbgmodel.dll",`
"$wd\symsrv.dll","$wd\msdia140.dll" $dst -Force
Copy-Item "$wd\ttd" "$dst\ttd" -Recurse -Force # TTDReplay*.dll, TtdExt.dll, TTDAnalyze.dll, ...
Copy-Item "$wd\winext" "$dst\winext" -Recurse -Force # ext.dll (!analyze), kext.dll, … — crash-dump triage
New-Item "$dst\winxp" -ItemType Directory -Force | Out-Null
Copy-Item "$wd\winxp\kdexts.dll" "$dst\winxp" -Force # !drvobj/!devobj/!irp — the kernel driver tools
- The
ttd\subdir provides the@$cursession.TTD/@$curprocess.TTDdata model and the!tt
time-travel commands. - The
winext\subdir providesext.dll(which exports!analyze) and the other!-extensions.open_dumpruns.load extfor you, but note the **unqualified!analyzedoes not resolve** on
this minimal engine — use the module-qualified **!ext.analyze -v** for crash-dump triage. Withoutwinext\,!analyzereturns "No export analyze found". winxp\kdexts.dllprovides!drvobj/!devobj/!irp, behind thedriver_object/device_object/irp_stacktools.attach_kernel/attach_kernel_local.load kdextsfor you and nothing complains at attach time, so a missing file first surfaces as
"No export drvobj found" from those three tools. It lives inwinxp\, notwinext\— the
engine searches that subdir by name.msdia140.dllis required for PDB symbols. Without it,dbghelpcan't parse any PDB
(dia error 0x8007007e) and silently falls back to export symbols — which makesmodule!name
lookups (and sottd_calls("ucrtbase!__stdio_common_vfprintf")) fail even with the right PDB in
the cache.symsrv.dllis needed to read a symbol-store cache.
(cargo clean wipes target\, so re-copy after one.) Live and dump debugging work with or without
the TTD engine; PDB symbol names* need msdia140.dll + a symbol path
(execute → `.sympath srvC:\ProgramData\Dbg\sym*https://msdl.microsoft.com/download/symbols`).
Use with an MCP client
Point your client at the built binary, e.g. Claude Code:
// .mcp.json (or claude_desktop_config.json under "mcpServers")
{
"mcpServers": {
"windbg": {
"command": "C:\\workspace\\windbg-mcp\\target\\release\\windbg-mcp.exe"
}
}
}
As a Claude Code plugin
This repo is also a single-plugin Claude Code marketplace:
installing it registers the windbg MCP server and a windbg-debugging skill that
knows how to drive it (setup, crash-dump, live/kernel, and TTD playbooks).
/plugin marketplace add glslang/windbg-mcp
/plugin install windbg-mcp@windbg-mcp
The plugin ships source, not a binary, so after installing you still put the server binary in
place — download a prebuilt release or build from source — and (for .run replay, crash-dump!analyze, and the kernel driver tools) bundle the WinDbg engine — the skill's setup.md
walks through it, and it mirrors the Build or download and
Bundling the WinDbg engine sections above. Then /reload-plugins
to connect the server. The plugin points at ${CLAUDE_PLUGIN_ROOT}/target/release/windbg-mcp.exe.
From the official MCP registry
The server is listed in the official MCP registry asio.github.glslang/windbg-mcp. Clients that support the registry — or that install
MCPB bundles directly — can add it by name: the client
downloads that release's .mcpb bundle, verifies its SHA-256, and wires up the windbg-mcp.exe
inside it as an stdio server, with no Rust build or manual binary placement.
The bundle is Windows x64 only and ships just the server binary, so the one-time engine setup
still applies — for TTD .run replay, crash-dump !analyze, and thedriver_object/device_object/irp_stack tools, drop the WinDbg engine DLLs next to the
client-extracted windbg-mcp.exe (the skill's setup.md covers it). Basic live and crash-dump
work runs on the in-box System32 engine without them — a kernel attach included, but not those
three tools, which need winxp\kdexts.dll.
Releasing
The plugin sets an explicit version in.claude-plugin/plugin.json, so users only receive an update
when that version changes — pushing commits alone does not trigger one. To cut a release, bumpversion in plugin.json and Cargo.toml, bump the release badge near the top of this README,
add a matching entry toCHANGELOG.md, and tag the commit vX.Y.Z. Runclaude plugin validate . --strict before publishing. Pushing the tag runsrelease.yml, which verifies the tag matches both manifest
versions and the README badge, builds windbg-mcp.exe, and attaches the zip + SHA256 checksum to the GitHub release.
It also builds an MCPB bundle
(windbg-mcp-vX.Y.Z-windows-x64.mcpb, described bypackaging/mcpb/manifest.json) and publishes aserver.json entry to the official MCP Registry
(io.github.glslang/windbg-mcp) with the mcp-publisher CLI over GitHub OIDC — no secrets. CI
stamps the release version into both files and the bundle's SHA-256 into server.json, so
neither is part of the manual bump list above.
The zip also gets a signed
build-provenance attestation
tying it to the workflow run that built it — verify with:
gh attestation verify <zip> --repo glslang/windbg-mcp `
--signer-workflow glslang/windbg-mcp/.github/workflows/release.yml
(--repo alone only proves the attestation came from some workflow in this repo;--signer-workflow pins it to the release workflow.)
Walkthroughs
docs/crash-dump-walkthrough.md— triaging a real kernel
minidump (docs/samples/052126-34312-01.dmp): a0x9F DRIVER_POWER_STATE_FAILUREtraced tonvlddmkm.sys—crash_triagefor the bug check as
fields, then!ext.analyze -vand a manual device-stack walk for the culprit it cannot name, with
the real outputs and the partial-minidump (0x80040205) gotcha. Ends on a second sample, a0x13Ain a PDB-less driver, where!analyzesaysUnknown_Moduleand the frame saysMessageManager+0x1654— the same offset in five dumps that loaded the driver at five addresses.docs/ttd-walkthrough.md— a hands-on tour of the TTD tools against thexusheng6/TTD_labhelloworldsample: opening a.run,
surveying events/threads, forward/reverse navigation, memory analysis, and countingprintfcalls
with symbols (with the real outputs and the gotchas). It maps each tool to the lab's exercises.docs/flareauthenticator-ttd-walkthrough.md— a full
TTD → Z3 solve of an obfuscated Qt crackme (Flare-On 12 #8). Defeats an anti-analysis env guard,
records a wrong-guess run, and usesttd_calls/ttd_memory/reverse-navigation to peel control-flow
flattening + computed calls + encrypted strings down to the exact check: a per-keystroke rolling hash
that reduces to a pure weighted sum. The 25 weights come from the replay, the 250gvalues from
debugger function-evaluation, and Z3 finds a satisfying code — which reveals the flag (the code is
intentionally non-unique). Runnable solver inexamples/flareauthenticator/;
recorded terminal session indocs/flareauthenticator.cast
(asciinema play) — rendered as a GIF in the walkthrough.docs/driver-ioctl-walkthrough.md— enumerating a driver's IOCTL
surface and deciding user-mode reachability on a live KDNET kernel:driver_object/ufto recover
the\Driver\mountmgrdispatch switch,decode_ioctlfor the access tiers, the device DACL parsed
from memory, and anioctl_tracesweep — ending with a reachability report (which codes a standard
user can reach vs. what the I/O manager blocks).
Tools
| Group | Tools |
|---|---|
| Session | open_dump, open_trace, attach_kernel_local, attach_kernel, attach_process, launch, interrupt, end_session, session_status |
| State | registers, read_memory, walk_memory, backtrace, modules, threads, disassemble, dx |
| Crash | crash_triage — a bug check as fields: code and parameters, crashing process, the stack as module+RVA, and the faulting driver frame |
| Control | go, step_over, step_into, set_breakpoint, run_to_address |
| Transaction | debug_batch — an ordered sequence with assertions and a rollback the engine process runs on every path |
| TTD nav | step_back (t-), step_over_back (p-), reverse_go (g-), goto_position (!tt) |
| TTD analysis | ttd_calls, ttd_memory, ttd_events, index_trace, record_trace |
| Driver IOCTL | decode_ioctl, driver_object, device_object, irp_stack, ioctl_trace, reachable_from_dispatch |
| Kernel pool | pool_find_tag, pool_chunk, pool_census, pool_diagnostics |
| User Segment Heap | heap_list, heap_allocations, heap_chunk, heap_census, heap_diagnostics |
| Raw | execute — run any debugger command, returns full text output |
Sessions and session handles
The six Session tools that open a target (open_dump, open_trace, attach_kernel_local,attach_kernel, attach_process, launch) each create a session — one engine worker process
holding one target — and return a session_id. Every tool that touches a target accepts that id
as an optional argument, and it is what routes the call to the right worker.
Sessions are independent. Opening a second target does not disturb the first, a call against one
does not queue behind work in another, and ending one leaves the rest alone. Up to 4 at once; at
the limit a new open reclaims the oldest idle session, and if every session has a call in flight
the open is refused with the list rather than picking a victim. Sessions end when you end_session
them or when the client disconnects — a disconnect is treated as end_session on everything, so no
debugger process is left behind. It gives each session a shorter grace than end_session does,
though, so end a live kernel session explicitly if you can: one still busy at disconnect is
terminated, and a terminated kernel session leaves its target halted.
Omit session_id and a call goes to the current session: the most recently opened one that will
still accept work. That is the pre-handle behaviour and it still holds. What supplying the id buys
is that the call can never land on a target you did not open — it fails loudly instead.decode_ioctl (pure) and record_trace (independent of any debug session) do not take one.
session_status lists every session — what it is, what state it is in, how long it has been there,
and which one is current — or reports on one you name. It never queues on any worker, so it answers
even while a session is parked.
Stopping a call that is taking too long. interrupt Ctrl+Breaks a session's engine, exactly as
Ctrl+Break does at a WinDbg prompt, and leaves the session and its target alone. Call it while the
slow call is still outstanding — it travels on the session's queue but is answered by the worker's
request reader, so it does not queue behind the operation it is meant to stop. That operation ends
at the debugger's next poll and returns whatever it had reached to the call that started it,
marked as cut short, and the session takes the next call immediately. It is bound to the job that
was running when it arrived, so it can never land on the one after it; with nothing running it says
so and does nothing. Two things it cannot reach, both properties of the debugger: an operation that
never polls for the break, and the parked kernel attach below.
A debug_batch is the one call that stops itself: it checks between steps and, when interrupted,
runs its always block and reports BATCH: INTERRUPTED — so no step after the interrupt is applied
and the session keeps its target, unlike end_session, which also stops a batch but takes the
session with it. Its rollback is not interruptible: cleanup runs as part of the same call, and a
restore cut short would come back Ok with partial output and be reported as a rollback that
completed while the target was still changed — so an interrupt aimed at a batch that is unwinding
says so and sends nothing, as does one repeated while a batch is still stopping.
Recovering a session that is stuck. A per-call timeout abandons the wait, not the job, so a
call that reports a timeout may still be running. The case that matters is attach_kernel: it waits
for the target to dial in with no timeout, and DbgEng cannot interrupt a wait that has not yet
connected — so a guest that is powered off, not booted with debugging enabled, or pointed at the
wrong host/port/key never arrives, and that wait never ends. session_status distinguishes a link
that is still coming up (normal, ~25s for a KDNET resync) from one that has been waiting far longer
than a healthy attach ever takes. For the second, end_session is the recovery: it asks the worker
to let go, and terminates the worker process if it will not. Do not re-run the open while it is
still waiting — the target was already claimed, so that would attach a second time.
Two caveats, both in the command hatches, and both now confined to a single session. The typed tools
announce their own transitions, but execute can replace its session's target directly
(.opendump, .attach, .detach, .kill, .restart, .abandon, .remote, q/qd/qq), and
those commands retire that session's handle: calls passing it are refused, while calls that pass
no id still reach the worker. dx is the second hatch — the data model reachesDebugger.Utility.Control.ExecuteCommand, which runs any command, so an expression that touches
command execution retires the handle too, conservatively, since the command it runs is a runtime
string this server never sees.
Both matches are deliberately biased toward retiring: over-matching costs one re-open,
under-matching would let a stale handle through. Neither can be exhaustive — DbgEng has more ways to
reach the target than a name list can enumerate, and the data model is extensible — so insideexecute and dx a handle is a strong hint rather than a guarantee. Everywhere else it is a
guarantee, and it is enforced at the front of that session's queue, after everything queued ahead of
it: checking on the caller's side would leave a window in which an execute { ".opendump …" }
already queued ahead retires the handle between a caller's check and its call.
Kernel connection profiles (keeping the KDNET key out of the transcript)
A KDNET connection string carries the target's debug key — net:port=50000,key=<w.x.y.z> — and that
key is all anyone on the same network needs to take the debug link. Passing it as a tool argument
puts it somewhere this server does not control: an MCP client keeps a transcript, and a key handed
over once is then copied into messages, tool calls, context snapshots and compaction summaries. That
is what a transcript is, not a client misbehaving, so the fix is that the secret never enters the
request.
attach_kernel therefore takes exactly one of two selectors:
{ "profile": "ctf-vm" } // resolved on this host; no key in the request
{ "connection": "net:port=50000,key=1.2.3.4" } // the raw string, still supported
Configure a profile either way — the environment is checked first, then the file:
# Per profile, in the environment the MCP server is launched with. The variable's own suffix is
# the profile name, lowercased: this defines `ctf_vm`, and `ctf-vm` finds it too.
$env:WINDBG_MCP_PROFILE_CTF_VM = "net:port=50000,key=1.2.3.4"
// %USERPROFILE%\.windbg-mcp\profiles.json (override the path with WINDBG_MCP_PROFILES)
{
"ctf-vm": "net:port=50000,key=1.2.3.4",
"lab": "net:port=50001,key=5.6.7.8"
}
Keep that file out of any repository — it holds keys, and it is deliberately machine-local. Names
are matched case-insensitively with -, _ and . equivalent (as are the environment-variable
names themselves, since Windows matches those that way).
The two sources differ in when a change lands. The file is re-read on every attach, so adding a
profile to it works immediately with nothing restarted — that is the one to edit mid-session. An
environment variable is read from the server's own environment, fixed when the process started, so
it belongs in the MCP client's server definition and takes a server restart to change.attach_kernel with neither selector answers with the names this host has, which is how an
agent discovers them without ever asking the user for a string.
Configured profiles stay in the supervisor: an engine worker is spawned without theWINDBG_MCP_PROFILE_* variables, and is told only the one connection it is opening, over its
private pipe. A launched debuggee inherits its worker's environment, and a debuggee is exactly the
untrusted program that must not be handed every kernel key on the host.
Connection strings are redacted everywhere else on principle, whichever selector opened the session:session_status reports kernel target: profile "ctf-vm" (net:port=50000,key=<redacted>), and the
value is held in a type whose Debug/Display are the redacted form, so a log line or an error can
only ever carry the masked one (src/kdconn.rs). The raw string is unwrapped at
exactly one call site, handing it to DbgEng inside the session's own worker process. Redaction
covers key= and password= values in any connection string, and masks nothing else — debugger
output is never rewritten.
It works off a parse, not a text scan: the string is split once into the structure DbgEng's
syntax has, and a secret parameter's value is simply never rendered. The parse is total — every
byte lands in exactly one field, so an unredacted render reproduces the input exactly — which is
what makes "the key cannot get out" checkable rather than a matter of having anticipated every
delimiter. Whitespace between parameters is refused rather than interpreted (it reads as either
a missing comma or a stray space, and each leaks the key under the other reading), so a connection
carrying any is rejected up front and reported as <connection redacted> in full; whitespace around
the whole string is trimmed as the paste artefact it is.
Typed operands are operands, not commands
The typed tools build debugger commands by interpolation (u {address}, bp {expression},!drvobj {name} 7), so those parameters refuse ;, line breaks, and " — the last everywhere
except dx, whose data-model expressions use quoted literals legitimately.
Two things go wrong without that. DbgEng treats ; as a command separator, sodisassemble { address: "rip; .opendump C:\other.dmp" } would replace the debug target from a tool
that reports itself read-only. And bp <location> "command" is real WinDbg syntax — ioctl_trace
builds exactly that form — so a quote in a breakpoint location arms a command that runs on every
hit, replacing the target at some arbitrary later moment, outside any tool call and outside anything
that could retire the session handle.
Nothing legitimate is lost: these parameters were always single operands. Use execute to run a
command list — it is annotated destructive and retires the handle when a command changes the target.
Walking a structure (walk_memory)
Walking a kernel list through execute is all-or-nothing. A single unmapped dereference inside a
MASM .for loop ends the whole script with An unexpected exception was raised (0x80040205) — no
rows, no iteration number, no indication of how many nodes were classified before it. In pool and
use-after-free work that is precisely backwards: "some of these nodes are freed" is the normal case,
and the pointer that will not read is usually the one worth looking at
(#103).
walk_memory reads each value on its own, so a hole is a row rather than an end. Three ways to name
the nodes, one of them required:
addresses |
walk these exactly — the bulk read |
start + stride |
an array: element i is start + i * stride |
start + next_offset |
a chain: the next node is the pointer at node + next_offset |
fields says what to read out of each node ({name, offset, size}, size 1/2/4/8, offsets may be
negative — a pool header sits 16 bytes before the address the allocator returned). start is any
expression ? evaluates, so a symbol or poi(<head>) works; the addresses in a list are numbers, in
any form the debugger prints. Fields of one structure are fetched in a single read and fall back to
per-field reads only where there is a hole, so a node costs one round trip in the ordinary case —
which is what lets a 512-node walk finish over KDNET.
// Every message pointer in a 512-slot handle table, freed ones included.
{ "start": "MessageManager!g_Handles", "stride": 16, "count": 512,
"fields": [{ "name": "msg", "offset": 8 }] }
// Then the refcount out of each of those pointers — one call, holes and all.
{ "addresses": ["0xffffc00f6ec02f90", "0xffffc00f6ec03000", …],
"fields": [{ "name": "refs", "offset": 16, "size": 4 },
{ "name": "flink", "offset": 0 }] }
An unreadable value comes back as null in its own field (0x???????????????? in the text), a node
where nothing read is counted, and the walk carries on — for a list or an array. A chain is the
exception, because the address of everything after an unreadable node lived in the bytes that would
not read: it stops and says which node. It also stops on a null link, on a loop (reporting where
the list closed — back at the head that is a healthy circular _LIST_ENTRY, anywhere else it is
corruption), and at count, where it hands back the address to resume from. count past the cap of
1024 is refused rather than clamped, so "every node asked for was visited" is never about a number
this server lowered.
Transactional batches (debug_batch)
A sequence that mutates a target — patch a byte, arm a breakpoint, resume a thread — has to put
things back afterwards, and a client cannot be relied on to do it. The call that would have sent the
cleanup is exactly the call that times out, and a disconnect sends nothing at all. On a kernel target
that costs the VM: an un-restored patch, or a target left halted.
debug_batch submits the whole sequence as one op. It runs inside the session's engine process,
which owns the deadline, so the always block is reached on every path — success, a debugger error,
an assertion that did not hold, the deadline expiring, the session being torn down under it — before
the tool call returns. Part of the budget is reserved for it up front, because "what is left" after a
step that ran to its own deadline is nothing.
{
"steps": [
{ "op": "eval", "expr": "poi(hevd!Guard)", "capture": "orig" }, // save
{ "op": "command", "command": "eq hevd!Guard 0" }, // patch
{ "op": "run_to", "address": "hevd!TriggerUaf", // confirm, with a verdict
"expect": [{ "check": "contains", "text": "VERDICT: HIT" }] },
{ "op": "eval", "expr": "@rcx",
"expect": [{ "check": "eval", "expr": "(@rcx > 0x1000)", "equals": "1" }] }
],
"always": [
{ "op": "command", "command": "eq hevd!Guard {{orig}}" }, // restore, whatever happened
{ "op": "command", "command": "bc *" }
]
}
Eight step kinds. Five are the debugger itself: command (raw), resume (a command that moves the
target, plus the wait), run_to (a HIT/STOPPED ELSEWHERE/TIMEOUT verdict), eval (a MASM
expression's value), and read_memory. Three ask the kernel pool the questions the pool_* tools
ask — pool_chunk, pool_find_tag, pool_census — because those are walks over the allocator's own
descriptors rather than debugger commands, so no command step can stand in for them:
{ "op": "eval", "expr": "@$t1", "capture": "obj" }, // what the target handed us
{ "op": "pool_chunk", "address": "{{obj}}", "refresh": true } // what the allocator says it is
Inside a batch a walk is bounded by the step's share of the budget rather than by the whole call's,
so a refresh cannot spend the reserve the rollback lives on; a walk cut short still reports how
much of the pool it covered. Assertions are contains, not_contains, and eval — the
last compares two MASM expressions, so registers, memory and relations between them are all one
check. An eval step may capture its value under a name that later steps interpolate as{{name}}; a reference that names no earlier capture is refused before anything runs.
A field this tool does not know is an error, not something to ignore — the one place in this
server where that is true. Serde drops unknown fields silently, so "aways" for always would be a
batch with no rollback block at all: mutations applied, nothing restored, COMMITTED reported. The
same goes for a misspelt expect, which is a step that asserts nothing and lets the batch commit.
Both fail open, so both are refused by name.
The report names every step that ran, the exact one that failed, what each step changed, whether the
rollback completed — reported beside the original failure, never instead of it — and whether the
session is left stopped, running, detached, or uncertain. A batch that did not commit comes back as a
tool error carrying that whole report.
It carries the same report as values (see Structured results), and the
pairing is worth reading once: a batch that ran answers status: "ok" — the report is the
answer — on a result flagged isError when the transaction did not commit or its rollback did not
finish. status: "error" is the batch that never ran at all: refused for a malformed step, a stale
handle, too little budget left to start. Reading only isError cannot tell those apart, and it is
the difference between "resubmit" and "check what the target is left holding".
Four honest limits, none of them hidden in the report:
- A raw command that prints an error and returns success is a step that succeeded with that text
(DbgEng reports most failures that way), so assert on it if it matters. - What a step "changed" is a best-effort classification of the command, biased toward reporting a
change: it is a reporting aid, and thealwaysblock, not the classifier, is what makes a
mutation recoverable. - The reserve buys the rollback time, not a guarantee. A step that overruns far enough to consume
the reserve as well leaves cleanup with no budget; the block is then skipped and the result saysrollback: INCOMPLETE, naming each step that did not run. - Against a call timeout the guarantee is arithmetic: the batch budget is clamped so the
rollback finishes and the report is written before the caller gives up. Against a teardown —end_session, or a client disconnect, both of which release the target — it is a signal instead.
The batch is told to stop, does so at its next step, runsalways, and the teardown waits: the
worker answers the signal with how long that batch may still need, so the wait covers the step in
flight as well as the rollback. That figure is the batch's own remaining budget, which was already
clamped to the caller's patience, so a teardown can never wait longer than the batch could have
run anyway. What the signal cannot do is shorten a step already inside the debugger, so a batch
of long steps stops at the end of the current one rather than where it stands — and it cannot undo
what the batch never recorded, sincealwaysis still the only thing that puts anything back.
Structured results
Every tool returns the same readable text it always has. The tools below also return MCPstructuredContent, with a
matching outputSchema in tools/list, so a program can read a field instead of parsing prose:
| Tool | Typed answer |
|---|---|
open_dump, open_trace, attach_kernel, attach_kernel_local, attach_process, launch |
session_id, kind, target, report, and a summary of the target — kernel_mode, modules_loaded, the primary_module (the kernel, or the process's own image) and, for a crash dump, the bug_check. On failure, whether a target was created (target: no | yes | pending), which is what decides whether opening again is a recovery or a second attach |
session_status |
each session's state (opening/attaching/open/failed/retired/closed), engine_pid, in_state_for_ms, and — for an attach — waits_indefinitely and overdue |
end_session |
released, worker_terminated, waited_ms |
registers |
registers[] as {name, kind, …} plus instruction_pointer — kind: int and kind: float carry value, kind: bytes carries bytes (an x87 or vector register, which no number holds), kind: non_finite names a NaN or infinity that JSON has no literal for and carries its bits, kind: unavailable carries neither; pass all: true for the x87/vector registers and subregister views |
modules |
modules[] with start/end, size and a typed symbols state (deferred is not none); unloaded[] for the images that have since unloaded (listed by image name, since an unloaded module has none of its own); loaded (how many the target has in total) and the filter a narrowed listing was matched by. The listing text is rendered from these same records rather than pasted from lm, so the two halves cannot describe different sets of modules; the filter's grammar is this server's own — a name plus * (any run) and ? (exactly one), every other character literal — and execute { "command": "lm m <pattern>" } is where WinDbg's fuller wildcard syntax lives |
set_breakpoint |
the ids this call added, and every breakpoint now set — a successful bp prints nothing at all |
run_to_address |
verdict (hit/stopped_elsewhere/timeout), target, stopped_at |
go, step_over, step_into, step_back, step_over_back, reverse_go |
stopped_at |
pool_find_tag, pool_chunk, pool_census, pool_diagnostics |
the chunks/totals/diagnostics as values, each carrying the walk behind them |
heap_list, heap_allocations, heap_chunk, heap_census, heap_diagnostics |
PEB heap roots and Segment Heap allocations/totals/diagnostics, each carrying exact ntdll layout provenance, skipped-heap scope, and walk coverage |
walk_memory |
nodes[] with each field's value — null where the debugger could not read it — plus walked/unreadable counts and a stopped reason (complete, cap, null_link, loop, unreadable_link, deadline, interrupted), each carrying the address it is about |
crash_triage |
bug_check (code, name, four parameters), process_name, frames[] as {module, rva, symbol}, the faulting_frame picked out of them — and !analyze's own conclusions kept apart under analysis |
debug_batch |
outcome (committed/failed/timed_out/abandoned/interrupted), the position it stopped at, committed, rollback_complete, what the session holds after, and every step of both blocks with what it changed and whether an assertion was unmet |
Two conventions hold across all of them:
- One address representation. Every address, and every register-sized value, is a
0x-prefixed,
lowercase, 16-digit zero-padded hex string —"0xfffff8031ab10000". A string because au64
past 2^53 does not survive a JSON parser that reads numbers as doubles; zero-padded so lexical
order matches numeric order. The debugger's backtick form (fffff803`1ab10000) appears only in
the text. - An allocator answer says what the walk covered.
walk.coverageiscomplete,deadline_truncated
(the call's budget ran out — more time reaches more of the allocator) orpartial(unreadable regions
or a traversal cap — more time changes nothing). Counts from anything butcompleteare floors,
not totals. A walk that failed outright, or was stopped byinterrupt, is not a coverage state at
all: it is the error branch below, with categorydebuggerorinterrupted.
Error reporting
Anything scoped to a session comes back as a normal tool result with isError: true and the text
intact, so the model can read it and correct itself: a failed debugger operation (an unresolvable
symbol, an unreadable address, a target that never stopped), a refused handle, a timeout, a session
whose worker is gone. Each has a next move. The only JSON-RPC protocol error is the one failure that
is the server's rather than a session's — no engine worker could be started at all.
For the tools listed above, a failure also carries structured content — the error branch of the
same output schema — so a caller can branch on a stable category instead of on wording:invalid_argument, debugger, timeout, interrupted, not_run (the work never started, so
nothing changed — unlike timeout, where it may still be running), stale_session, worker_lost,capacity. Both branches carry a status discriminator ("ok" / "error"), which is what lets one
schema describe a result whichever way it went.
The forward (go/step_over/step_into) and reverse (reverse_go/step_over_back/step_back)
control tools mirror a debugger UI's F9/F8/F7 and Shift+F9/F8/F7, so an agent can drive a trace in
both directions and jump anywhere with goto_position. All of these issue the command and pump the
engine to the next stop (a plain Execute only sets the run state — it doesn't move the target),
which is what makes both live stepping and TTD forward/reverse navigation actually advance.
ttd_calls/ttd_memory/ttd_events are convenience wrappers over the TTD data model: ttd_calls
and ttd_memory query @$cursession.TTD.{Calls,Memory} (every call to a function / every access to
an address range), and ttd_events queries @$curprocess.TTD.Events (the module/thread/exception
timeline). For anything else, dx evaluates arbitrary data-model/LINQ expressions, e.g.@$cursession.TTD.Calls("ntdll!NtCreateFile").Where(c => c.ReturnValue != 0).
Session transcripts (WINDBG_MCP_TRANSCRIPT)
Point WINDBG_MCP_TRANSCRIPT at a path and the server records what it was asked and what it did,
one JSON object per line. Unset — the default — nothing is written and nothing is spent.
$env:WINDBG_MCP_TRANSCRIPT = "$env:USERPROFILE\.windbg-mcp\session.jsonl"
{"v":1,"run":4158027124358305144,"seq":1,"at":"2026-08-16T12:05:20.549Z","mono_ms":1,"event":"tool_request","request":1,"tool":"open_dump","args":{"path":"C:\\dumps\\a.dmp"}}
{"v":1,"run":4158027124358305144,"seq":2,"at":"2026-08-16T12:05:20.672Z","mono_ms":125,"event":"session_open","session":"sess-18cc47a3b2779cc8-1","kind":"crash dump","target":{"text":"C:\\dumps\\a.dmp"},"engine_pid":832}
{"v":1,"run":4158027124358305144,"seq":11,"at":"2026-08-16T12:05:23.129Z","mono_ms":2582,"event":"batch","request":3,"session":"sess-18cc47a3b2779cc8-1","outcome":"failed","at_step":2,"committed":false,"rollback_complete":true,"after":"stopped","elapsed_ms":402}
Every record carries the format version, the run that wrote it, a sequence number, a wall clock
and a monotonic offset.
The events are the tool call and its result; a session opening, changing state and being released;
a wait abandoned, an interrupt, a worker process dying; and — derived from each result's typed
half, never scraped from the text beside it — where execution stopped, what a run_to_address
concluded, every breakpoint or memory mutation, each assertion that did not hold, and how adebug_batch ended with whether its rollback completed. See src/record.rs.
A record's session is the one the call was routed to, not the one it named: omittingsession_id accepts the current session rather than none, so the field answers "which target was
this?" even for the calls that did not say.
It is not the log. RUST_LOG output is prose about the server, on stderr; this is values about
the session, in a file of its own. Standard output stays JSON-RPC and nothing else.
Render one as a terminal recording with the same executable:
windbg-mcp --render-cast session.jsonl -o session.cast # asciicast v2
asciinema play session.cast
The rendering is derived, so a cast can be made from a transcript recorded weeks ago and the
timings are the recorded ones. --idle-limit <s> tells a player how long to sit in a pause (0
plays at the speed it happened), --max-lines <n> caps how much of a long result is shown, and--title/--width/--height shape the recording.
A file holding several runs renders as one recording with the runs laid end to end, separated by
however long the server was actually down — each run's own clock starts at zero, so playing those
offsets as they stand would step backwards at the join and no player would accept it. Two servers
pointed at the same path interleave their lines; they are grouped back into their own runs by therun field every record carries, so neither session is read as part of the other.
Redaction, by two mechanisms that are not equally strong. Every secret this server has been
handed — from a profile or from a raw connection — is masked by value, wherever it appears
and in whatever syntax, so a key that reached this process cannot leave it in a transcript. Under
that sits a scan for key=/password= in text, which also catches a secret the server has never
seen (one a target printed itself); it has to guess a syntax and is best-effort by nature. An
argument member named like a secret is masked whole. Prefer a
profile regardless: it
keeps the key out of the request in the first place, and all of this is the backstop.
Retention. Nothing else is masked, and that is the point to plan around: debugger output is the
contents of somebody's memory — stack frames, strings, registry paths, whatever the target holds —
so a transcript of a live session is as sensitive as the machine it was taken from. Treat one like
a crash dump. Keep it out of version control unless you have read it, put it somewhere with the
same access as the target, and delete it when the investigation is done. The file is appended
to, never truncated, so a path reused across runs accumulates until something removes it; each run
starts with a start record naming its pid, which is what separates them.
Size. Fields are capped at 16 KiB, and a record says how much it dropped rather than being
quietly short. WINDBG_MCP_TRANSCRIPT_MAX_FIELD moves the cap; 0 removes it, at the cost of a
file that grows with every module listing and pool census. A whole record is bounded too, well
above what a capped one reaches: a record past that ceiling is replaced by an oversized marker
naming its kind and size, which is a bug in the recorder rather than something a session did.
Limitations & notes
- TTD is user-mode only (a Microsoft limitation): kernel debugging and TTD are distinct session
types — you can't time-travel a kernel target. launchandattach_processstop the target at its initial/break-in point with a live
process/thread context. (The binding enables the initial-breakpoint event filter —sxe ibp—
which a bareDebugCreatehost leaves off; without it the target would run free.) Note: on
Windows 11notepadis a Store app, so attaching by the PID thatStart-Process notepadreturns
can hit0xD000010A(that PID is a transient launcher) — attach to a classic Win32 process.read_memorytakes a numeric/0x-hex address only; for register/symbol expressions useexecutewithdb/dd(e.g.db @rip).reachable_from_dispatchis a static call-graph walk overufdisassembly: it follows
direct calls and cross-function tail jumps but not indirect calls through function pointers
or unresolved compiler jump tables. So aREACHABLEverdict is sound (a concrete static path
exists, and the path is reported), whileNOT REACHABLEis best-effort within the explored
bounds. If the dispatch uses aswitch(IoControlCode)jump table (common), pass the specific
handler VA asfromto scope past it, or confirm dynamically with a breakpoint +go.- The kernel pool tools (
pool_find_tag,pool_chunk,pool_census,pool_diagnostics) walk the allocator's own
descriptors through win-kexp rather than shelling out to!pool/!poolused, so all four read
one snapshot and cannot disagree with each other. They need a broken-in x64 kernel target.
Walking every pool page is expensive, so the snapshot is cached per session and reused; passrefresh: trueafter letting the target run, or you are reading a photograph of a target that has
since moved. A walk that does happen is bounded by what is left of the caller's own timeout
(WINDBG_MCP_CALL_TIMEOUT_SECS, less what the query waited its turn on the session), so it can
neither outlive the call that asked for it nor stop early while that call is still waiting; a walk
cut short still answers, and every result says how much of the pool it reached.interruptends
one sooner. A query that reaches the engine with no time left to walk in — a call timeout at or
under the 15s the reply itself reserves, or a long wait behind other work on the session — is
refused rather than run, since a truncated walk is discarded rather than cached; withoutrefreshit is still answered from the cached snapshot if there is one. Two semantics worth knowing: only allocated chunks are indexed by tag (a freed
chunk's tag is not reliably preserved, sopool_find_tagnever reports freed memory — ask about a
specific address withpool_chunkinstead), andpool_chunkreports three outcomes that are
easy to conflate. A chunk in an explicitly free state (ReusableFreeorCachedFree) is the
one that means a pointer the target still holds is dangling. An address not covered by the
snapshot is not the opposite finding: a region the walk never reached looks exactly like
memory that was never pool, so read the coverage the tools print — andpool_diagnosticsfor
what was missed — before concluding the pointer never pointed at pool. A span reportedUnreadableis the walk's own limit (a Verifier guard page reads that way) and says nothing
about whether the allocator freed anything.pool_chunkalso
reports the neighbouring chunks, which is what tells you what a reclaim would land next to.pool_diagnosticsreturns the walk's own diagnostics filtered by substring: a real walk emits tens of thousands across a hundred-plus categories, so any per-call summary truncates and the one line explaining a specific heap is never in the truncated head — filter by a heap address or a phrase to reach it. - The user Segment Heap tools share that typed decoder but discover roots through the current
process's PDB-resolved_PEB.NumberOfHeapsandProcessHeaps. They require a stopped x64 user
target (or a dump with sufficient memory) and the exact loadedntdllPDB.heap_listreports
every root and explicitly separates Segment Heaps walked from classic NT, unknown, and unreadable
heaps skipped. V1 does not decode classic NT heaps, WOW64, or ARM64; use!heapfor classic heaps.
Snapshots are cached per target/PEB/ntdllimage and invalidated when execution resumes; passrefresh: truefor the final observation after target execution. Allocationcapacityis always
allocator-backed, whilerequested_sizeis optional and appears only when the selected schema
validates exact unused-byte metadata.heap_allocationsdefaults tostate: allocated; when
investigating freed memory, passstate: reusable_freeorstate: cached_freeexplicitly. Readlayout,scope, andwalkbefore treating an absent allocation as evidence. The agent workflow is inskills/windbg-debugging/heap-walking.md. crash_triagereads a bug check two ways, and keeps them apart. The code and its parameters
(ReadBugCheckData), the stack, each frame's module, and the crashing process (out of the current_EPROCESS's audit name — the full image name, not the 15-byteImageFileNamethat turnsmm_exploit_v5.exeintomm_exploit_v5.) are engine reads. The pool tag, the
failure bucket, the blamed module and the per-parameter explanations exist nowhere but!analyze's
own output, so they are extracted from it and confined to theanalysisobject. Preferfaulting_frametoanalysis.module_name: the frame'smodule+RVAis computed from the load
base and is right for a driver with no PDB, which is exactly where!analyze's attribution goes
wrong. Which frame is the culprit is still a guess, though — only the offset is computed.faulting_frameis the innermost frame that could be a kernel driver: notnt/hal, not the
framework layers that sit on a stack on somebody else's behalf (KMDF'sWdf01000, Driver
Verifier), and not a user-mode module — a kernel stack that unwinds past the system call boundary
runs on intontdlland the caller's own.exe, and neither can be a driver. It is still
positional, so a crash routed through a layer this build does not recognise names that layer
instead of the driver behind it. The text
prints!analyze's attribution beside it whenever the two disagree and tells you to settle it
fromframes, where everymodule+RVAis sound whichever guess is right.faulting_frameis
absent when the whole captured stack is in those images;faulting_frame_notethen says
whether that is the crash itself (a0x9Fwatchdog fires on an idle CPU — the culprit is not on
that stack, and the bug check arguments are where to go next) or merely the 16-frame default
cap, whichframesraises to 128.analyze: falseskips!analyzefor a fast answer of
everything else. The call leaves the session exactly as it found it, which running the same!analyze -vthroughexecutedoes not: the analysis resets the selected scope to the target's
default, so a.frame/.cxra caller had chosen would be silently discarded —crash_triage
saves that scope and restores it (ScopeGuard, win-kexp#98),
which is why the tool reports itself read-only. The stack it walks is the default context
(the crash, on a crash dump) whenever the analysis ran to completion — running it is what resets
the scope there — and whatever the session has selected when it did not:analyze: false, no
time left, noext.dll, or a run the deadline cut short before the reset it does partway
through its output.analysis.ranandanalysis.truncateddistinguish them. Needs a kernel
target
stopped at a bug check — a live kernel that has not crashed yet, and a dump that is not a crash
dump, are both refused with a message saying which of the two they are.crash_triagetries!analyze -vand then!ext.analyze -v, and reports which one worked
underanalysis.command. A manualexecutehas to pick, and on the bundled engine the
answer is the module-qualified!ext.analyze -v— the unqualified form does not resolve there
even after.load ext(see Bundling the WinDbg engine). On a partial minidump, reads of
pages that weren't captured raiseAn unexpected exception was raised (0x80040205)rather than a
clean "memory read error"; query the specific field you need (e.g.dt nt!_DRIVER_OBJECT <addr> DriverName) instead of dumping whole structures. See the
crash-dump walkthrough.- That failure is all-or-nothing inside a
.forloop, which is whatwalk_memoryis for: one
unmapped dereference takes the whole script down with no rows at all, and the node it happened on
is usually the interesting one. Walk a list, an array or a chain withwalk_memoryand each hole
is a marked value instead. - Single-stepping is only valid once the target is stopped with a real thread context (after a
go/step or a breakpoint hit). Stepping straight after a baregoto_positionto the very start of
a trace (before any thread is live) returns0x80040205—goto a breakpoint first. - Symbol names (
module!func) need (a)msdia140.dllbundled next to the binary, (b) a symbol
path pointing at the PDBs (.sympath …), and (c) for TTD, reloading at a stopped position
(after ago/breakpoint, not straight off a!tt) so the module's PDB is matched and loaded.
With those, e.g.ttd_calls("ucrtbase!__stdio_common_vfprintf")returns the exact call count.
Without symbols, the data model, navigation, and memory reads still work — query by address. - One command at a time per session. Sessions run concurrently, but each one is a single engine
processing operations serially, so a call against a busy session waits its turn. Issue tool calls
for a given session sequentially — await each result before sending the next: the server does
not order concurrent in-flight requests against each other, so pipelining them (firing several
calls before their results return) can run a command before the one that establishes its state,
and is meaningless for stateful, order-dependent debugger commands anyway. Standard MCP clients
already serialize call→result, so this is only a concern for custom/batched callers. - Sessions are a bounded resource. Each is a process holding a dump, a trace, or a live target,
and at most4are open at once.end_sessionwhen you are done with one rather than relying on
the oldest idle session being reclaimed. - TTD replay (
open_trace) needsTTDReplay.dlldiscoverable but not elevation; TTD
recording (record_trace) needsTTD.exeand Administrator.record_tracecaptures the
recorder's startup output to<out_dir>\ttd_record.logand watches it briefly, so a fast failure
(e.g. running un-elevated →0x80070005 Access is denied) is reported as an error rather than a
false "recording started". - Control-flow tools (
go/step*) issue the corresponding debugger command; precise stop/wait
semantics for long-runninggoagainst a live target are bounded by the per-call timeout.
Yorumlar (0)
Yorum birakmak icin giris yap.
Yorum birakSonuc bulunamadi