cpp-debug-mcp 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,301 @@
1
+ """Human-readable output formatting for MCP tool results."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def section(title: str, content: str) -> str:
7
+ """Format a titled section."""
8
+ return f"── {title} ──\n{content}"
9
+
10
+
11
+ def kv(key: str, value: Any, indent: int = 0) -> str:
12
+ """Format a key-value pair."""
13
+ prefix = " " * indent
14
+ return f"{prefix}{key}: {value}"
15
+
16
+
17
+ def fmt_session_start(kind: str, session_id: str, details: str = "") -> str:
18
+ lines = [
19
+ section(f"{kind} Session Started", kv("Session ID", session_id)),
20
+ ]
21
+ if details:
22
+ lines.append(details)
23
+ return "\n".join(lines)
24
+
25
+
26
+ def fmt_session_end(kind: str, session_id: str) -> str:
27
+ return f"── {kind} Session Ended ──\n{kv('Session ID', session_id)}"
28
+
29
+
30
+ def fmt_breakpoint(bp: dict) -> str:
31
+ loc = f"{bp.get('file', '?')}:{bp.get('line', '?')}"
32
+ func = bp.get("function") or bp.get("func", "")
33
+ cond = bp.get("condition") or bp.get("cond", "")
34
+ lines = [
35
+ kv("Breakpoint", f"#{bp.get('breakpoint_id', bp.get('id', '?'))}"),
36
+ kv("Location", loc, indent=1),
37
+ ]
38
+ if func:
39
+ lines.append(kv("Function", func, indent=1))
40
+ if cond:
41
+ lines.append(kv("Condition", cond, indent=1))
42
+ return "\n".join(lines)
43
+
44
+
45
+ def fmt_breakpoint_list(breakpoints: list[dict]) -> str:
46
+ if not breakpoints:
47
+ return "No breakpoints set."
48
+ lines = [f"── Breakpoints ({len(breakpoints)}) ──"]
49
+ for bp in breakpoints:
50
+ enabled = bp.get("enabled", "y")
51
+ status = "ON" if enabled == "y" else "OFF"
52
+ loc = bp.get("location", f"{bp.get('file', '?')}:{bp.get('line', '?')}")
53
+ func = bp.get("function", "")
54
+ hits = bp.get("hit_count", "0")
55
+ entry = f" #{bp.get('id', '?')} [{status}] {loc}"
56
+ if func:
57
+ entry += f" ({func})"
58
+ entry += f" hits={hits}"
59
+ cond = bp.get("condition", "")
60
+ if cond:
61
+ entry += f" if {cond}"
62
+ lines.append(entry)
63
+ return "\n".join(lines)
64
+
65
+
66
+ def fmt_backtrace(frames: list[dict]) -> str:
67
+ if not frames:
68
+ return "No stack frames."
69
+ lines = [f"── Backtrace ({len(frames)} frames) ──"]
70
+ for f in frames:
71
+ level = f.get("level", "?")
72
+ func = f.get("function", "??")
73
+ file_ = f.get("file", "")
74
+ line = f.get("line", "")
75
+ addr = f.get("address", "")
76
+ loc = f"{file_}:{line}" if file_ else addr
77
+ lines.append(f" #{level} {func} at {loc}")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def fmt_variables(variables: list[dict]) -> str:
82
+ if not variables:
83
+ return "No local variables."
84
+ lines = [f"── Local Variables ({len(variables)}) ──"]
85
+ for v in variables:
86
+ name = v.get("name", "?")
87
+ typ = v.get("type", "")
88
+ val = v.get("value", "")
89
+ if typ:
90
+ lines.append(f" {typ} {name} = {val}")
91
+ else:
92
+ lines.append(f" {name} = {val}")
93
+ return "\n".join(lines)
94
+
95
+
96
+ def fmt_evaluate(expression: str, value: str) -> str:
97
+ return f"{expression} = {value}"
98
+
99
+
100
+ def fmt_memory(blocks: list[dict]) -> str:
101
+ if not blocks:
102
+ return "No memory data."
103
+ lines = ["── Memory ──"]
104
+ for block in blocks:
105
+ begin = block.get("begin", "?")
106
+ end = block.get("end", "")
107
+ contents = block.get("contents", "")
108
+ lines.append(f" {begin}:")
109
+ # Format hex contents in groups of 16 bytes (32 hex chars)
110
+ for i in range(0, len(contents), 32):
111
+ chunk = contents[i:i+32]
112
+ # Add spaces every 2 hex chars for readability
113
+ spaced = " ".join(chunk[j:j+2] for j in range(0, len(chunk), 2))
114
+ offset = i // 2
115
+ lines.append(f" +{offset:04x} {spaced}")
116
+ return "\n".join(lines)
117
+
118
+
119
+ def fmt_threads(threads: list[dict]) -> str:
120
+ if not threads:
121
+ return "No threads."
122
+ lines = [f"── Threads ({len(threads)}) ──"]
123
+ for t in threads:
124
+ tid = t.get("id", "?")
125
+ name = t.get("name", "")
126
+ state = t.get("state", "")
127
+ func = t.get("function", "")
128
+ file_ = t.get("file", "")
129
+ line = t.get("line", "")
130
+ loc = f"at {file_}:{line}" if file_ else ""
131
+ name_str = f" ({name})" if name else ""
132
+ lines.append(f" Thread {tid}{name_str} [{state}] {func} {loc}".rstrip())
133
+ return "\n".join(lines)
134
+
135
+
136
+ def fmt_diagnostics(diagnostics: list[dict]) -> str:
137
+ if not diagnostics:
138
+ return "No diagnostics (clean)."
139
+ # Count by severity
140
+ errors = sum(1 for d in diagnostics if d.get("severity") == "error")
141
+ warnings = sum(1 for d in diagnostics if d.get("severity") == "warning")
142
+ others = len(diagnostics) - errors - warnings
143
+ summary = []
144
+ if errors:
145
+ summary.append(f"{errors} error(s)")
146
+ if warnings:
147
+ summary.append(f"{warnings} warning(s)")
148
+ if others:
149
+ summary.append(f"{others} other")
150
+ lines = [f"── Diagnostics ({', '.join(summary)}) ──"]
151
+ for d in diagnostics:
152
+ sev = d.get("severity", "?").upper()
153
+ line = d.get("line", 0) + 1 # Convert 0-indexed to 1-indexed for display
154
+ col = d.get("column", 0) + 1
155
+ msg = d.get("message", "")
156
+ src = d.get("source", "")
157
+ file_ = d.get("file", "")
158
+ loc = f"{file_}:{line}:{col}" if file_ else f":{line}:{col}"
159
+ src_str = f" [{src}]" if src else ""
160
+ lines.append(f" {sev}{src_str} {loc}")
161
+ lines.append(f" {msg}")
162
+ return "\n".join(lines)
163
+
164
+
165
+ def fmt_hover(hover: dict) -> str:
166
+ contents = hover.get("contents", "")
167
+ lang = hover.get("language", "")
168
+ if not contents:
169
+ return "No hover information."
170
+ if lang:
171
+ return f"── Type Info ({lang}) ──\n {contents}"
172
+ return f"── Type Info ──\n {contents}"
173
+
174
+
175
+ def fmt_locations(locations: list[dict], title: str = "Locations") -> str:
176
+ if not locations:
177
+ return f"No {title.lower()} found."
178
+ lines = [f"── {title} ({len(locations)}) ──"]
179
+ for loc in locations:
180
+ file_ = loc.get("file", "?")
181
+ line = loc.get("line", 0) + 1 # 0-indexed to 1-indexed
182
+ col = loc.get("column", 0) + 1
183
+ lines.append(f" {file_}:{line}:{col}")
184
+ return "\n".join(lines)
185
+
186
+
187
+ def fmt_symbols(symbols: list[dict], indent: int = 0) -> str:
188
+ if not symbols:
189
+ return "No symbols found."
190
+ lines = []
191
+ if indent == 0:
192
+ lines.append(f"── Symbols ({len(symbols)}) ──")
193
+ for s in symbols:
194
+ prefix = " " * (indent + 1)
195
+ kind = s.get("kind", "?")
196
+ name = s.get("name", "?")
197
+ line = s.get("line", 0) + 1
198
+ lines.append(f"{prefix}{kind} {name} (line {line})")
199
+ children = s.get("children", [])
200
+ if children:
201
+ lines.append(fmt_symbols(children, indent + 1))
202
+ return "\n".join(lines)
203
+
204
+
205
+ def fmt_signature_help(data: dict) -> str:
206
+ sigs = data.get("signatures", [])
207
+ if not sigs:
208
+ return "No signature information."
209
+ active_sig = data.get("active_signature", 0)
210
+ active_param = data.get("active_parameter", 0)
211
+ lines = [f"── Signature Help ──"]
212
+ for i, sig in enumerate(sigs):
213
+ marker = "▸" if i == active_sig else " "
214
+ lines.append(f" {marker} {sig.get('label', '?')}")
215
+ for j, p in enumerate(sig.get("parameters", [])):
216
+ param_marker = "▸" if i == active_sig and j == active_param else " "
217
+ doc = p.get("documentation", "")
218
+ label = p.get("label", "?")
219
+ doc_str = f" — {doc}" if doc else ""
220
+ lines.append(f" {param_marker} {label}{doc_str}")
221
+ return "\n".join(lines)
222
+
223
+
224
+ def fmt_crash_report(report: dict) -> str:
225
+ lines = ["══ Crash Diagnosis Report ══"]
226
+
227
+ # Thread info
228
+ thread = report.get("current_thread", {})
229
+ if thread:
230
+ lines.append("")
231
+ lines.append(section("Current Thread", "\n".join([
232
+ kv("ID", thread.get("id", "?"), indent=1),
233
+ kv("State", thread.get("state", "?"), indent=1),
234
+ ])))
235
+
236
+ # Backtrace
237
+ bt = report.get("backtrace", [])
238
+ if bt:
239
+ lines.append("")
240
+ lines.append(fmt_backtrace(bt))
241
+
242
+ # Local variables
243
+ local_vars = report.get("local_variables", [])
244
+ if local_vars:
245
+ lines.append("")
246
+ lines.append(fmt_variables(local_vars))
247
+
248
+ # Static diagnostics
249
+ diags = report.get("static_diagnostics", {})
250
+ if diags:
251
+ lines.append("")
252
+ for file_path, file_diags in diags.items():
253
+ lines.append(f"── Diagnostics: {file_path} ──")
254
+ for d in file_diags:
255
+ sev = d.get("severity", "?").upper()
256
+ line = d.get("line", 0) + 1
257
+ msg = d.get("message", "")
258
+ lines.append(f" {sev} :{line} {msg}")
259
+
260
+ return "\n".join(lines)
261
+
262
+
263
+ def fmt_variable_info(info: dict) -> str:
264
+ lines = [f"── Variable: {info.get('variable', '?')} ──"]
265
+ lines.append(kv("Runtime Value", info.get("runtime_value", "N/A"), indent=1))
266
+ type_info = info.get("type_info", {})
267
+ if type_info:
268
+ lines.append(kv("Type", type_info.get("contents", "?"), indent=1))
269
+ defn = info.get("definition", {})
270
+ if defn:
271
+ lines.append(kv("Defined at", f"{defn.get('file', '?')}:{defn.get('line', 0) + 1}", indent=1))
272
+ return "\n".join(lines)
273
+
274
+
275
+ def fmt_function_analysis(info: dict) -> str:
276
+ lines = [f"── Function: {info.get('function', '?')} ──"]
277
+
278
+ bp = info.get("breakpoint", {})
279
+ if bp:
280
+ loc = f"{bp.get('file', '?')}:{bp.get('line', '?')}"
281
+ lines.append(kv("Location", loc, indent=1))
282
+ lines.append(kv("Breakpoint", f"#{bp.get('id', '?')} (temporary)", indent=1))
283
+
284
+ sig = info.get("signature", {})
285
+ if sig:
286
+ lines.append(kv("Signature", sig.get("contents", ""), indent=1))
287
+
288
+ refs = info.get("references", [])
289
+ if refs:
290
+ lines.append(f" References ({len(refs)}):")
291
+ for r in refs[:10]:
292
+ lines.append(f" {r.get('file', '?')}:{r.get('line', 0) + 1}")
293
+ if len(refs) > 10:
294
+ lines.append(f" ... and {len(refs) - 10} more")
295
+
296
+ local_vars = info.get("local_variables", [])
297
+ if local_vars:
298
+ lines.append("")
299
+ lines.append(fmt_variables(local_vars))
300
+
301
+ return "\n".join(lines)
@@ -0,0 +1,395 @@
1
+ """GDB MCP tool definitions."""
2
+
3
+ from typing import Any
4
+
5
+ from fastmcp import Context
6
+
7
+ from . import fmt
8
+
9
+
10
+ def _payload(responses: list[dict[str, Any]]) -> dict | str | None:
11
+ """Extract the result payload from MI responses."""
12
+ for r in responses:
13
+ if r.get("type") == "result":
14
+ return r.get("payload")
15
+ return None
16
+
17
+
18
+ def _raw_fmt(controller, responses: list[dict[str, Any]]) -> str:
19
+ """Fallback: format MI responses via controller."""
20
+ return controller.format(responses)
21
+
22
+
23
+ def register_gdb_tools(mcp):
24
+ """Register all GDB tools on the FastMCP server instance."""
25
+
26
+ @mcp.tool()
27
+ async def gdb_start_session(
28
+ executable: str,
29
+ args: list[str] = [],
30
+ working_dir: str = ".",
31
+ ctx: Context = None,
32
+ ) -> str:
33
+ """Start a GDB debugging session for a compiled C++ executable.
34
+
35
+ Args:
36
+ executable: Path to the compiled executable (must be built with -g for debug symbols).
37
+ args: Command-line arguments to pass to the program.
38
+ working_dir: Working directory for the debug session.
39
+ """
40
+ manager = ctx.request_context.lifespan_context["gdb"]
41
+ session_id, output = await manager.create_session(executable, args, working_dir)
42
+ return fmt.fmt_session_start("GDB", session_id, output)
43
+
44
+ @mcp.tool()
45
+ async def gdb_end_session(session_id: str, ctx: Context = None) -> str:
46
+ """End a GDB debugging session and clean up resources.
47
+
48
+ Args:
49
+ session_id: The session identifier returned by gdb_start_session.
50
+ """
51
+ manager = ctx.request_context.lifespan_context["gdb"]
52
+ await manager.destroy_session(session_id)
53
+ return fmt.fmt_session_end("GDB", session_id)
54
+
55
+ @mcp.tool()
56
+ async def gdb_run(
57
+ session_id: str,
58
+ stop_at_main: bool = True,
59
+ ctx: Context = None,
60
+ ) -> str:
61
+ """Start program execution in GDB.
62
+
63
+ Args:
64
+ session_id: The session identifier.
65
+ stop_at_main: If true, stop at the beginning of main().
66
+ """
67
+ manager = ctx.request_context.lifespan_context["gdb"]
68
+ ctrl = manager.get_session(session_id)
69
+
70
+ if stop_at_main:
71
+ responses = await ctrl.send_command("-exec-run --start")
72
+ else:
73
+ responses = await ctrl.send_command("-exec-run")
74
+
75
+ return _raw_fmt(ctrl, responses)
76
+
77
+ @mcp.tool()
78
+ async def gdb_set_breakpoint(
79
+ session_id: str,
80
+ location: str,
81
+ condition: str = "",
82
+ ctx: Context = None,
83
+ ) -> str:
84
+ """Set a breakpoint at a location.
85
+
86
+ Args:
87
+ session_id: The session identifier.
88
+ location: Where to break. Accepts "file:line" (e.g. "main.cpp:42"), function name (e.g. "main"), or address.
89
+ condition: Optional C++ condition expression (e.g. "i > 10").
90
+ """
91
+ manager = ctx.request_context.lifespan_context["gdb"]
92
+ ctrl = manager.get_session(session_id)
93
+
94
+ cmd = "-break-insert"
95
+ if condition:
96
+ cmd += f" -c {condition}"
97
+ cmd += f" {location}"
98
+
99
+ responses = await ctrl.send_command(cmd)
100
+ payload = _payload(responses)
101
+
102
+ if isinstance(payload, dict) and "bkpt" in payload:
103
+ bkpt = payload["bkpt"]
104
+ return fmt.fmt_breakpoint({
105
+ "breakpoint_id": bkpt.get("number"),
106
+ "file": bkpt.get("file", ""),
107
+ "line": bkpt.get("line", ""),
108
+ "function": bkpt.get("func", ""),
109
+ })
110
+ return _raw_fmt(ctrl, responses)
111
+
112
+ @mcp.tool()
113
+ async def gdb_delete_breakpoint(
114
+ session_id: str,
115
+ breakpoint_id: int,
116
+ ctx: Context = None,
117
+ ) -> str:
118
+ """Delete a breakpoint.
119
+
120
+ Args:
121
+ session_id: The session identifier.
122
+ breakpoint_id: The breakpoint number to delete.
123
+ """
124
+ manager = ctx.request_context.lifespan_context["gdb"]
125
+ ctrl = manager.get_session(session_id)
126
+ responses = await ctrl.send_command(f"-break-delete {breakpoint_id}")
127
+ return f"Breakpoint #{breakpoint_id} deleted."
128
+
129
+ @mcp.tool()
130
+ async def gdb_list_breakpoints(session_id: str, ctx: Context = None) -> str:
131
+ """List all breakpoints in the session.
132
+
133
+ Args:
134
+ session_id: The session identifier.
135
+ """
136
+ manager = ctx.request_context.lifespan_context["gdb"]
137
+ ctrl = manager.get_session(session_id)
138
+ responses = await ctrl.send_command("-break-list")
139
+ payload = _payload(responses)
140
+
141
+ if isinstance(payload, dict) and "BreakpointTable" in payload:
142
+ table = payload["BreakpointTable"]
143
+ breakpoints = []
144
+ for bp in table.get("body", []):
145
+ breakpoints.append({
146
+ "id": bp.get("number"),
147
+ "enabled": bp.get("enabled"),
148
+ "location": f"{bp.get('file', '?')}:{bp.get('line', '?')}",
149
+ "function": bp.get("func", ""),
150
+ "condition": bp.get("cond", ""),
151
+ "hit_count": bp.get("times", "0"),
152
+ })
153
+ return fmt.fmt_breakpoint_list(breakpoints)
154
+ return _raw_fmt(ctrl, responses)
155
+
156
+ @mcp.tool()
157
+ async def gdb_continue(session_id: str, ctx: Context = None) -> str:
158
+ """Continue program execution until next breakpoint or exit.
159
+
160
+ Args:
161
+ session_id: The session identifier.
162
+ """
163
+ manager = ctx.request_context.lifespan_context["gdb"]
164
+ ctrl = manager.get_session(session_id)
165
+ responses = await ctrl.send_command("-exec-continue")
166
+ return _raw_fmt(ctrl, responses)
167
+
168
+ @mcp.tool()
169
+ async def gdb_step(
170
+ session_id: str,
171
+ mode: str = "into",
172
+ ctx: Context = None,
173
+ ) -> str:
174
+ """Step through program execution.
175
+
176
+ Args:
177
+ session_id: The session identifier.
178
+ mode: Step mode - "into" (step into functions), "over" (step over functions), "out" (step out of current function).
179
+ """
180
+ manager = ctx.request_context.lifespan_context["gdb"]
181
+ ctrl = manager.get_session(session_id)
182
+
183
+ cmd_map = {
184
+ "into": "-exec-step",
185
+ "over": "-exec-next",
186
+ "out": "-exec-finish",
187
+ }
188
+ cmd = cmd_map.get(mode)
189
+ if not cmd:
190
+ return f"Invalid step mode: {mode}. Use 'into', 'over', or 'out'."
191
+
192
+ responses = await ctrl.send_command(cmd)
193
+ return _raw_fmt(ctrl, responses)
194
+
195
+ @mcp.tool()
196
+ async def gdb_backtrace(
197
+ session_id: str,
198
+ max_frames: int = 20,
199
+ ctx: Context = None,
200
+ ) -> str:
201
+ """Get the current call stack (backtrace).
202
+
203
+ Args:
204
+ session_id: The session identifier.
205
+ max_frames: Maximum number of frames to return.
206
+ """
207
+ manager = ctx.request_context.lifespan_context["gdb"]
208
+ ctrl = manager.get_session(session_id)
209
+ responses = await ctrl.send_command(f"-stack-list-frames 0 {max_frames - 1}")
210
+ payload = _payload(responses)
211
+
212
+ if isinstance(payload, dict) and "stack" in payload:
213
+ frames = []
214
+ for frame_entry in payload["stack"]:
215
+ frame = frame_entry.get("frame", frame_entry) if isinstance(frame_entry, dict) else frame_entry
216
+ frames.append({
217
+ "level": frame.get("level"),
218
+ "function": frame.get("func", "??"),
219
+ "file": frame.get("file", ""),
220
+ "line": frame.get("line", ""),
221
+ "address": frame.get("addr", ""),
222
+ })
223
+ return fmt.fmt_backtrace(frames)
224
+ return _raw_fmt(ctrl, responses)
225
+
226
+ @mcp.tool()
227
+ async def gdb_list_variables(
228
+ session_id: str,
229
+ frame: int = 0,
230
+ ctx: Context = None,
231
+ ) -> str:
232
+ """List local variables in a stack frame.
233
+
234
+ Args:
235
+ session_id: The session identifier.
236
+ frame: Stack frame number (0 = current frame).
237
+ """
238
+ manager = ctx.request_context.lifespan_context["gdb"]
239
+ ctrl = manager.get_session(session_id)
240
+
241
+ if frame != 0:
242
+ await ctrl.send_command(f"-stack-select-frame {frame}")
243
+
244
+ responses = await ctrl.send_command("-stack-list-variables --simple-values")
245
+ payload = _payload(responses)
246
+
247
+ if isinstance(payload, dict) and "variables" in payload:
248
+ variables = []
249
+ for var in payload["variables"]:
250
+ variables.append({
251
+ "name": var.get("name"),
252
+ "type": var.get("type", ""),
253
+ "value": var.get("value", ""),
254
+ })
255
+ return fmt.fmt_variables(variables)
256
+ return _raw_fmt(ctrl, responses)
257
+
258
+ @mcp.tool()
259
+ async def gdb_evaluate(
260
+ session_id: str,
261
+ expression: str,
262
+ ctx: Context = None,
263
+ ) -> str:
264
+ """Evaluate a C++ expression in the current debugging context.
265
+
266
+ Args:
267
+ session_id: The session identifier.
268
+ expression: C++ expression to evaluate (e.g. "x + y", "arr[5]", "*ptr").
269
+ """
270
+ manager = ctx.request_context.lifespan_context["gdb"]
271
+ ctrl = manager.get_session(session_id)
272
+ responses = await ctrl.send_command(f'-data-evaluate-expression "{expression}"')
273
+ payload = _payload(responses)
274
+
275
+ if isinstance(payload, dict) and "value" in payload:
276
+ return fmt.fmt_evaluate(expression, payload["value"])
277
+ return _raw_fmt(ctrl, responses)
278
+
279
+ @mcp.tool()
280
+ async def gdb_read_memory(
281
+ session_id: str,
282
+ address: str,
283
+ count: int = 64,
284
+ ctx: Context = None,
285
+ ) -> str:
286
+ """Read raw memory at an address.
287
+
288
+ Args:
289
+ session_id: The session identifier.
290
+ address: Memory address (e.g. "0x7fff5fbff8a0" or "&variable").
291
+ count: Number of bytes to read.
292
+ """
293
+ manager = ctx.request_context.lifespan_context["gdb"]
294
+ ctrl = manager.get_session(session_id)
295
+ responses = await ctrl.send_command(f"-data-read-memory-bytes {address} {count}")
296
+ payload = _payload(responses)
297
+
298
+ if isinstance(payload, dict) and "memory" in payload:
299
+ blocks = []
300
+ for block in payload["memory"]:
301
+ blocks.append({
302
+ "begin": block.get("begin"),
303
+ "end": block.get("end"),
304
+ "contents": block.get("contents"),
305
+ })
306
+ return fmt.fmt_memory(blocks)
307
+ return _raw_fmt(ctrl, responses)
308
+
309
+ @mcp.tool()
310
+ async def gdb_thread_info(session_id: str, ctx: Context = None) -> str:
311
+ """List all threads and their states.
312
+
313
+ Args:
314
+ session_id: The session identifier.
315
+ """
316
+ manager = ctx.request_context.lifespan_context["gdb"]
317
+ ctrl = manager.get_session(session_id)
318
+ responses = await ctrl.send_command("-thread-info")
319
+ payload = _payload(responses)
320
+
321
+ if isinstance(payload, dict) and "threads" in payload:
322
+ threads = []
323
+ for t in payload["threads"]:
324
+ frame = t.get("frame", {})
325
+ threads.append({
326
+ "id": t.get("id"),
327
+ "name": t.get("name", ""),
328
+ "state": t.get("state", ""),
329
+ "function": frame.get("func", ""),
330
+ "file": frame.get("file", ""),
331
+ "line": frame.get("line", ""),
332
+ })
333
+ return fmt.fmt_threads(threads)
334
+ return _raw_fmt(ctrl, responses)
335
+
336
+ @mcp.tool()
337
+ async def gdb_raw_command(
338
+ session_id: str,
339
+ command: str,
340
+ ctx: Context = None,
341
+ ) -> str:
342
+ """Execute a raw GDB command (with safety restrictions).
343
+
344
+ Blocked commands: shell, python, pipe, source.
345
+
346
+ Args:
347
+ session_id: The session identifier.
348
+ command: The GDB command to execute.
349
+ """
350
+ manager = ctx.request_context.lifespan_context["gdb"]
351
+ ctrl = manager.get_session(session_id)
352
+ responses = await ctrl.send_raw_command(command)
353
+ return _raw_fmt(ctrl, responses)
354
+
355
+ @mcp.tool()
356
+ async def gdb_open_console(session_id: str, ctx: Context = None) -> str:
357
+ """Open an interactive GDB console so the programmer can step through code themselves.
358
+
359
+ Creates a tmux session with a full GDB console attached to the same
360
+ debug session. Both Claude (via MCP tools) and the programmer (via the
361
+ console) can control GDB simultaneously.
362
+
363
+ Requires tmux to be installed.
364
+
365
+ Args:
366
+ session_id: The session identifier.
367
+ """
368
+ manager = ctx.request_context.lifespan_context["gdb"]
369
+ tmux_name = await manager.open_console(session_id)
370
+ return (
371
+ f"── Interactive GDB Console ──\n"
372
+ f" Session: {session_id}\n"
373
+ f" tmux session: {tmux_name}\n"
374
+ f"\n"
375
+ f" Connect from your terminal:\n"
376
+ f" tmux attach -t {tmux_name}\n"
377
+ f"\n"
378
+ f" You can type GDB commands directly (e.g. next, print x, bt).\n"
379
+ f" Both you and Claude share the same debug session.\n"
380
+ f" Detach with: Ctrl-b d"
381
+ )
382
+
383
+ @mcp.tool()
384
+ async def gdb_close_console(session_id: str, ctx: Context = None) -> str:
385
+ """Close the interactive GDB console for a session.
386
+
387
+ Args:
388
+ session_id: The session identifier.
389
+ """
390
+ manager = ctx.request_context.lifespan_context["gdb"]
391
+ tmux_name = manager.get_console(session_id)
392
+ if not tmux_name:
393
+ return f"No console open for session {session_id}."
394
+ await manager.close_console(session_id)
395
+ return f"Console closed for session {session_id} (tmux session {tmux_name} terminated)."