gdbmcp
by luckinbyte
README.md
# gdbmcp
A standalone **MCP (Model Context Protocol) server** for **Linux** that attaches
to a running C++ process and exposes **three tools** — `evaluate`,
`map_get`, `enumerate` — to inspect the target's live memory via a **resident
GDB** subprocess.
Zero source-level intrusion: no target code or build is modified. The AI writes
the field/path it wants as a C++ expression; gdbmcp passes it verbatim to GDB,
which resolves symbols from the target's DWARF debug info and returns the value.
```
MCP client ──stdio JSON-RPC──▶ gdbmcp (Python, single ELF) ──GDB/MI──▶ gdb ──ptrace──▶ target C++ process (-g)
```
## What works (and what doesn't) — read this first
GDB's expression evaluator is powerful but has hard limits on a stock
libstdc++/libc++ target. `evaluate` surfaces these as error strings; prefer the
working forms.
| Want | Works? | How |
|---|---|---|
| Read a field / follow pointers | ✅ | `obj->member.field` |
| Call an **out-of-line** function (e.g. a singleton accessor) | ✅ | `World::GetInstance()->FindPlayer(1001)->hp` |
| Index a `std::vector` element | ✅ via raw buffer | `vec._M_impl._M_start[i].field` |
| Index a raw array / pointer | ✅ | `arr[i]`, `ptr[i]` |
| Call **inlined STL** methods (`map::find`, `map::at`, `map::operator[]`, `vector::at`, `vector::operator[]`) | ❌ | gdb can't call inlined functions — use an out-of-line accessor on the target |
| Construct a custom key: `EntityID(1001)` (functional cast) | ❌ syntax error | use C-style cast `(EntityID)1001`, and only if that ctor is a callable out-of-line symbol |
| `print /r`-style struct dump | ✅ (text format) | default |
**Why:** `std::map::find` / `std::vector::operator[]` are defined inline in the
STL headers; GDB cannot inferior-call an inlined function. The robust pattern on
a real target is to expose **out-of-line accessor methods** taking primitive
arguments (e.g. `FindPlayer(int id)` instead of `players.find(EntityID(id))`),
and to reach vector elements through `_M_impl._M_start[i]`.
**If you can't add an accessor** (e.g. a third-party binary), use the dedicated
container tools below — they walk the container's internal layout by direct
memory reads, no inferior call:
| Want | Tool | How |
|---|---|---|
| Look up one element by key in a map/unordered_map | `map_get` | walks the rb-tree / hash buckets, matches the key field-wise |
| List elements of any container | `enumerate` | walks map/set (tree), unordered_* (hash), vector (buffer) |
These limitations come from GDB/libstdc++, not from gdbmcp — gdbmcp only passes
the expression through.
## Target pause model
GDB attaches via `ptrace`, which `SIGSTOP`s the target. gdbmcp keeps the target
**running between queries** and briefly interrupts it (all-stop) per `evaluate`
call — typically a few milliseconds for a pure data read, longer if the
expression triggers an inferior function call. A wall-clock watchdog (plus GDB
≥14.1's native `direct-call-timeout`) aborts any call that does not return.
On gdbmcp exit (stdin closed, SIGTERM, SIGINT), it **detaches** (never kills)
the target, which resumes normal execution.
## Requirements
- **Linux** only.
- **gdb ≥ 14.1** recommended (native inferior-call timeout). Minimum gdb ≥ 10
(watchdog-only). Must be the **full gdb package** with Python support
(`gdb --configuration` shows `--with-python`) — a stripped `gdb-minimal` cannot
load STL pretty-printers. Install e.g. `apt install gdb`.
- **ptrace permission** to attach to the target. `kernel.yama.ptrace_scope`
defaults to `1` on most distros (only a parent may trace its children). Pick one:
- run gdbmcp as **root**, or
- `sudo setcap cap_sys_ptrace+ep ./dist/gdbmcp`, or
- set `/proc/sys/kernel/yama/ptrace_scope` to `0` (system-wide; security trade-off).
- in Docker: `--cap-add SYS_PTRACE`.
- The **target** must be compiled with debug info (`-g`, not stripped) for symbol
resolution.
## Build the single-file executable
```bash
./packaging/build.sh # uses .venv, installs deps + pyinstaller, builds dist/gdbmcp
```
The result `dist/gdbmcp` is a self-contained ELF — **no Python needed on the host**.
gdb itself is a separate system dependency (see above). Build on the oldest glibc
you intend to support for forward portability.
## Run
gdbmcp speaks MCP over **stdio** (default) or **HTTP** (streamable-http).
### stdio (default — for Claude Code / Cursor / cline / claude.ai)
The MCP client launches gdbmcp as a subprocess and talks over its stdin/stdout:
```bash
./dist/gdbmcp --pid <PID> [--config config.json] [--gdb-path /usr/bin/gdb]
```
Example client config:
```json
{
"mcpServers": {
"gdbmcp": { "command": "/path/to/gdbmcp", "args": ["--pid", "12345"] }
}
}
```
### HTTP (for remote / multi-client access)
```bash
./dist/gdbmcp --pid <PID> --transport http --host 0.0.0.0 --port 8000 [--auth-token SECRET]
```
Endpoint: `POST http://<host>:<port>/mcp` (MCP streamable-http). Bind defaults to
`0.0.0.0:8000`.
### Authentication (HTTP only — important)
`evaluate` reads arbitrary target memory. **Any HTTP exposure must be authenticated.**
Set a bearer token via `--auth-token` or the `GDBMCP_TOKEN` env var; requests must
then carry `Authorization: Bearer <token>` or they get `401`. Without a token on
the HTTP transport, gdbmcp prints a warning and runs unauthenticated — do not do
this on a reachable network.
This is a shared-secret scheme (constant-time compare). For production, put a
**reverse proxy** (nginx/caddy) in front to terminate TLS and bind gdbmcp to
`127.0.0.1`, so the token is never sent in cleartext. (gdbmcp does not bundle a
TLS terminator; OAuth is not implemented — see `auth.py` if you need either.)
### `config.json` (optional)
See [`config.example.json`](config.example.json). Notable fields: `gdb_path`,
`timeouts.{attach,call,eval}_seconds`, `output.{default_format,max_depth,
max_children,string_truncate}`, `stl_flavor`.
## The `evaluate` tool
- **`expression`** *(string, required)* — a C++ expression.
- **`format`** *("text" | "json", default "text")* — `text` is gdb-style;
`json` returns a typed tree `{name, type, value|children}` (scalars coerced to
int/float/bool/str).
On error, returns a plain-language diagnostic string (unknown symbol, null
dereference, timeout, target exited, invalid expression). Failures never crash
the server, GDB, or the target.
## The `map_get` and `enumerate` tools (no-accessor container access)
When the target has no out-of-line accessor, these read containers directly:
**`map_get(map_expr, key_expr, format="text")`** — look up one element by key in
`std::map` / `std::unordered_map` / `std::multimap` / `std::multiset`. Walks the
red-black tree / hash buckets by memory reads and matches the key (deep,
field-wise, so struct keys compare by fields). Use C-style cast for struct keys:
`map_get("world->m_players", "(EntityID)1002")`.
**`enumerate(container_expr, limit=100, format="text")`** — list up to `limit`
elements of any STL container (`map`/`set`/`multimap`/`multiset`, `unordered_*`,
`vector`). Map items carry both key and value; vector/set items carry the value.
Both never call inlined STL methods, so they work on any target regardless of
accessors. Layout is libstdc++-specific (stable across its ABI versions).
## Quick examples (MCP JSON-RPC)
All three tools are called the same way — a `tools/call` JSON-RPC over whichever
transport you started (stdio or HTTP). After the standard `initialize` /
`notifications/initialized` handshake:
**`evaluate`** — read a single value/expression (prefers an out-of-line accessor):
```jsonc
{ "jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": { "name": "evaluate",
"arguments": { "expression": "GameWorld::GetInstance()->find_player(1001)->m_hp",
"format": "text" } } }
// -> { "result": { "isError": false, "content": [ { "type": "text", "text": "int = 100" } ] } }
```
**`map_get`** — look up one map element by key WITHOUT an accessor:
```jsonc
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "map_get",
"arguments": { "map_expr": "GameWorld::GetInstance()->m_players",
"key_expr": "(EntityID)1002", "format": "text" } } }
// -> key matched -> value is the Player struct dump (Bob ...)
```
**`enumerate`** — list elements of any container:
```jsonc
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": { "name": "enumerate",
"arguments": { "container_expr": "GameWorld::GetInstance()->find_player(1001)->m_items",
"limit": 5, "format": "text" } } }
// -> size=2, lists Sword / Potion
```
Use `format: "json"` on any tool for structured output. On any failure the tool
returns a plain-language diagnostic string (`isError: false`, the text describes
the problem); the server, GDB, and target never crash.
## Test
```bash
.venv/bin/python -m pytest # unit + integration + full-stack e2e
```
Tests attach to a compiled fixture (`tests/fixtures/target.cpp`) via real gdb.
## Architecture / code map
| File | Role |
|---|---|
| `src/gdbmcp/gdb_controller.py` | Resident gdb subprocess; GDB/MI send/recv with token correlation; attach/interrupt/continue/detach; watchdog abort path; init + libstdc++ printer registration |
| `src/gdbmcp/mi_parser.py` | GDB/MI stream parser (result/async/stream records; nested tuples & lists) |
| `src/gdbmcp/evaluator.py` | `evaluate` orchestration: validate → stop → watchdog → resolve (var-objects + data-evaluate) → render → resume; error mapping |
| `src/gdbmcp/container_query.py` | `map_get` / `enumerate`: gdb-Python walker that traverses map/unordered_map/vector internals with no inferior calls |
| `src/gdbmcp/formatters.py` | text/JSON rendering of the value tree; scalar coercion; depth/width caps |
| `src/gdbmcp/errors.py` | Error classification + human-friendly messages |
| `src/gdbmcp/watchdog.py` | Cancellable wall-clock timer that aborts runaway inferior calls |
| `src/gdbmcp/mcp_server.py` | FastMCP server registering the `evaluate` / `map_get` / `enumerate` tools; stdio + HTTP transports |
| `src/gdbmcp/auth.py` | Shared-secret bearer-token verifier for the HTTP transport |
| `src/gdbmcp/lifecycle.py` | atexit + signal handlers → clean detach on exit |
| `src/gdbmcp/config.py` | Config load (JSON + CLI overrides) |
| `src/gdbmcp/__main__.py` | CLI entry: parse args, attach, serve stdio |
## Out of scope (v1)
- non-stop mode (smaller pause blast radius), process-name auto-discovery,
- a custom GDB Python pretty-printer that emits JSON (currently the JSON path
parses GDB's var-object output), memory-write tooling, and bundling gdb into
the executable.
This server cannot be deployed
Maintenance
ActivityInactive
ResponsivenessNo issues