jdwp-mcp
Java debugging for LLMs via JDWP and Model Context Protocol
An MCP server that lets Claude Code and other LLM tools debug Java applications over the Java Debug Wire Protocol. Attach to a running JVM โ or launch one โ set stop points, read live objects, evaluate expressions, step, and hot-swap code, all in natural language.
It speaks JDWP natively in Rust, so it is one self-contained binary: no JDK, no JDI, no agent to install in the target.
๐ Tool reference ยท ๐ Development ยท โ๏ธ Compared with the other Java debugging MCP servers
What it is good at
It is built for a JVM you are not allowed to freeze. That constraint shapes everything else: a
shared app server serving other people's requests, reached over a kubectl port-forward, where the
usual debugger move โ suspend everything and poke around โ is an outage.
- Non-suspending trace mode.
trace:trueon a breakpoint, exception stop, watchpoint or method-exit stop snapshots the hit and resumes the thread immediately. Each snapshot carries the calling chain above it, so a logpoint answers which path reached this. Read them withdebug.get_traces. - Freeze one thread, not the VM.
debug.suspend_threadholds a single worker and leaves the rest serving โ enough for the whole stack, locals, field chains and deep object expansion. - A watchdog that undoes your mistake. A VM or thread left suspended too long is auto-resumed
(
JDWP_WATCHDOG_SECS, default 120) and whatever froze it is disabled, so it cannot re-freeze on the next hit.debug.panicdoes it on demand;debug.disconnectcan never leave a JVM frozen. - Read-only sessions.
JDWP_READONLY=1refuses everything that would execute or install code โ enforced at the JDWP boundary, so the indirect paths (toString()rendering,Mapsubscripts, breakpoint conditions) are covered too. - Costs are measured and reported, never guessed. A thread dump says how long it held the VM and how many packets it spent. A traced stop point reports its own capture cost on your JVM. A heap query states the pause it imposed. A budget that truncates says what it dropped.
Expression evaluation that resolves like javac does. localVar / this / Class / @0xโฆ
heads with .field and .method(args) chains, including static members. Overloads resolve on the
arguments' runtime types โ interfaces walked transitively, autoboxing applied, and an argument a
parameter cannot accept is refused rather than handed to the JVM. Arguments may be literals or
expressions passed by reference (svc.matches(reserva)).
- Collections as first-class syntax โ
lines[0],counts["key"],lines[2..5], andlines[?qty > 3]filters with the left side resolved against each element. A filter reportsN of M matched, so an empty result is distinguishable from an unscanned one. - Reads that need no suspended thread. A subscript, slice or filter on a
HashMap,LinkedHashMap,ConcurrentHashMaporArrayListis answered by walking the collection's own fields instead of invokingget()in the debuggee โ so the commonest cache question works underread_onlyon a JVM you must not freeze. Any other implementation falls back to invoking, and the reply says which path it took. - Deep expansion โ
expand_objects:truewalks nested objects, arrays andList/Set/Map/Optionalcontents into a field tree, bounded by depth, breadth and a node budget, with cycle detection and unboxed wrappers. byte[]as text โbyte[73] ISO-8859-1 "<?xml version=โฆ"rather than a list of signed integers, with a trailing#<charset>to pick the reading. Octets that do not decode are marked\xNN, so a wrong charset looks wrong instead of looking like a bug in the payload.
Questions that otherwise cost ten tool calls, answered in one.
| The question | The tool |
|---|---|
| Which link in this chain went null? | debug.evaluate_chain โ names the first null, values above it, and how many links it never reached |
What did this method return, and from which return? |
debug.set_method_exit_stop |
| Who changes this field behind my back? | debug.set_field_stop โ reports the mutating location with old โ new |
| Requests are hanging โ which threads are blocked on what? | debug.set_monitor_stop (live, no suspend) or debug.thread_dump (names the lock owner) |
| Is this JVM even running the code I compiled? | debug.check_stale โ compares line tables method by method |
| Where does this object live if nothing on the stack names it? | debug.list_instances โ live objects of a type as @0xโฆ handles |
Does this @NamedQuery return what its author believes? |
debug.run_named_query โ through the app's own EntityManager, without the flush it would have caused |
An exception hit reports its message. On JDK 15+ that is frequently the whole diagnosis: the JVM
has already computed because the return value of "X.getY()" is null, naming the failing
subexpression a hand-run bisect would have taken three calls to find. On a framework that rethrows,
the sightings of one instance are folded โ original throw and escape point kept, the plumbing
between them a count.
Hot reload, and the frame rewind that makes it work. debug.reload_class installs freshly
compiled bytecode into the running JVM โ no redeploy, no restart, warm state intact โ and a request
suspended at a breakpoint survives the fix: swap the method, debug.pop_frame, debug.continue, and
it re-runs with the new code. HotSpot accepts method bodies only, and each of the twelve ways it can
refuse is turned into what to do next instead of a bare error code.
Stepping that lands on your code. step_into skips the JDK and the container by default
(java.*, jakarta.*, org.jboss.*, io.undertow.*, org.hibernate.*, โฆ), so you arrive at the
next line of your method rather than inside a Weld proxy. exclude_classes:[] restores the
unfiltered behaviour, only_classes is the inverse, and every step reply says which was in force.
Discovery, for when you do not know the name yet. debug.list_classes shows what the debuggee has
actually loaded โ the only way to find a generated proxy or a shaded class. debug.list_methods and
debug.list_fields render signatures as Java source types with generics. debug.source settles
whether your checkout is the code that is running.
40 tools in total โ see the tool reference.
Install
1. Start your Java app with JDWP enabled
Or skip this and let debug.launch start it for you โ which is the only way to break on code that
runs during startup, since it holds the VM before its first instruction.
2. Get the server
Download a prebuilt binary โ no Rust toolchain needed โ from the latest release:
| Platform | Asset |
|---|---|
| Linux x86_64 | jdwp-mcp-<tag>-linux-x86_64 |
| macOS (Apple Silicon) | jdwp-mcp-<tag>-macos-aarch64 |
| macOS (Intel) | jdwp-mcp-<tag>-macos-x86_64 |
| Windows x86_64 | jdwp-mcp-<tag>-windows-x86_64.exe |
The Linux build is statically linked against musl, so it runs on any x86_64 Linux whatever the
distribution's glibc โ including an app server older than the machine you downloaded it on. Every
release ships a SHA256SUMS covering all four assets:
tag=v0.17.0
base=https://github.com/YgorPerez/java-debugging-mcp/releases/download/
&&
The macOS binaries are unsigned, so the first run needs xattr -d com.apple.quarantine <file> or
Settings โ Privacy & Security โ "Open Anyway".
Or install from crates.io โ needs Rust 1.85 or newer, and compiles from source, so budget a few minutes the prebuilt binaries above do not cost you:
Or build from a clone โ same 1.85 floor:
The floor is 1.85 because it was measured rather than declared (BUILD-2): 1.82 and 1.83 fail on this workspace's own code, and 1.84 fails at dependency resolution before any of it is compiled.
3. Configure Claude Code
# From your Java project directory
--scope project makes the debugger available only in this project. Manual configuration via
.mcp.json works too:
JDWP_CLASS_ROOTS and JDWP_SOURCE_ROOTS are worth setting too โ they are what debug.check_stale,
debug.reload_class and debug.source read, and without a class root the arm-time staleness check has
nothing to compare against, so every stop point you arm says Staleness NOT CHECKED instead of vouching
for the line it just resolved (DISC-14).
Use it
> Attach to the JVM at localhost:5005
> Set a breakpoint at com.example.HelloController line 65
> When it hits, show me the stack and the value of requestCount
On a shared instance, ask for trace mode instead of a suspending stop point:
> Trace com.example.OrderService.submit without suspending, and show me the caller chain
> Read the traces
For a Kubernetes-deployed app, forward the JDWP port first:
Most tools take thread_id as an optional hex string (e.g. "0x2"); when omitted they default to the
last thread that hit a breakpoint. Every tool takes an optional session_id โ debug.attach can hold
several JVMs at once and debug.list_sessions finds one you lost.
Worked examples with captured output are in examples/.
Two things that look like reads and are not
Neither is caught by read_only, which is why they are written down rather than guarded.
- A JAX-RS
Responseentity is single-pass.response.readEntity(String.class)consumes it, and the application's own read afterwards gets an empty body โ you break the thing you were inspecting by inspecting it. Break at or after the assignment to a local instead, or capture the value withdebug.set_method_exit_stop. The same goes for any one-shot stream. debug.list_instanceslooks free and is not. JDWP needs no suspend and none is issued, yet the JVM stops the world for a full live-heap walk โ measured at 522 ms of held application threads on a 2,000,000-object heap to answer with 7 objects. Nothing refuses on size; the reply states the duration it actually held them.
Both are covered in full, with the rest of the shared-instance rules, in the tool reference.
Status
โ Functionally complete โ 38 debug tools, integrated and validated against live JVMs on JDK 11, 17 and 21.
The JDWP client implements the VirtualMachine, ReferenceType, ClassType, Method, ObjectReference,
StringReference, ArrayReference, ThreadReference, StackFrame and EventRequest command sets this needs,
including RedefineClasses, PopFrames, InvokeMethod (instance and static), Instances /
InstanceCounts, and the four MONITOR_* events โ big-endian throughout, so Intel and ARM both work.
Known open issues are tracked as GitHub issues,
which are the authority on open work. docs/adr/ holds the decisions that are settled, and each
release's notes carry the commit body of what shipped and why.
References
License
MIT