textual-debugger 0.1.3__tar.gz → 0.1.4__tar.gz

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.
Files changed (72) hide show
  1. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/PKG-INFO +91 -6
  2. {textual_debugger-0.1.3/src/tdb → textual_debugger-0.1.4}/README.md +89 -5
  3. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/pyproject.toml +3 -1
  4. {textual_debugger-0.1.3 → textual_debugger-0.1.4/src/tdb}/README.md +89 -5
  5. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/__init__.py +1 -1
  6. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_handlers/inspection.py +39 -123
  7. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/cli.py +20 -8
  8. textual_debugger-0.1.4/src/tdb/mcp/__init__.py +10 -0
  9. textual_debugger-0.1.4/src/tdb/mcp/__main__.py +6 -0
  10. textual_debugger-0.1.4/src/tdb/mcp/server.py +306 -0
  11. textual_debugger-0.1.4/src/tdb/mcp/session.py +205 -0
  12. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/app.py +14 -0
  13. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/handlers.py +156 -105
  14. textual_debugger-0.1.4/src/tdb/session/inspect_service.py +265 -0
  15. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/PKG-INFO +91 -6
  16. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/SOURCES.txt +5 -0
  17. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/entry_points.txt +1 -0
  18. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/requires.txt +1 -0
  19. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/LICENSE +0 -0
  20. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/setup.cfg +0 -0
  21. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/__main__.py +0 -0
  22. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/_timeouts.py +0 -0
  23. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app.py +0 -0
  24. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_handlers/__init__.py +0 -0
  25. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_handlers/dap_events.py +0 -0
  26. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_handlers/routing.py +0 -0
  27. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_handlers/ui_panels.py +0 -0
  28. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/app_helpers.py +0 -0
  29. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/breakpoint_hook.py +0 -0
  30. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/dap/__init__.py +0 -0
  31. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/dap/client.py +0 -0
  32. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/dap/messages.py +0 -0
  33. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/dap/protocol.py +0 -0
  34. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/dap/types.py +0 -0
  35. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/inspection.py +0 -0
  36. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/inspection_full.py +0 -0
  37. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/keybindings/__init__.py +0 -0
  38. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/persist.py +0 -0
  39. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/post_mortem.py +0 -0
  40. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/processes_cache.py +0 -0
  41. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/__init__.py +0 -0
  42. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/event_handler.py +0 -0
  43. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/rpc_types.py +0 -0
  44. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/server/runner.py +0 -0
  45. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/__init__.py +0 -0
  46. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/child_processes.py +0 -0
  47. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/controller.py +0 -0
  48. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/event_bus.py +0 -0
  49. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/messages.py +0 -0
  50. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/post_mortem_loader.py +0 -0
  51. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/state.py +0 -0
  52. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/statement_stepper.py +0 -0
  53. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/terminal.py +0 -0
  54. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/session/textual_handler.py +0 -0
  55. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/source_analysis.py +0 -0
  56. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/__init__.py +0 -0
  57. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/_inspection_modal.py +0 -0
  58. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/async_tasks_modal.py +0 -0
  59. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/breakpoint_view.py +0 -0
  60. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/code_view.py +0 -0
  61. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/console_view.py +0 -0
  62. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/evaluate_console.py +0 -0
  63. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/full_contents_modal.py +0 -0
  64. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/menu_bar.py +0 -0
  65. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/modals.py +0 -0
  66. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/processes_modal.py +0 -0
  67. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/stack_view.py +0 -0
  68. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/status_bar.py +0 -0
  69. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/threads_modal.py +0 -0
  70. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/tdb/widgets/variable_view.py +0 -0
  71. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/dependency_links.txt +0 -0
  72. {textual_debugger-0.1.3 → textual_debugger-0.1.4}/src/textual_debugger.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: textual-debugger
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: A TUI Python debugger based on textual and debugpy
5
5
  Author-email: Al Danial <al.danial@gmail.com>
6
6
  License: textual-debugger is licensed to you under the MIT/X Consortium license:
@@ -51,6 +51,7 @@ Requires-Dist: debugpy>=1.8.0
51
51
  Requires-Dist: pygments>=2.17.0
52
52
  Requires-Dist: fastapi>=0.115.0
53
53
  Requires-Dist: uvicorn>=0.30.0
54
+ Requires-Dist: mcp>=1.28.0
54
55
  Provides-Extra: dev
55
56
  Requires-Dist: pytest; extra == "dev"
56
57
  Requires-Dist: pytest-cov; extra == "dev"
@@ -738,11 +739,12 @@ curl -s -X POST http://127.0.0.1:8150/rpc \
738
739
  | `set_breakpoint` | `["file:line"]` or `["file:line", "condition", "hit_condition"]` | Set a breakpoint |
739
740
  | `remove_breakpoint` | `["file:line"]` | Remove a breakpoint |
740
741
  | `list_breakpoints` | `[]` | Show all breakpoints |
741
- | `continue` | `[]` | Resume execution |
742
- | `next` | `[]` | Step over |
743
- | `step_in` | `[]` | Step into |
744
- | `step_out` | `[]` | Step out |
745
- | `pause` | `[]` | Pause execution |
742
+ | `continue` | `[]` or `[timeout_s]` | Resume execution; on timeout returns `"still running — call pause or wait again"` (success) |
743
+ | `next` | `[]` or `[timeout_s]` | Step over |
744
+ | `step_in` | `[]` or `[timeout_s]` | Step into |
745
+ | `step_out` | `[]` or `[timeout_s]` | Step out |
746
+ | `pause` | `[]` | Pause execution; bypasses the dispatch lock so it can interrupt an in-flight blocking action |
747
+ | `wait_for_stop` | `[]` or `[timeout_s]` | Wait for the next stop without issuing a step (use after `continue` returns `"still running"` to keep waiting) |
746
748
  | `inspect` | `["expr1", "expr2", ...]` | Evaluate multiple expressions |
747
749
  | `evaluate` | `["expression"]` | Evaluate a single expression |
748
750
  | `stack_up` | `[]` | Move up the call stack |
@@ -771,6 +773,89 @@ curl -N http://127.0.0.1:8150/events
771
773
  Events: `initialized`, `stopped`, `continued`, `terminated`, `exited`, `output`.
772
774
  Each is JSON with `event`, `data`, and `timestamp` fields.
773
775
 
776
+ ## MCP Integration
777
+
778
+ tdb ships a Model Context Protocol (MCP) server (`tdb-mcp`) that exposes
779
+ the debugger as a curated set of tools an AI agent can call. The MCP
780
+ server is a third in-process consumer of the same dispatch handlers the
781
+ TUI and the HTTP server use — so an agent gets the same lock semantics,
782
+ including the pause-during-continue bypass, and the same DAP-backed
783
+ inspection surface.
784
+
785
+ For a worked end-to-end example (prompting an agent to find two
786
+ runtime bugs in a sample program) see
787
+ [docs/tutorial-mcp-debugging.md](docs/tutorial-mcp-debugging.md) and
788
+ its companion `examples/sales_report_buggy.py`.
789
+
790
+ ### Running the MCP server
791
+
792
+ Configure your MCP client (Claude Desktop, an IDE extension, etc.) to
793
+ launch `tdb-mcp` over stdio. Example `claude_desktop_config.json`:
794
+
795
+ ```json
796
+ {
797
+ "mcpServers": {
798
+ "tdb": {
799
+ "command": "tdb-mcp"
800
+ }
801
+ }
802
+ }
803
+ ```
804
+
805
+ Three equivalent invocation forms: `tdb-mcp` (the dedicated entry
806
+ point), `tdb --mcp` (the main CLI with the `--mcp` switch), and
807
+ `python -m tdb.mcp` (module form). Pick whichever matches how your MCP
808
+ client expects to launch servers.
809
+
810
+ ### Tool surface (16 tools, curated)
811
+
812
+ | Cluster | Tools |
813
+ |---------|-------|
814
+ | Lifecycle | `debug_launch`, `debug_attach`, `quit` |
815
+ | Control | `control(action, timeout_s=30)` — `action ∈ {continue, next, step_in, step_out, pause, wait_for_stop}` |
816
+ | Inspection | `inspect(expressions)`, `read_source(file_path)`, `stack_trace()`, `status()`, `get_output()` |
817
+ | Breakpoints | `set_breakpoint(spec, condition?, hit_condition?)`, `remove_breakpoint(spec)`, `list_breakpoints()` |
818
+ | Differentiators | `threads(thread_id?)`, `tasks(task_name?)`, `processes(name_or_pid?)`, `wait_graph()` |
819
+
820
+ `control` is intentionally one tool that takes an action enum — the six
821
+ underlying RPC actions share a return shape, and agents perform
822
+ measurably better with a small surface than with one tool per action.
823
+ `threads` / `tasks` / `processes` overload list-vs-inspect via a
824
+ single optional argument for the same reason.
825
+
826
+ ### Agent flow for a long-running step
827
+
828
+ ```
829
+ agent → control(action="continue", timeout_s=30)
830
+ mcp → "still running — call pause or wait again"
831
+ agent → control(action="pause") # OR: control(action="wait_for_stop", timeout_s=30)
832
+ mcp → "<file>:<line>"
833
+ agent → inspect(["x", "len(buf)"])
834
+ mcp → "x = 7\nlen(buf) = 1024"
835
+ ```
836
+
837
+ `pause` bypasses the dispatch lock so it can interrupt a `continue`
838
+ that's still mid-flight (HTTP and MCP share the same `NO_LOCK_ACTIONS`
839
+ policy — see `tdb/server/app.py`).
840
+
841
+ ### Security caveat
842
+
843
+ `inspect` calls debugpy's `evaluate`, which is **arbitrary Python
844
+ execution in the debuggee process**. This is inherent to a debugger and
845
+ not a tdb-specific concern, but MCP clients (and the humans running
846
+ them) should apply appropriate permission models: don't auto-approve
847
+ `inspect` against untrusted expressions, and don't expose `tdb-mcp` on
848
+ a network — stdio transport only by design.
849
+
850
+ ### Deferred / out of scope (v1)
851
+
852
+ - SSE-style event push — `control` and `wait_for_stop` make polling
853
+ efficient enough; events would also need uneven MCP-client support.
854
+ - HTTP / streamable-HTTP transports — would require auth (which the
855
+ HTTP RPC server also currently lacks); stdio inherits the trust of
856
+ the process that spawned it.
857
+ - Multi-session — one debug session per MCP process.
858
+
774
859
  ## CLI Reference
775
860
 
776
861
  ```
@@ -673,11 +673,12 @@ curl -s -X POST http://127.0.0.1:8150/rpc \
673
673
  | `set_breakpoint` | `["file:line"]` or `["file:line", "condition", "hit_condition"]` | Set a breakpoint |
674
674
  | `remove_breakpoint` | `["file:line"]` | Remove a breakpoint |
675
675
  | `list_breakpoints` | `[]` | Show all breakpoints |
676
- | `continue` | `[]` | Resume execution |
677
- | `next` | `[]` | Step over |
678
- | `step_in` | `[]` | Step into |
679
- | `step_out` | `[]` | Step out |
680
- | `pause` | `[]` | Pause execution |
676
+ | `continue` | `[]` or `[timeout_s]` | Resume execution; on timeout returns `"still running — call pause or wait again"` (success) |
677
+ | `next` | `[]` or `[timeout_s]` | Step over |
678
+ | `step_in` | `[]` or `[timeout_s]` | Step into |
679
+ | `step_out` | `[]` or `[timeout_s]` | Step out |
680
+ | `pause` | `[]` | Pause execution; bypasses the dispatch lock so it can interrupt an in-flight blocking action |
681
+ | `wait_for_stop` | `[]` or `[timeout_s]` | Wait for the next stop without issuing a step (use after `continue` returns `"still running"` to keep waiting) |
681
682
  | `inspect` | `["expr1", "expr2", ...]` | Evaluate multiple expressions |
682
683
  | `evaluate` | `["expression"]` | Evaluate a single expression |
683
684
  | `stack_up` | `[]` | Move up the call stack |
@@ -706,6 +707,89 @@ curl -N http://127.0.0.1:8150/events
706
707
  Events: `initialized`, `stopped`, `continued`, `terminated`, `exited`, `output`.
707
708
  Each is JSON with `event`, `data`, and `timestamp` fields.
708
709
 
710
+ ## MCP Integration
711
+
712
+ tdb ships a Model Context Protocol (MCP) server (`tdb-mcp`) that exposes
713
+ the debugger as a curated set of tools an AI agent can call. The MCP
714
+ server is a third in-process consumer of the same dispatch handlers the
715
+ TUI and the HTTP server use — so an agent gets the same lock semantics,
716
+ including the pause-during-continue bypass, and the same DAP-backed
717
+ inspection surface.
718
+
719
+ For a worked end-to-end example (prompting an agent to find two
720
+ runtime bugs in a sample program) see
721
+ [docs/tutorial-mcp-debugging.md](docs/tutorial-mcp-debugging.md) and
722
+ its companion `examples/sales_report_buggy.py`.
723
+
724
+ ### Running the MCP server
725
+
726
+ Configure your MCP client (Claude Desktop, an IDE extension, etc.) to
727
+ launch `tdb-mcp` over stdio. Example `claude_desktop_config.json`:
728
+
729
+ ```json
730
+ {
731
+ "mcpServers": {
732
+ "tdb": {
733
+ "command": "tdb-mcp"
734
+ }
735
+ }
736
+ }
737
+ ```
738
+
739
+ Three equivalent invocation forms: `tdb-mcp` (the dedicated entry
740
+ point), `tdb --mcp` (the main CLI with the `--mcp` switch), and
741
+ `python -m tdb.mcp` (module form). Pick whichever matches how your MCP
742
+ client expects to launch servers.
743
+
744
+ ### Tool surface (16 tools, curated)
745
+
746
+ | Cluster | Tools |
747
+ |---------|-------|
748
+ | Lifecycle | `debug_launch`, `debug_attach`, `quit` |
749
+ | Control | `control(action, timeout_s=30)` — `action ∈ {continue, next, step_in, step_out, pause, wait_for_stop}` |
750
+ | Inspection | `inspect(expressions)`, `read_source(file_path)`, `stack_trace()`, `status()`, `get_output()` |
751
+ | Breakpoints | `set_breakpoint(spec, condition?, hit_condition?)`, `remove_breakpoint(spec)`, `list_breakpoints()` |
752
+ | Differentiators | `threads(thread_id?)`, `tasks(task_name?)`, `processes(name_or_pid?)`, `wait_graph()` |
753
+
754
+ `control` is intentionally one tool that takes an action enum — the six
755
+ underlying RPC actions share a return shape, and agents perform
756
+ measurably better with a small surface than with one tool per action.
757
+ `threads` / `tasks` / `processes` overload list-vs-inspect via a
758
+ single optional argument for the same reason.
759
+
760
+ ### Agent flow for a long-running step
761
+
762
+ ```
763
+ agent → control(action="continue", timeout_s=30)
764
+ mcp → "still running — call pause or wait again"
765
+ agent → control(action="pause") # OR: control(action="wait_for_stop", timeout_s=30)
766
+ mcp → "<file>:<line>"
767
+ agent → inspect(["x", "len(buf)"])
768
+ mcp → "x = 7\nlen(buf) = 1024"
769
+ ```
770
+
771
+ `pause` bypasses the dispatch lock so it can interrupt a `continue`
772
+ that's still mid-flight (HTTP and MCP share the same `NO_LOCK_ACTIONS`
773
+ policy — see `tdb/server/app.py`).
774
+
775
+ ### Security caveat
776
+
777
+ `inspect` calls debugpy's `evaluate`, which is **arbitrary Python
778
+ execution in the debuggee process**. This is inherent to a debugger and
779
+ not a tdb-specific concern, but MCP clients (and the humans running
780
+ them) should apply appropriate permission models: don't auto-approve
781
+ `inspect` against untrusted expressions, and don't expose `tdb-mcp` on
782
+ a network — stdio transport only by design.
783
+
784
+ ### Deferred / out of scope (v1)
785
+
786
+ - SSE-style event push — `control` and `wait_for_stop` make polling
787
+ efficient enough; events would also need uneven MCP-client support.
788
+ - HTTP / streamable-HTTP transports — would require auth (which the
789
+ HTTP RPC server also currently lacks); stdio inherits the trust of
790
+ the process that spawned it.
791
+ - Multi-session — one debug session per MCP process.
792
+
709
793
  ## CLI Reference
710
794
 
711
795
  ```
@@ -25,7 +25,8 @@
25
25
  "pygments>=2.17.0",
26
26
  "fastapi>=0.115.0",
27
27
  "uvicorn>=0.30.0",
28
- ]
28
+ "mcp>=1.28.0",
29
+ ]
29
30
 
30
31
  # extras needed to run the examples and tests
31
32
  [project.optional-dependencies]
@@ -43,6 +44,7 @@
43
44
 
44
45
  [project.scripts]
45
46
  tdb = "tdb.cli:main"
47
+ tdb-mcp = "tdb.mcp.server:main"
46
48
 
47
49
  [build-system]
48
50
  requires = ["setuptools>=68.0"]
@@ -673,11 +673,12 @@ curl -s -X POST http://127.0.0.1:8150/rpc \
673
673
  | `set_breakpoint` | `["file:line"]` or `["file:line", "condition", "hit_condition"]` | Set a breakpoint |
674
674
  | `remove_breakpoint` | `["file:line"]` | Remove a breakpoint |
675
675
  | `list_breakpoints` | `[]` | Show all breakpoints |
676
- | `continue` | `[]` | Resume execution |
677
- | `next` | `[]` | Step over |
678
- | `step_in` | `[]` | Step into |
679
- | `step_out` | `[]` | Step out |
680
- | `pause` | `[]` | Pause execution |
676
+ | `continue` | `[]` or `[timeout_s]` | Resume execution; on timeout returns `"still running — call pause or wait again"` (success) |
677
+ | `next` | `[]` or `[timeout_s]` | Step over |
678
+ | `step_in` | `[]` or `[timeout_s]` | Step into |
679
+ | `step_out` | `[]` or `[timeout_s]` | Step out |
680
+ | `pause` | `[]` | Pause execution; bypasses the dispatch lock so it can interrupt an in-flight blocking action |
681
+ | `wait_for_stop` | `[]` or `[timeout_s]` | Wait for the next stop without issuing a step (use after `continue` returns `"still running"` to keep waiting) |
681
682
  | `inspect` | `["expr1", "expr2", ...]` | Evaluate multiple expressions |
682
683
  | `evaluate` | `["expression"]` | Evaluate a single expression |
683
684
  | `stack_up` | `[]` | Move up the call stack |
@@ -706,6 +707,89 @@ curl -N http://127.0.0.1:8150/events
706
707
  Events: `initialized`, `stopped`, `continued`, `terminated`, `exited`, `output`.
707
708
  Each is JSON with `event`, `data`, and `timestamp` fields.
708
709
 
710
+ ## MCP Integration
711
+
712
+ tdb ships a Model Context Protocol (MCP) server (`tdb-mcp`) that exposes
713
+ the debugger as a curated set of tools an AI agent can call. The MCP
714
+ server is a third in-process consumer of the same dispatch handlers the
715
+ TUI and the HTTP server use — so an agent gets the same lock semantics,
716
+ including the pause-during-continue bypass, and the same DAP-backed
717
+ inspection surface.
718
+
719
+ For a worked end-to-end example (prompting an agent to find two
720
+ runtime bugs in a sample program) see
721
+ [docs/tutorial-mcp-debugging.md](docs/tutorial-mcp-debugging.md) and
722
+ its companion `examples/sales_report_buggy.py`.
723
+
724
+ ### Running the MCP server
725
+
726
+ Configure your MCP client (Claude Desktop, an IDE extension, etc.) to
727
+ launch `tdb-mcp` over stdio. Example `claude_desktop_config.json`:
728
+
729
+ ```json
730
+ {
731
+ "mcpServers": {
732
+ "tdb": {
733
+ "command": "tdb-mcp"
734
+ }
735
+ }
736
+ }
737
+ ```
738
+
739
+ Three equivalent invocation forms: `tdb-mcp` (the dedicated entry
740
+ point), `tdb --mcp` (the main CLI with the `--mcp` switch), and
741
+ `python -m tdb.mcp` (module form). Pick whichever matches how your MCP
742
+ client expects to launch servers.
743
+
744
+ ### Tool surface (16 tools, curated)
745
+
746
+ | Cluster | Tools |
747
+ |---------|-------|
748
+ | Lifecycle | `debug_launch`, `debug_attach`, `quit` |
749
+ | Control | `control(action, timeout_s=30)` — `action ∈ {continue, next, step_in, step_out, pause, wait_for_stop}` |
750
+ | Inspection | `inspect(expressions)`, `read_source(file_path)`, `stack_trace()`, `status()`, `get_output()` |
751
+ | Breakpoints | `set_breakpoint(spec, condition?, hit_condition?)`, `remove_breakpoint(spec)`, `list_breakpoints()` |
752
+ | Differentiators | `threads(thread_id?)`, `tasks(task_name?)`, `processes(name_or_pid?)`, `wait_graph()` |
753
+
754
+ `control` is intentionally one tool that takes an action enum — the six
755
+ underlying RPC actions share a return shape, and agents perform
756
+ measurably better with a small surface than with one tool per action.
757
+ `threads` / `tasks` / `processes` overload list-vs-inspect via a
758
+ single optional argument for the same reason.
759
+
760
+ ### Agent flow for a long-running step
761
+
762
+ ```
763
+ agent → control(action="continue", timeout_s=30)
764
+ mcp → "still running — call pause or wait again"
765
+ agent → control(action="pause") # OR: control(action="wait_for_stop", timeout_s=30)
766
+ mcp → "<file>:<line>"
767
+ agent → inspect(["x", "len(buf)"])
768
+ mcp → "x = 7\nlen(buf) = 1024"
769
+ ```
770
+
771
+ `pause` bypasses the dispatch lock so it can interrupt a `continue`
772
+ that's still mid-flight (HTTP and MCP share the same `NO_LOCK_ACTIONS`
773
+ policy — see `tdb/server/app.py`).
774
+
775
+ ### Security caveat
776
+
777
+ `inspect` calls debugpy's `evaluate`, which is **arbitrary Python
778
+ execution in the debuggee process**. This is inherent to a debugger and
779
+ not a tdb-specific concern, but MCP clients (and the humans running
780
+ them) should apply appropriate permission models: don't auto-approve
781
+ `inspect` against untrusted expressions, and don't expose `tdb-mcp` on
782
+ a network — stdio transport only by design.
783
+
784
+ ### Deferred / out of scope (v1)
785
+
786
+ - SSE-style event push — `control` and `wait_for_stop` make polling
787
+ efficient enough; events would also need uneven MCP-client support.
788
+ - HTTP / streamable-HTTP transports — would require auth (which the
789
+ HTTP RPC server also currently lacks); stdio inherits the trust of
790
+ the process that spawned it.
791
+ - Multi-session — one debug session per MCP process.
792
+
709
793
  ## CLI Reference
710
794
 
711
795
  ```
@@ -1,6 +1,6 @@
1
1
  from tdb.breakpoint_hook import breakpoint
2
2
  from tdb.post_mortem import exception_hook
3
3
 
4
- __version__ = "0.1.3"
4
+ __version__ = "0.1.4"
5
5
 
6
6
  __all__ = ["breakpoint", "exception_hook"]
@@ -20,18 +20,11 @@ from __future__ import annotations
20
20
  import logging
21
21
  import os
22
22
  import re
23
- from pathlib import Path
24
23
  from typing import TYPE_CHECKING
25
24
 
26
25
  from tdb.dap.types import Source, StackFrame
27
- from tdb.inspection import (
28
- PROCESS_COLLECT_EXPR,
29
- ProcessInfo,
30
- TASK_COLLECT_EXPR,
31
- TASK_LOCALS_EXPR,
32
- parse_process_json,
33
- parse_task_json,
34
- )
26
+ from tdb.inspection import ProcessInfo
27
+ from tdb.session.inspect_service import InspectService, SessionGateError
35
28
  from tdb.widgets.async_tasks_modal import AsyncTasksModal
36
29
  from tdb.widgets.menu_bar import MenuBar
37
30
  from tdb.widgets.processes_modal import ProcessesModal
@@ -48,10 +41,17 @@ log = logging.getLogger(__name__)
48
41
 
49
42
 
50
43
  class InspectionWorkflows:
51
- """Drives the threads/processes/async-tasks UI flows."""
44
+ """Drives the threads/processes/async-tasks UI flows.
45
+
46
+ Fetch-and-parse mechanics live in `tdb.session.inspect_service.
47
+ InspectService` (shared with the JSON-RPC server); this collaborator
48
+ holds only the TUI presentation around them — gates rendered as
49
+ toasts, modal lifecycle, menu-bar labels.
50
+ """
52
51
 
53
52
  def __init__(self, app: TdbApp) -> None:
54
53
  self.app = app
54
+ self._svc = InspectService(lambda: self.app.controller)
55
55
 
56
56
  # --- Async tasks ----------------------------------------------------
57
57
 
@@ -82,19 +82,15 @@ class InspectionWorkflows:
82
82
  if ctrl.state.is_running:
83
83
  self.app.notify("Program is running — pause first", title="Async Tasks")
84
84
  return
85
- raw = ""
86
85
  try:
87
- raw = await ctrl.evaluate(TASK_COLLECT_EXPR)
88
- tasks = parse_task_json(raw)
86
+ tasks = await self._svc.collect_tasks()
87
+ except SessionGateError:
88
+ return # phase changed between our gate and the fetch
89
89
  except Exception:
90
90
  log.exception("Error fetching async tasks")
91
91
  tasks = []
92
92
 
93
93
  if not tasks:
94
- log.warning(
95
- "Async task collection returned no tasks. Raw: %s",
96
- raw[:300] if raw else "(empty)",
97
- )
98
94
  self.app.notify(
99
95
  "No asyncio tasks found (program may not use asyncio)",
100
96
  title="Async Tasks",
@@ -110,12 +106,10 @@ class InspectionWorkflows:
110
106
  self.app.panels.async_tasks = None
111
107
 
112
108
  async def refresh_async_tasks(self) -> None:
113
- ctrl = self.app.controller
114
- if ctrl.state.is_terminated or ctrl.state.is_running:
115
- return
116
109
  try:
117
- raw = await ctrl.evaluate(TASK_COLLECT_EXPR)
118
- tasks = parse_task_json(raw)
110
+ tasks = await self._svc.collect_tasks()
111
+ except SessionGateError:
112
+ return
119
113
  except Exception:
120
114
  log.exception("Error refreshing async tasks")
121
115
  return
@@ -124,29 +118,15 @@ class InspectionWorkflows:
124
118
 
125
119
  async def load_task_variables(self, task_name: str) -> None:
126
120
  """Fetch a task's local variables via DAP and populate the tree."""
127
- ctrl = self.app.controller
128
- if ctrl.state.is_terminated or ctrl.state.is_running:
129
- return
130
121
  modal = self.app.panels.async_tasks
131
122
  if modal is None:
132
123
  return
133
- variables: list = []
134
124
  try:
135
- expr = TASK_LOCALS_EXPR.format(task_name=task_name)
136
- # Route around synthetic frame ids — see
137
- # controller.resolve_evaluate_frame_id. Without this, after
138
- # the user has navigated to a task once, state.current_frame_id
139
- # is negative and debugpy rejects the evaluate request.
140
- frame_id = await ctrl.resolve_evaluate_frame_id(ctrl.active_client)
141
- _result, var_ref = await ctrl.active_client.evaluate(
142
- expr,
143
- frame_id=frame_id,
144
- context="repl",
145
- )
146
- if var_ref > 0:
147
- variables = await ctrl.active_client.variables(var_ref)
148
- except Exception:
149
- log.debug("Failed to load variables for task %s", task_name)
125
+ # Routes around synthetic frame ids and evaluates against the
126
+ # active client — see InspectService.task_locals.
127
+ variables = await self._svc.task_locals(task_name)
128
+ except SessionGateError:
129
+ return
150
130
  modal.show_task_variables(variables)
151
131
 
152
132
  def navigate_to_task(self, task_name: str) -> bool:
@@ -213,7 +193,9 @@ class InspectionWorkflows:
213
193
  self.app.notify("Program is running — pause first", title="Threads")
214
194
  return
215
195
  try:
216
- threads = await ctrl.client.threads()
196
+ threads = await self._svc.list_threads()
197
+ except SessionGateError:
198
+ return
217
199
  except Exception:
218
200
  log.exception("Error fetching threads")
219
201
  self.app.notify("Failed to fetch threads", title="Threads")
@@ -230,29 +212,16 @@ class InspectionWorkflows:
230
212
 
231
213
  async def load_thread_detail(self, thread_id: int) -> None:
232
214
  """Fetch stack trace and variables for a thread."""
233
- ctrl = self.app.controller
234
- if ctrl.state.is_terminated or ctrl.state.is_running:
235
- return
236
215
  modal = self.app.panels.threads
237
216
  if modal is None:
238
217
  return
239
218
  try:
240
- frames = await ctrl.client.stack_trace(thread_id)
219
+ frames, scopes, variables = await self._svc.thread_stack(thread_id)
220
+ except SessionGateError:
221
+ return
241
222
  except Exception:
242
223
  log.debug("Failed to fetch stack trace for thread %d", thread_id)
243
- frames = []
244
- scopes: list = []
245
- variables: dict = {}
246
- if frames:
247
- top_frame = frames[0]
248
- try:
249
- scopes = await ctrl.client.scopes(top_frame.id)
250
- for scope in scopes:
251
- variables[scope.variables_reference] = await ctrl.client.variables(
252
- scope.variables_reference
253
- )
254
- except Exception:
255
- log.debug("Failed to fetch variables for thread %d", thread_id)
224
+ frames, scopes, variables = [], [], {}
256
225
  modal.show_thread_detail(thread_id, frames, scopes, variables)
257
226
 
258
227
  # --- Full-Contents (variable subtree pre-fetch) --------------------
@@ -315,55 +284,17 @@ class InspectionWorkflows:
315
284
 
316
285
  # --- Processes ------------------------------------------------------
317
286
 
318
- def get_processes_from_pids(self) -> list[ProcessInfo]:
319
- """Build ProcessInfo list from tracked child PIDs via /proc.
287
+ async def get_processes(self) -> list[ProcessInfo]:
288
+ """Get child process info, trying eval on parent first, then /proc.
320
289
 
321
- Linux-only fallback used when DAP `evaluate` against the parent
322
- is unavailable (e.g., the parent isn't the active process).
290
+ Maps a gate failure (phase changed mid-flight) to an empty list:
291
+ callers treat "nothing to show" and "can't look right now" the
292
+ same way.
323
293
  """
324
- result = []
325
- for pid in self.app.controller.get_child_pids():
326
- try:
327
- cmdline_path = Path(f"/proc/{pid}/cmdline")
328
- if not cmdline_path.exists():
329
- continue
330
- cmdline = (
331
- cmdline_path.read_bytes().replace(b"\x00", b" ").decode().strip()
332
- )
333
- status_path = Path(f"/proc/{pid}/status")
334
- name = f"Process-{pid}"
335
- if status_path.exists():
336
- for line in status_path.read_text().splitlines():
337
- if line.startswith("Name:"):
338
- name = line.split(":", 1)[1].strip()
339
- break
340
- result.append(
341
- ProcessInfo(
342
- name=name,
343
- pid=pid,
344
- alive=True,
345
- exitcode=None,
346
- daemon=False,
347
- target=cmdline[:200] if cmdline else "unknown",
348
- args="()",
349
- kwargs="{}",
350
- start_method="",
351
- )
352
- )
353
- except Exception:
354
- pass
355
- return result
356
-
357
- async def get_processes(self) -> list[ProcessInfo]:
358
- """Get child process info, trying eval on parent first, then /proc."""
359
294
  try:
360
- raw = await self.app.controller.evaluate_on_parent(PROCESS_COLLECT_EXPR)
361
- processes = parse_process_json(raw)
362
- if processes:
363
- return processes
364
- except Exception:
365
- pass
366
- return self.get_processes_from_pids()
295
+ return await self._svc.collect_processes()
296
+ except SessionGateError:
297
+ return []
367
298
 
368
299
  async def fetch_process_count(self) -> None:
369
300
  """Update the Processes label with child process count."""
@@ -470,25 +401,10 @@ class InspectionWorkflows:
470
401
  modal = self.app.panels.processes
471
402
  if modal is None:
472
403
  return
473
- child = self.app.controller.get_child_client(pid)
474
- if child is None:
404
+ detail = await self._svc.process_stack(pid)
405
+ if detail is None:
475
406
  # PIDs from /proc might not exactly match the controller's keys
476
407
  modal.show_process_detail(pid, [], [], {})
477
408
  return
478
- frames: list = []
479
- scopes: list = []
480
- variables: dict = {}
481
- try:
482
- threads = await child.threads()
483
- if threads:
484
- frames = await child.stack_trace(threads[0].id)
485
- if frames:
486
- top_frame = frames[0]
487
- scopes = await child.scopes(top_frame.id)
488
- for scope in scopes:
489
- variables[scope.variables_reference] = await child.variables(
490
- scope.variables_reference
491
- )
492
- except Exception:
493
- log.debug("Failed to fetch detail for child process %d", pid)
409
+ frames, scopes, variables = detail
494
410
  modal.show_process_detail(pid, frames, scopes, variables)
@@ -9,14 +9,9 @@ from pathlib import Path
9
9
 
10
10
 
11
11
  def _get_version() -> str:
12
- try:
13
- from importlib.metadata import version
14
-
15
- return version("textual-debugger")
16
- except Exception:
17
- from tdb import __version__
12
+ from tdb import __version__
18
13
 
19
- return __version__
14
+ return __version__
20
15
 
21
16
 
22
17
  def build_parser() -> argparse.ArgumentParser:
@@ -141,6 +136,14 @@ def build_parser() -> argparse.ArgumentParser:
141
136
  action="store_true",
142
137
  help="Run as a headless JSON-RPC debug server (no TUI)",
143
138
  )
139
+ parser.add_argument(
140
+ "--mcp",
141
+ action="store_true",
142
+ help="Run as a Model Context Protocol (MCP) server over stdio. "
143
+ "Equivalent to `python -m tdb.mcp`. The session is owned by the "
144
+ "MCP client (via debug_launch / debug_attach tools); no program "
145
+ "argument is needed on the CLI.",
146
+ )
144
147
  parser.add_argument(
145
148
  "-k",
146
149
  "--breakpoint",
@@ -417,7 +420,7 @@ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
417
420
  args = parser.parse_args(argv)
418
421
  _apply_flag_implications(args)
419
422
 
420
- if args.doc or args.doc_text or args.post_mortem:
423
+ if args.doc or args.doc_text or args.post_mortem or args.mcp:
421
424
  return args
422
425
 
423
426
  _validate_terminal_choice(args, parser)
@@ -461,12 +464,21 @@ def main(argv: list[str] | None = None) -> None:
461
464
  _run_doc_text()
462
465
  elif args.post_mortem:
463
466
  _run_post_mortem(args)
467
+ elif args.mcp:
468
+ _run_mcp()
464
469
  elif args.headless:
465
470
  _run_headless(args)
466
471
  else:
467
472
  _run_tui(args)
468
473
 
469
474
 
475
+ def _run_mcp() -> None:
476
+ """Launch the MCP server over stdio. Mirrors `python -m tdb.mcp`."""
477
+ from tdb.mcp.server import main as mcp_main
478
+
479
+ mcp_main()
480
+
481
+
470
482
  def _run_doc() -> None:
471
483
  """Display the bundled README.md in a Textual MarkdownViewer."""
472
484
  from tdb.app_helpers import find_readme