# mcp-windbg A [Model Context Protocol](https://modelcontextprotocol.io/) 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). ```powershell 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. ```powershell 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.md` are read through `Path(__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, or `load_prompt` raises only in the binary. - `--filter-script` still loads and runs, but it imports against the bundled standard library, which is the subset the analysis pulled in - `sqlite3`, `xml.etree` and `tkinter` are 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_COVERAGE` so the harness launches it under `coverage run --parallel-mode`. - the pytest process itself, where the hermetic unit tests (`tests/test_*.py`) run. This needs `coverage run -m pytest`, not plain `pytest`. Miss this and every unit test reads as dead code: that is how `kd_session.py` once reported 34% while fully tested. Both write parallel-mode data files that `coverage combine` merges: ```powershell 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: ```powershell $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. Run `pwsh scripts/Format-Docs.ps1`. (`**/*.md`) - `documentation.md` - authoring style for the `docs/` 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__`, 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](https://github.com/googleapis/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-notes` replaces the generated notes with your CHANGELOG entry. - `fast-forward-main` pushes `main` to the released commit. - `publish` calls `publish-mcp.yml` to run the full test matrix and ship to PyPI. - `registry` calls `publish-mcp-registry.yml` once `publish` is done, to list the version in the MCP registry (which verifies the package on PyPI, so it has to wait). - `docs` calls `pages.yml` to 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_TOKEN` do not trigger other workflows. That is why `publish-mcp.yml` is `workflow_call`/`workflow_dispatch` rather than `on: push: tags` - a tag trigger would silently never fire. The same applies to the `main` fast-forward, which is why `pages.yml` gained a `workflow_call` trigger instead of watching `main`. It is also why CI does not run on the release PR itself; supply a PAT or GitHub App token to `release-please.yml` if you want that gate. ## Docs ```powershell 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 ```