16 KiB
mcp-windbg
A Model Context Protocol server that lets AI models
analyze Windows crash dumps and drive live or remote debugging through WinDbg/CDB. It is a
Python wrapper around cdb.exe: the model calls MCP tools, the server runs the matching
debugger commands and returns the text output. Windows-only (needs CDB). The single entry
point is mcp-windbg, which speaks MCP over stdio or streamable-http.
Build / test / run
Python 3.10+ with the uv package manager. CDB must be installed for the live tests (WinDbg
from the Microsoft Store, or the Windows SDK).
uv sync --dev # install incl. dev deps
uv run pytest src/mcp_windbg/tests/ -v # full test suite
uv run pytest src/mcp_windbg/tests/ -v -m "not live" # hermetic subset (no CDB)
uv run python -m mcp_windbg --verbose # run the server (stdio)
uv run python -m mcp_windbg --transport streamable-http --port 8000 # HTTP transport
pwsh scripts/Switch-McpWindbg.ps1 Local # Claude Code in this checkout: use the working copy
pwsh scripts/Switch-McpWindbg.ps1 Released # back to the published plugins, updated
Signing releases
The isolated sign job reads SIGNING_URL and SIGNING_API_KEY through its environment.
The API key is a repository Actions secret, separate from the hardware PIN; the signing
environment must not shadow it with an older value. A missing key or HTTP failure stops the
job before a signed artifact is uploaded. release-please.yml forwards secrets with
secrets: inherit; reusable callers must also supply SIGNING_API_KEY.
For acceptance, dispatch publish-mcp.yml on develop with rehearsal=true and leave tag
empty. This signs and verifies the binary and runs its Windows scenarios, without publishing
to PyPI or attaching a release asset. Keep the signing job free of checkouts and write tokens.
Standalone binary
scripts/Build-Exe.ps1 freezes the checkout into dist/mcp-windbg.exe with PyInstaller, for
users who want nothing to do with Python. It carries its own interpreter and every dependency;
it does not carry cdb.exe, which stays a prerequisite.
uv sync --dev --group dist # pyinstaller lives in the dist group, not dev
pwsh scripts/Build-Exe.ps1 # -> dist/mcp-windbg.exe, with SHA256
pwsh scripts/Build-Exe.ps1 -Verify # build, then run the scenarios against the binary
Importing cleanly is not the test. A frozen build only ships the modules PyInstaller's
static analysis saw, so a dependency that imports lazily or reads a data file at runtime fails
at the tool call, not at startup. MCP_WINDBG_SERVER_EXE points the e2e harness at a built
binary, so the whole scenario suite re-runs against it with the same assertions and a different
launcher. That is what -Verify does, and what the exe job in build-and-test.yml runs on
every PR. Set the variable by hand to drive a downloaded release asset the same way.
Two consequences of freezing worth knowing:
prompts/*.prompt.mdare read throughPath(__file__).parent, which resolves inside the unpacked bundle. They are laid down there by--add-data, so moving or adding a prompt file means the build has to lay it down too, orload_promptraises only in the binary.--filter-scriptstill loads and runs, but it imports against the bundled standard library, which is the subset the analysis pulled in -sqlite3,xml.etreeandtkinterare not in it. A filter script that needs more than the exe happens to carry wants the pip or uvx install instead.
Coverage
The code under test runs in two processes, and both must be measured or the number lies:
- the hosted server subprocess, where tool dispatch executes. Set
MCP_WINDBG_COVERAGEso the harness launches it undercoverage run --parallel-mode. - the pytest process itself, where the hermetic unit tests (
tests/test_*.py) run. This needscoverage run -m pytest, not plainpytest. Miss this and every unit test reads as dead code: that is howkd_session.pyonce reported 34% while fully tested.
Both write parallel-mode data files that coverage combine merges:
uv run coverage erase
$env:MCP_WINDBG_COVERAGE = "1"
uv run coverage run -m pytest src/mcp_windbg/tests/ # measures pytest AND the server
$env:MCP_WINDBG_COVERAGE = $null
uv run coverage combine # merge the per-process .coverage.* files
uv run coverage report # or: uv run coverage html -> htmlcov/
CI runs exactly this and gates on --fail-under=88. To reproduce CI's number, leave
MCP_WINDBG_KERNEL_CONNECTION unset so the kernel scenarios skip as they do there. Run it with
the variable set for the honest local number, which covers kd_session.py against a real
target rather than a fake process.
Test suite
The suite is a declarative end-to-end harness: each tests/scenarios/*.yaml file is run
against a real python -m mcp_windbg server hosted over stdio and driven by a real MCP client
(only the LLM is faked, by the scripted tool calls). Scenarios that need a debugger carry the
live (and remote / kernel) marker and pytest.skip cleanly when cdb.exe or kd.exe is
absent, so -m "not live" always runs and stays green off-Windows. See
src/mcp_windbg/tests/e2e/README.md for the scenario format. Test dumps are ordinary files in
src/mcp_windbg/tests/dumps/ (Git LFS was removed in #20). .gitignore excludes every other
*.dmp, so a new fixture is added deliberately: check it for sensitive data first, then list it
there.
Kernel scenarios. kernel_dump.yaml opens a committed kernel minidump and needs only
kd.exe, so it runs in CI. kernel_session.yaml drives a real kernel target through kd.exe. CI has
no target machine, so it skips there; locally, point it at a debuggable VM or box and it runs:
$env:MCP_WINDBG_KERNEL_CONNECTION = "net:port=50005,key=1.2.3.4" # the -k connection string
uv run pytest src/mcp_windbg/tests/ -m kernel -v
Kernel code paths that CI cannot reach are held up by the hermetic fake-kd.exe tests in
tests/test_kd_session.py. Treat those as the CI floor, not as proof the feature works: before
shipping a kernel change, run the kernel marker against a real target.
Layout
src/mcp_windbg/
__init__.py main(): CLI argument parsing, picks the transport
__main__.py module entry point
server.py MCP server: session lifecycle + call_tool dispatch
server_api.py Tool param models + list_tools + packaged prompt handlers
cdb_session.py CDBSession: spawns cdb.exe, sends commands, reads output
filter_script.py --filter-script loader and tool content hooks
prompts/ prompt templates (dump-triage.prompt.md)
tests/ e2e harness: e2e/ (runner + harness), scenarios/*.yaml, dumps/
scripts/ check-version-consistency.ps1, validate-server-schema.py, Format-Docs.ps1,
Switch-McpWindbg.ps1, Build-Exe.ps1
examples/ small C++ programs that crash, for generating test dumps
docs/ MkDocs user guide (Material), deployed to GitHub Pages
.github/workflows/ ci.yml -> build-and-test.yml (tests), publish-mcp.yml (PyPI on v* tags),
pages.yml (docs deploy)
pyproject.toml project + dependency config server.json MCP registry manifest
Conventions
Topic-scoped conventions live in .claude/rules/ and load automatically when you read a
matching file:
markdown.md- Markdown typography for every*.md: plain hyphens (no em/en dashes), no emojis. Runpwsh scripts/Format-Docs.ps1. (**/*.md)documentation.md- authoring style for thedocs/user guide (scenario-first, link to the reference, sentence-case). (docs/**)
Tool and CLI facts come from src/mcp_windbg/server_api.py (tool schemas) and
src/mcp_windbg/__init__.py (command-line options). Keep docs/reference/ in sync with them.
The plugin has two names, and they do different jobs. The marketplace entry in
.claude-plugin/marketplace.json is named mcp-windbg-uvx - that is the install identity, so
the command is /plugin install mcp-windbg-uvx@mcp-windbg, and it is what the plugin cache
directory is named after. The name in plugins/mcp-windbg/.claude-plugin/plugin.json stays
mcp-windbg, and that is what its MCP tools are scoped by:
mcp__plugin_mcp-windbg_mcp-windbg__<tool>, whatever the entry is called.
That split is deliberate. It lets a second entry ship the same plugin a different way -
mcp-windbg-native for a bundled binary, mcp-windbg-pipx for pipx run - while every variant
keeps identical tool names and one set of documentation. Change plugin.json's name
and you rename its tools with it.
The skills-only entry mcp-windbg-skills is different: its source is
plugins/mcp-windbg-skills, and strict: false makes the marketplace entry its
manifest. Its four skill directories live under that source's skills/, outside
the uvx plugin's package. The skill namespace is /mcp-windbg-skills:....
The mcp-windbg-agents entry follows the same pattern at plugins/mcp-windbg-agents,
declaring agents/crash-analyst.md with the name mcp-windbg-agents:crash-analyst.
The uvx plugin contains only the MCP server configuration; users may add either
optional plugin, both, or neither. Keep skills and agents independent of launcher
and tool namespace. The existing marketplace version wildcard updates all entries;
the release check must check every entry.
Versioning and release
Releases are driven by release-please. You do
not bump versions and you do not push tags. You do still write CHANGELOG.md by hand: it is
configured with skip-changelog, so release-please never touches it.
Branches. develop is the working branch and the base for every feat/* and fix/* PR;
it is the repository default, so PRs and Dependabot target it automatically. main is not a
branch you commit to - release-please.yml fast-forwards it onto each released commit, so it
always equals the last released state. Never push to main directly: the fast-forward is a
plain one, so a commit on main that develop lacks fails the next release until someone
merges main back into develop by hand.
PR titles decide the version. Because PRs are squash-merged, the PR title becomes the commit subject, which is what release-please reads. It must be a Conventional Commit:
| PR title prefix | Bump | Shown in the release notes |
|---|---|---|
feat: |
minor | yes |
fix: |
patch | yes |
perf: docs: deps: |
patch | yes |
chore: ci: test: refactor: build: style: |
patch | hidden |
any prefix with !, or a BREAKING CHANGE: footer |
major | yes |
A title with no recognized prefix contributes nothing. If everything since the last release is
hidden-only (chore, ci, test, ...), release-please opens no release PR at all - by design,
not a failure. Release-As: X.Y.Z in a commit body forces an exact version.
When no release PR appears and you expected one, read the release-please job log. It says
which commits it could use:
commit could not be parsed: 30c7dd9 Fix kernel go/break-in handling, add wait_for_break (#80)
Considering: 1 commits
No user facing commits found since c7ae1a6 - skipping
could not be parsed means the subject was not a Conventional Commit, so it counted for nothing.
No user facing commits found means everything that did parse was a hidden type. Neither is an
error; both mean there is nothing to release yet.
A config change does not rewrite an open release PR. release-please leaves an existing
release PR alone when the version it computes has not changed, so fixing something that only
affects the PR's contents - an extra-files entry, say - has no visible effect. Delete the
release-please--branches--develop--* branch (which closes the PR) and re-run the workflow
from the Actions tab via workflow_dispatch; it rebuilds the PR with the new config.
Check the release PR's diff before merging it. It should touch pyproject.toml,
.release-please-manifest.json, and all three versions in server.json. A missing server.json
means the extra-files entries are not firing.
The flow. Every push to develop runs release-please.yml, which opens or rewrites a
chore(develop): release X.Y.Z PR carrying the version bumps. Before merging it, add the
CHANGELOG.md entry to that PR: rename ## [Unreleased] to ## [X.Y.Z] - YYYY-MM-DD to match
the version in the PR title. Merging that PR is what "release now" means. It pushes to develop,
which runs the workflow again; this time release-please creates the tag and the GitHub release,
and then, in the same run:
release-notesreplaces the generated notes with your CHANGELOG entry.fast-forward-mainpushesmainto the released commit.publishcallspublish-mcp.ymlto run the full test matrix and ship to PyPI.registrycallspublish-mcp-registry.ymloncepublishis done, to list the version in the MCP registry (which verifies the package on PyPI, so it has to wait).docscallspages.ymlto deploy the documentation site.
The last three are chained through needs rather than triggered, for the GITHUB_TOKEN reason
below: neither the tag nor the main fast-forward starts a workflow on its own.
Forgetting the CHANGELOG entry fails the release before it happens. The preflight job runs
scripts/check-version-consistency.ps1 ahead of release-please whenever the head commit is a
chore(develop): release ... merge, and release-please does not run unless it passes. It asserts
that pyproject.toml, both versions in server.json, .release-please-manifest.json, the plugin
manifest, the marketplace entry and the uvx pin in the plugin's .mcp.json all carry the same
version, that CHANGELOG.md's top heading names it, and that the heading has an entry under it.
No tag is created when any of that is wrong, so the fix is a commit to develop rather than a
second release.
The same script still runs from the publish build, which is what guards a publish-mcp.yml
started by hand.
What release-please keeps in sync. release-please-config.json drives it. The python
release type updates pyproject.toml's [project] version natively. server.json is not
something it knows about, so it is handled by two extra-files JSONPath entries ($.version and
$.packages[*].version) - the top-level and per-package versions are separate fields, and one
entry carries one JSONPath. The plugin manifest, the marketplace entry and the uvx pin in the
plugin's .mcp.json are three more extra-files entries.
Expect an extra-files target to come back re-serialised, not patched in place: the updater
parses the file and writes it out again, which is why plugin.json's inline keywords array
reflowed to one entry per line on the first release that touched it. Harmless for a small
hand-written manifest; the reason uv.lock is not an extra-files target.
uv.lock is instead regenerated by the sync-lockfile job, which runs uv lock on the release
PR and commits the result. It records the project's own version, so every bump leaves it a
release behind - which is what uv lock --check in the build now fails on. Left to extra-files
a 900-line generated lockfile would be re-serialised by a tool that does not own its format.
Two setup requirements, easy to lose track of:
- Settings -> Actions -> General -> "Allow GitHub Actions to create and approve pull requests" must stay enabled, or opening the release PR fails with a 403.
- Releases created with the default
GITHUB_TOKENdo not trigger other workflows. That is whypublish-mcp.ymlisworkflow_call/workflow_dispatchrather thanon: push: tags- a tag trigger would silently never fire. The same applies to themainfast-forward, which is whypages.ymlgained aworkflow_calltrigger instead of watchingmain. It is also why CI does not run on the release PR itself; supply a PAT or GitHub App token torelease-please.ymlif you want that gate.
Docs
pip install -r requirements-docs.txt
python -m mkdocs serve # live preview at http://127.0.0.1:8000
python -m mkdocs build --strict # what Pages builds; links must resolve
pwsh scripts/Format-Docs.ps1 # markdown typography