Skip to content

Latest commit

 

History

History
273 lines (188 loc) · 18.5 KB

README.md

File metadata and controls

273 lines (188 loc) · 18.5 KB

Python Debugging with Debug MCP Server

The Debug MCP Server provides support for Python debugging through the debugpy library. This document explains how to use the Python debugging capabilities.

Tool calls below are written the way the rest of these docs write them: the tool name followed by its JSON arguments — the shape your MCP client sends.

Prerequisites

Before using the Python debugging features, ensure you have:

  1. Python 3.7 or higher installed
  2. The debugpy package installed:
    pip install debugpy
    Verify with python -m debugpy --version.

The interpreter is auto-detected from PATH. To pin a specific one, set the PYTHON_PATH environment variable (PYTHON_EXECUTABLE is checked as a fallback), or pass executablePath in create_debug_session.

Use absolute paths. In host mode relative file/scriptPath values are rejected; in container mode paths are resolved against the /workspace/ mount.

Debugging Workflow

1. Create a Debug Session

First, create a Python debug session:

create_debug_session { "language": "python", "name": "My Python Debug Session" }

This returns a session ID that you'll use for all subsequent debugging commands.

2. Set Breakpoints

Set breakpoints in your code before starting execution:

set_breakpoint { "sessionId": "your-session-id", "file": "/abs/path/script.py", "line": 10 }

"verified": false in the response is normal for Python — debugpy verifies breakpoints asynchronously once it loads the module. Set them anyway and start; they bind and hit. Do not retry-loop on the flag.

Three sturdier ways to address a breakpoint than a bare line number:

# By content, like an Edit-tool match — survives file edits across restart_debugging
set_breakpoint { "sessionId": "...", "file": "/abs/path/script.py", "statement": "total = price * qty" }

# Line plus an assertion — if the text is not on that line the breakpoint is NOT set,
# and the error shows the line's actual content
set_breakpoint { "sessionId": "...", "file": "/abs/path/script.py", "line": 10,
                 "expectedContent": "total = price" }

# By symbol — no file and no line at all (function goes alone, optionally with condition)
set_breakpoint { "sessionId": "...", "function": "compute_total" }

Conditional breakpoints and logpoints:

set_breakpoint { "sessionId": "...", "file": "/abs/path/script.py", "line": 15, "condition": "x > 5" }
set_breakpoint { "sessionId": "...", "file": "/abs/path/script.py", "line": 15, "logMessage": "x={x} y={y}" }

A logpoint never pauses: it interpolates the {expressions} and writes the line to get_output while the program runs at full speed.

3. Start Debugging

Start debugging your Python script:

start_debugging { "sessionId": "your-session-id", "scriptPath": "/abs/path/script.py",
                  "args": ["--optional", "arguments"] }

Optional tuning:

  • dapLaunchArgs: { "stopOnEntry": true } pauses on the first line (the default is false); { "justMyCode": false } steps into library code (the default is true).
  • Module launch: dapLaunchArgs: { "module": "pytest", "cwd": "/abs/project" } with args: ["tests/test_billing.py::test_rounding", "-x"] runs python -m pytest … under the debugger instead of scriptPath (issue #985). debugpy takes exactly one of program, module and code, so the launch goes out without program, and the answer's warning says so (dapLaunchArgs.module: launched as a module (python -m); program is not sent …). scriptPath still has to name an existing file — the test file, or the package's __main__.py — and cwd is what -m puts on sys.path: it defaults to the directory above the top-most package containing scriptPath (so python -m modpkg from modpkg/__main__.py resolves), and the warning names the directory chosen; set it explicitly — the project root for a pytest run — when the module lives elsewhere. code (python -c) works the same way.
  • breakOnExceptions: launch sessions default to "uncaught", so a crashing script pauses at the crash site with stack and locals intact instead of terminating the session. "all" also pauses on caught/raised exceptions; "none" opts out. Python edge case: debugpy treats sys.exit(n) with a non-zero code as an unhandled SystemExit and pauses there (sys.exit(0) runs to completion) — pass "none" if your script legitimately exits non-zero that way.
  • dryRunSpawn: true validates the spawn configuration without starting anything, and leaves the session exactly as it was — a running program included (issue #812); the same session then takes the real launch.

Debug a failing test in one call (pytest preset)

The most common entry point is a failing test, and translating it into a debug launch takes runner knowledge you should not need. testRunner: "pytest" does it (issue #985):

start_debugging { "sessionId": "...", "scriptPath": "/abs/project/tests/test_billing.py",
                  "testRunner": "pytest", "testTarget": "test_proration_rounding" }

No breakpoint is needed. The answer is the autopsy: state: "paused" at the failing assert, with snapshot.exception (AssertionError, pytest's rewritten explanation as the message), snapshot.frames and snapshot.locals (the test's variables, plus pytest's @py_assert* rewriting temporaries). A passing test runs to completion with exit code 0. testTarget is a selector inside the file — test_name, Class::test_name, test_name[param] — or a full node id of that file; omit it to run the whole file (-x stops at the first failure).

What the preset does, measured in spike-notes-pytest.md:

  • Runs python -m pytest <file>[::<target>] -x from pytest's rootdir — the nearest ancestor of the test file holding pytest.ini, a pyproject.toml with a [tool.pytest.ini_options] table, a tox.ini with [pytest] or a setup.cfg with [tool:pytest], else the nearest setup.py (pytest's own rule), else the top-most conftest.py ancestor, else the file's directory. dapLaunchArgs.cwd overrides it; your args are appended as pytest arguments.
  • Turns pytest-xdist and pytest-cov off for the run: -p no:xdist / -p no:cov when nothing asks for them (silent when the plugin is absent), or the plugin's inert switch — -n0 / --no-cov — when the project's own addopts turns it on (-p no: would make pytest reject those options with a usage error); your args asking for a plugin leave it alone. The PYTEST_ADDOPTS environment variable is not read: a project that enables a plugin there should name it in args. xdist's workers are child debug sessions whose stop only wait_for_stop reports, not the launch answer this preset promises (and the spawn used to fail under the Windows debuggee, issue #993); coverage's tracer competes with the debugger's on Pythons before 3.12.
  • Arms debugpy's userUnhandled filter beside uncaught under the breakOnExceptions default — pytest catches every test failure, so uncaught alone never fires on one. "all" and "none" behave as usual ("none" lets the failing test run to exit code 1). Any dapLaunchArgs: { "module": "pytest" } launch gets the same filters.
  • Where it stops: the failing assert in the test frame; for an exception raised in a helper, the raise site is the top frame with its locals readable and the live test frame below it, marked (Current frame); a fixture that raises pauses in the fixture; pytest.skip() pauses once (Skipped, continue to move on); a decorated test lands on its assert. An exception pytest.raises expects never pauses under pydevd's sys.monitoring path — Python 3.12+ with debugpy 1.8.6 or later — which only sees an exception leave the test frame; under frame tracing — 3.11 and older, or an older debugpy on any Python (Ubuntu 24.04's packaged 1.8.0, say) — it pauses once at the raise — continue, and the test passes. A print before the assert reaches get_output with pytest's report after the run, not before the pause — pass args: ["-s"] for live prints.
  • restart_debugging replays the preset; dryRunSpawn: true answers data.script: "module: pytest" with the expansion in warning. A runner Python has no preset for is refused by name before anything starts.

4. Control Execution

When execution pauses at a breakpoint, you can:

Step Over (execute current line and pause at next line)

step_over { "sessionId": "your-session-id" }

Step Into (go into functions called on current line)

step_into { "sessionId": "your-session-id" }

Step Out (run until exiting current function)

step_out { "sessionId": "your-session-id" }

Continue (run until next breakpoint)

continue_execution { "sessionId": "your-session-id" }

Pause (pause a running program)

pause_execution { "sessionId": "your-session-id" }

5. Examine Program State

When paused, you can examine the program's state using the get_stack_trace -> get_scopes -> get_variables sequence. Each step returns numeric handles that feed into the next:

Step 1: Get the Stack Trace

get_stack_trace { "sessionId": "your-session-id" }

This returns stack frames, each with a numeric id (the frame ID). The top frame's id is not necessarily 0.

Step 2: Get Scopes for a Frame

Use the id from the top stack frame:

get_scopes { "sessionId": "your-session-id", "frameId": 3 }

This returns scopes (e.g., "Locals", "Globals"), each with a numeric variablesReference.

Step 3: Get Variables for a Scope

Use the variablesReference from a scope (not the frame ID):

get_variables { "sessionId": "your-session-id", "scope": 5 }

The scope parameter is the numeric variablesReference from get_scopes. Frame IDs and variable references are different numbers — never swap them.

debugpy may return a special variables entry inside a Locals scope. That is a container, not a variable: call get_variables again with scope set to its variablesReference to expand it and reveal the real locals.

Shortcut: Get Local Variables

For convenience, get_local_variables performs the full stack->scopes->variables traversal in a single call and filters out debugpy's special/internal entries (pass includeSpecial: true to keep them):

get_local_variables { "sessionId": "your-session-id" }

Evaluate Expressions

evaluate_expression { "sessionId": "your-session-id", "expression": "x + y * 2" }

Get Source Context

get_source_context { "sessionId": "your-session-id", "file": "/abs/path/script.py",
                     "line": 15, "linesContext": 5 }

6. Read Program Output

Python launches run with debugpy's redirectOutput, so print() and stderr are captured:

get_output { "sessionId": "your-session-id" }
get_output { "sessionId": "your-session-id", "since": 42 }

Output is buffered per launch and readable while the program runs and after it exits, until the session closes. Pass since = the previous response's nextSince to fetch only new entries. The same transcript is also exposed as the MCP resource debug://sessions/{sessionId}/output, which supports resources/subscribe.

7. Close the Session

When finished debugging, close the session:

close_debug_session { "sessionId": "your-session-id" }

Managing Breakpoints

Breakpoints have a lifecycle beyond set_breakpoint. All three tools below take effect immediately while the program is running or paused, and also work before launch and after the program has exited:

list_breakpoints   { "sessionId": "..." }                    # add "file" to scope to one file
remove_breakpoint  { "sessionId": "...", "breakpointId": "..." }
remove_breakpoint  { "sessionId": "...", "file": "/abs/path/script.py", "line": 15 }
remove_breakpoint  { "sessionId": "...", "function": "compute_total" }
clear_breakpoints  { "sessionId": "..." }                    # add "file" to scope to one file

list_breakpoints reports each breakpoint's verified state and adapter-assigned id; session-global function breakpoints appear separately as functionBreakpoints.

The Edit-Rerun Loop

restart_debugging terminates the current program (if it is still running) and relaunches it with the same configuration as the last start_debugging, re-applying all current breakpoints:

restart_debugging { "sessionId": "your-session-id" }

That turns "fix a line -> rerun -> land on the same breakpoints" into a single call. Two notes: the get_output buffer starts fresh (read from since: 0 after a restart), and restart_debugging is rejected for attach sessions.

Statement-anchored breakpoints (see above) are what make this loop robust — they re-resolve against the edited file instead of drifting when your edit shifts line numbers.

Attach Mode

Python attach is port-only: mcp-debugger connects directly to a listening debugpy endpoint. A processId — alone or beside a port — is refused by name before anything starts, with the --listen recipe (issue #903).

Start the target yourself:

python -m debugpy --listen 127.0.0.1:5678 --wait-for-client script.py

--wait-for-client blocks the script until a debugger attaches — use it for anything short-lived. Then:

create_debug_session { "language": "python" }
attach_to_process    { "sessionId": "...", "host": "127.0.0.1", "port": 5678 }
set_breakpoint       { "sessionId": "...", "file": "/abs/path/script.py", "line": 20 }
continue_execution   { "sessionId": "..." }

Shorthand: passing port to create_debug_session (or any attach target attach_to_process takes) creates the session and attaches in one call, with the same arguments.

After the handshake the attach is verified by polling for threads; raise verifyTimeout (default 20000 ms) for slow targets. breakOnExceptions applies no language default on attach — set it explicitly if you want it. detach_from_process { "sessionId": "...", "terminateProcess": false } leaves the target running. The target's Python children become child debug sessions of the attach session — the target decides at listen time: started with --configure-subProcess false (or debugpy.configure(subProcess=False)), they run undebugged — and close_debug_session or detach_from_process on the attach session releases them with it; the target and its children run on.

Remote targets and path mapping

Breakpoint paths are sent verbatim to the remote debugger and resolved against the target's filesystem. For a container or pod, use debuggee-side paths (/app/app.py, as reported by get_stack_trace), or a function breakpoint, which needs no paths at all.

To address breakpoints by local-checkout path instead, pass debugpy's native mapping shape through adapterConfig (issue #450):

attach_to_process { "sessionId": "...", "host": "127.0.0.1", "port": 5678,
                    "adapterConfig": { "pathMappings": [
                      { "localRoot": "/abs/local/src", "remoteRoot": "/app" }] } }

Frames then report local paths too. Caveats:

  • The ptvsd-era bare localRoot/remoteRoot keys are not forwarded — pathMappings is the one lever. The attach response's warning names any adapterConfig keys that were ignored.
  • host and port must stay top-level; debugpy rejects a config carrying both connect and top-level host/port.

For the Kubernetes port-forward pattern and per-language presets, see the Kubernetes debugging recipe and the attach presets.

Secret Redaction

Variable values, captured output, and evaluate results are scanned for credentials before they reach the transcript: values with a recognizable secret shape, and variables whose name exactly matches a known sensitive name (password, api_key, ...), come back masked and flagged redacted: true. Only the display is masked — program state is untouched. Start the server with DEBUG_MCP_NO_REDACT=1 to disable it (for example when debugging credential-handling code itself). Full rules and limitations: Secret redaction.

Debugging Tips

  1. "verified": false at set time is expected — debugpy binds breakpoints when it loads the module, not when you set them
  2. If the debugger doesn't stop at a breakpoint, ensure the file path is correct and absolute, and that the line is an executable statement (not a comment, a blank line, or a bare def/class line)
  3. Use source context to see code around your current position
  4. The stack trace shows the call hierarchy that led to the current position
  5. Expressions are evaluated against the current paused debug context (current frame when available), with the evaluation context defaulting to 'variables' — reads and arithmetic are reliable, but verify a mutation such as x = 5 with a follow-up evaluate before relying on it
  6. Child processes are debug sessions by default: debugpy's sub-process debugging is on (the last step of #1081), so every Python child the program starts (subprocess, multiprocessing, pytest-xdist workers, an autoreloader, a script that shells out to Python) becomes a child debug session of its own: list_debug_sessions lists it with parentSessionId naming this session, it stops on the breakpoints set on this session before launch (re-sent to it before it runs), and it has its own get_output and close_debug_session; closing the parent closes its children first. A wait_for_stop on this session is a wait on the tree: it reports a child's stop with sessionId naming the child (inspect, step and continue with that id) and children listing every child session with its state; a child can still be waited on by its own id. Each child parks until it is adopted (about 110 ms from report to its first stop), is a session row, and counts against DEBUG_MCP_MAX_CHILD_SESSIONS (default 32 live children per tree; beyond it a child is released and runs undebugged). Opt out per launch with dapLaunchArgs: { subProcess: false } (the children run undebugged and the launch answer's warning says so), per attach target with python -m debugpy --listen … --configure-subProcess false (or debugpy.configure(subProcess=False) in code — the attach request's subProcess is inert), or server-wide with DEBUG_MCP_MAX_CHILD_SESSIONS=0. The design is the session tree, built on the handshake measured in spike-notes-subprocess.md