Skip to main content
Glama

windbg-mcp

CI License: MIT CodeRabbit Pull Request Reviews Latest release Platform: Windows x64

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 with WaitForEvent(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, which end_session can 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 a session_id into "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. The DebugEngine is created on, and confined to, one OS thread inside it (DbgEng requires serialized, single-thread access, and WaitForEvent must run on the session-owning thread). A catch_unwind guard 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 makes interrupt and 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 with rmcp's #[tool_router]/#[tool_handler].

  • kdconn.rs — kernel connection strings, the one tool argument that is a secret: profile resolution, and the Connection type whose Debug/Display are redacted so a key can only be unwrapped deliberately (see Kernel connection profiles).

  • ttd.rs — locates TTD.exe and 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 a debug_batch is 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 with end_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 with server/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 and cacheScope: 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.

Related MCP server: Windbg-MCP

Requirements

  • Windows x64 (host bitness must match the target).

  • dbgeng.dll / dbghelp.dll — present in System32 on modern Windows 11 (verified with 10.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 WinDbg winext\ extensions bundled next to the binary — System32's engine ships none, so !analyze would return "No export analyze found". See Bundling the WinDbg engine below.

  • For the kernel driver tools (driver_object / device_object / irp_stack), the engine needs winxp\kdexts.dll bundled 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 .run traces (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) for record_trace — ships with the WinDbg / TTD store packages; put it on PATH.

  • A reachable symbol server (e.g. srv*https://msdl.microsoft.com/download/symbols) for symbol-name queries like ttd_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 as windbg-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 — see docs/smoke-test.md.

Install with Scoop

A community-maintained Scoop manifest lives in gitfool/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\ and winxp\ 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-run post_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 with gh 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.TTD data model and the !tt time-travel commands.

  • The winext\ subdir provides ext.dll (which exports !analyze) and the other !-extensions. open_dump runs .load ext for you, but note the **unqualified !analyze does not resolve** on this minimal engine — use the module-qualified **!ext.analyze -v** for crash-dump triage. Without winext\, !analyze returns "No export analyze found".

  • winxp\kdexts.dll provides !drvobj/!devobj/!irp, behind the driver_object/device_object/irp_stack tools. attach_kernel / attach_kernel_local .load kdexts for you and nothing complains at attach time, so a missing file first surfaces as "No export drvobj found" from those three tools. It lives in winxp\, not winext\ — the engine searches that subdir by name.

  • msdia140.dll is required for PDB symbols. Without it, dbghelp can't parse any PDB (dia error 0x8007007e) and silently falls back to export symbols — which makes module!name lookups (and so ttd_calls("ucrtbase!__stdio_common_vfprintf")) fail even with the right PDB in the cache. symsrv.dll is 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 as io.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 the driver_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, bump version in plugin.json and Cargo.toml, bump the release badge near the top of this README, add a matching entry to CHANGELOG.md, and tag the commit vX.Y.Z. Run claude plugin validate . --strict before publishing. Pushing the tag runs release.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 by packaging/mcpb/manifest.json) and publishes a server.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): a 0x9F DRIVER_POWER_STATE_FAILURE traced to nvlddmkm.syscrash_triage for the bug check as fields, then !ext.analyze -v and 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, a 0x13A in a PDB-less driver, where !analyze says Unknown_Module and the frame says MessageManager+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 the xusheng6/TTD_lab helloworld sample: opening a .run, surveying events/threads, forward/reverse navigation, memory analysis, and counting printf calls 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 uses ttd_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 250 g values from debugger function-evaluation, and Z3 finds a satisfying code — which reveals the flag (the code is intentionally non-unique). Runnable solver in examples/flareauthenticator/; recorded terminal session in docs/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/uf to recover the \Driver\mountmgr dispatch switch, decode_ioctl for the access tiers, the device DACL parsed from memory, and an ioctl_trace sweep — 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 reaches Debugger.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 inside execute 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 the WINDBG_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, so disassemble { 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.

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 the always block, 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 says rollback: 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 teardownend_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, runs always, 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, since always is 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 MCP structuredContent, 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_pointerkind: 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 valuenull 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

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 a u64 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.coverage is complete, deadline_truncated (the call's budget ran out — more time reaches more of the allocator) or partial (unreadable regions or a traversal cap — more time changes nothing). Counts from anything but complete are floors, not totals. A walk that failed outright, or was stopped by interrupt, is not a coverage state at all: it is the error branch below, with category debugger or interrupted.

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).

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.

  • launch and attach_process stop 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 bare DebugCreate host leaves off; without it the target would run free.) Note: on Windows 11 notepad is a Store app, so attaching by the PID that Start-Process notepad returns can hit 0xD000010A (that PID is a transient launcher) — attach to a classic Win32 process.

  • read_memory takes a numeric/0x-hex address only; for register/symbol expressions use execute with db/dd (e.g. db @rip).

  • reachable_from_dispatch is a static call-graph walk over uf disassembly: it follows direct calls and cross-function tail jumps but not indirect calls through function pointers or unresolved compiler jump tables. So a REACHABLE verdict is sound (a concrete static path exists, and the path is reported), while NOT REACHABLE is best-effort within the explored bounds. If the dispatch uses a switch(IoControlCode) jump table (common), pass the specific handler VA as from to 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; pass refresh: true after 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. interrupt ends 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; without refresh it 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, so pool_find_tag never reports freed memory — ask about a specific address with pool_chunk instead), and pool_chunk reports three outcomes that are easy to conflate. A chunk in an explicitly free state (ReusableFree or CachedFree) 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 — and pool_diagnostics for what was missed — before concluding the pointer never pointed at pool. A span reported Unreadable is the walk's own limit (a Verifier guard page reads that way) and says nothing about whether the allocator freed anything. pool_chunk also reports the neighbouring chunks, which is what tells you what a reclaim would land next to. pool_diagnostics returns 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.NumberOfHeaps and ProcessHeaps. They require a stopped x64 user target (or a dump with sufficient memory) and the exact loaded ntdll PDB. heap_list reports 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 !heap for classic heaps. Snapshots are cached per target/PEB/ntdll image and invalidated when execution resumes; pass refresh: true for the final observation after target execution. Allocation capacity is always allocator-backed, while requested_size is optional and appears only when the selected schema validates exact unused-byte metadata. heap_allocations defaults to state: allocated; when investigating freed memory, pass state: reusable_free or state: cached_free explicitly. Read layout, scope, and walk before treating an absent allocation as evidence. The agent workflow is in skills/windbg-debugging/heap-walking.md.

  • crash_triage reads 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-byte ImageFileName that turns mm_exploit_v5.exe into mm_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 the analysis object. Prefer faulting_frame to analysis.module_name: the frame's module+RVA is 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_frame is the innermost frame that could be a kernel driver: not nt/hal, not the framework layers that sit on a stack on somebody else's behalf (KMDF's Wdf01000, Driver Verifier), and not a user-mode module — a kernel stack that unwinds past the system call boundary runs on into ntdll and 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 from frames, where every module+RVA is sound whichever guess is right. faulting_frame is absent when the whole captured stack is in those images; faulting_frame_note then says whether that is the crash itself (a 0x9F watchdog 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, which frames raises to 128. analyze: false skips !analyze for a fast answer of everything else. The call leaves the session exactly as it found it, which running the same !analyze -v through execute does not: the analysis resets the selected scope to the target's default, so a .frame/.cxr a 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, no ext.dll, or a run the deadline cut short before the reset it does partway through its output. analysis.ran and analysis.truncated distinguish 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_triage tries !analyze -v and then !ext.analyze -v, and reports which one worked under analysis.command. A manual execute has 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 raise An 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 .for loop, which is what walk_memory is 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 with walk_memory and 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 bare goto_position to the very start of a trace (before any thread is live) returns 0x80040205go to a breakpoint first.

  • Symbol names (module!func) need (a) msdia140.dll bundled next to the binary, (b) a symbol path pointing at the PDBs (.sympath …), and (c) for TTD, reloading at a stopped position (after a go/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 most 4 are open at once. end_session when you are done with one rather than relying on the oldest idle session being reclaimed.

  • TTD replay (open_trace) needs TTDReplay.dll discoverable but not elevation; TTD recording (record_trace) needs TTD.exe and Administrator. record_trace captures the recorder's startup output to <out_dir>\ttd_record.log and 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-running go against a live target are bounded by the per-call timeout.

A
license - permissive license
Not graded
quality - not tested
A
maintenance

Maintenance

Maintainers
<1hResponse time
4dRelease cycle
17Releases (12mo)
Commit activity
Issues opened vs closed

Resources

Unclaimed servers have limited discoverability.

Looking for Admin?

If you are the server author, to access and configure the admin panel.

Related MCP Servers

  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables AI-driven WinDbg kernel debugging via the Model Context Protocol, allowing agents to send debug commands and receive results.
    5
    MIT
  • A
    license
    Not graded
    quality
    B
    maintenance
    MCP server that drives cdb.exe/kd.exe to provide WinDbg debugging to LLM agents, enabling crash dump analysis, live process debugging, kernel debugging, and a searchable command reference.
    849
    3
    MIT
  • A
    license
    B
    quality
    A
    maintenance
    MCP server that connects WinDbg, IDA Pro, and x64dbg to AI assistants for reverse engineering and Windows security research, offering 160+ tools for debugging, analysis, and cross-debugger workflows.
    100
    19
    MIT

View all related MCP servers

Related MCP Connectors

  • MCP server exposing the Backtest360 engine API as tools for AI agents.

  • MCP server for AI agents to plan, verify, and deploy Cloudflare-native apps.

  • A comprehensive Model Context Protocol (MCP) server that enables AI assistants to interact with yo…

View all MCP Connectors

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/glslang/windbg-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server