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.
Before using the Python debugging features, ensure you have:
- Python 3.7 or higher installed
- The debugpy package installed:
Verify with
pip install debugpy
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.
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.
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.
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 isfalse);{ "justMyCode": false }steps into library code (the default istrue).- Module launch:
dapLaunchArgs: { "module": "pytest", "cwd": "/abs/project" }withargs: ["tests/test_billing.py::test_rounding", "-x"]runspython -m pytest …under the debugger instead ofscriptPath(issue #985). debugpy takes exactly one ofprogram,moduleandcode, so the launch goes out withoutprogram, and the answer'swarningsays so (dapLaunchArgs.module: launched as a module (python -m); program is not sent …).scriptPathstill has to name an existing file — the test file, or the package's__main__.py— andcwdis what-mputs onsys.path: it defaults to the directory above the top-most package containingscriptPath(sopython -m modpkgfrommodpkg/__main__.pyresolves), and thewarningnames 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 treatssys.exit(n)with a non-zero code as an unhandledSystemExitand pauses there (sys.exit(0)runs to completion) — pass"none"if your script legitimately exits non-zero that way.dryRunSpawn: truevalidates 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.
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>] -xfrom pytest's rootdir — the nearest ancestor of the test file holdingpytest.ini, apyproject.tomlwith a[tool.pytest.ini_options]table, atox.iniwith[pytest]or asetup.cfgwith[tool:pytest], else the nearestsetup.py(pytest's own rule), else the top-mostconftest.pyancestor, else the file's directory.dapLaunchArgs.cwdoverrides it; yourargsare appended as pytest arguments. - Turns pytest-xdist and pytest-cov off for the run:
-p no:xdist/-p no:covwhen nothing asks for them (silent when the plugin is absent), or the plugin's inert switch —-n0/--no-cov— when the project's ownaddoptsturns it on (-p no:would make pytest reject those options with a usage error); yourargsasking for a plugin leave it alone. ThePYTEST_ADDOPTSenvironment variable is not read: a project that enables a plugin there should name it inargs. xdist's workers are child debug sessions whose stop onlywait_for_stopreports, 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
userUnhandledfilter besideuncaughtunder thebreakOnExceptionsdefault — pytest catches every test failure, souncaughtalone never fires on one."all"and"none"behave as usual ("none"lets the failing test run to exit code 1). AnydapLaunchArgs: { "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 exceptionpytest.raisesexpects never pauses under pydevd'ssys.monitoringpath — 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. Aprintbefore the assert reachesget_outputwith pytest's report after the run, not before the pause — passargs: ["-s"]for live prints. restart_debuggingreplays the preset;dryRunSpawn: trueanswersdata.script: "module: pytest"with the expansion inwarning. A runner Python has no preset for is refused by name before anything starts.
When execution pauses at a breakpoint, you can:
step_over { "sessionId": "your-session-id" }
step_into { "sessionId": "your-session-id" }
step_out { "sessionId": "your-session-id" }
continue_execution { "sessionId": "your-session-id" }
pause_execution { "sessionId": "your-session-id" }
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:
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.
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.
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.
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_expression { "sessionId": "your-session-id", "expression": "x + y * 2" }
get_source_context { "sessionId": "your-session-id", "file": "/abs/path/script.py",
"line": 15, "linesContext": 5 }
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.
When finished debugging, close the session:
close_debug_session { "sessionId": "your-session-id" }
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.
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.
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.
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/remoteRootkeys are not forwarded —pathMappingsis the one lever. The attach response'swarningnames anyadapterConfigkeys that were ignored. hostandportmust stay top-level; debugpy rejects a config carrying bothconnectand top-level host/port.
For the Kubernetes port-forward pattern and per-language presets, see the Kubernetes debugging recipe and the attach presets.
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.
"verified": falseat set time is expected — debugpy binds breakpoints when it loads the module, not when you set them- 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/classline) - Use source context to see code around your current position
- The stack trace shows the call hierarchy that led to the current position
- 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 asx = 5with a follow-up evaluate before relying on it - 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_sessionslists it withparentSessionIdnaming this session, it stops on the breakpoints set on this session before launch (re-sent to it before it runs), and it has its ownget_outputandclose_debug_session; closing the parent closes its children first. Await_for_stopon this session is a wait on the tree: it reports a child's stop withsessionIdnaming the child (inspect, step and continue with that id) andchildrenlisting 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 againstDEBUG_MCP_MAX_CHILD_SESSIONS(default 32 live children per tree; beyond it a child is released and runs undebugged). Opt out per launch withdapLaunchArgs: { subProcess: false }(the children run undebugged and the launch answer's warning says so), per attach target withpython -m debugpy --listen … --configure-subProcess false(ordebugpy.configure(subProcess=False)in code — the attach request'ssubProcessis inert), or server-wide withDEBUG_MCP_MAX_CHILD_SESSIONS=0. The design is the session tree, built on the handshake measured in spike-notes-subprocess.md