jdwp-mcp 0.20.0

MCP server for Java debugging via JDWP protocol
jdwp-mcp-0.20.0 is not a library.

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:true on 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 with debug.get_traces.
  • Freeze one thread, not the VM. debug.suspend_thread holds 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.panic does it on demand; debug.disconnect can never leave a JVM frozen.
  • Read-only sessions. JDWP_READONLY=1 refuses everything that would execute or install code โ€” enforced at the JDWP boundary, so the indirect paths (toString() rendering, Map subscripts, 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], and lines[?qty > 3] filters with the left side resolved against each element. A filter reports N 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, ConcurrentHashMap or ArrayList is answered by walking the collection's own fields instead of invoking get() in the debuggee โ€” so the commonest cache question works under read_only on 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:true walks nested objects, arrays and List/Set/Map/ Optional contents 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

java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar myapp.jar

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/$tag
curl -LO "$base/jdwp-mcp-$tag-linux-x86_64" && curl -LO "$base/SHA256SUMS"
sha256sum --ignore-missing -c SHA256SUMS   # macOS: shasum -a 256 -c SHA256SUMS --ignore-missing
chmod +x "jdwp-mcp-$tag-linux-x86_64"

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:

cargo install jdwp-mcp     # binary at ~/.cargo/bin/jdwp-mcp

Or build from a clone โ€” same 1.85 floor:

cargo build --release   # binary at target/release/jdwp-mcp

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
claude mcp add --scope project jdwp /path/to/jdwp-mcp

--scope project makes the debugger available only in this project. Manual configuration via .mcp.json works too:

{
  "mcpServers": {
    "jdwp": {
      "command": "/path/to/jdwp-mcp",
      "env": { "JDWP_READONLY": "0", "JDWP_WATCHDOG_SECS": "120" }
    }
  }
}

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:

kubectl port-forward pod/my-app-pod 5005:5005

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 Response entity 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 with debug.set_method_exit_stop. The same goes for any one-shot stream.
  • debug.list_instances looks 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