# Mcp Jdwp Java

> MCP server giving AI agents full debugger control over running Java applications — inspect state, set breakpoints, evaluate expressions, and mutate values at runtime via JDWP/JDI

- **Type:** MCP server
- **Install:** `agentstack add mcp-fgforrest-mcp-jdwp-java`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [FgForrest](https://agentstack.voostack.com/s/fgforrest)
- **Installs:** 0
- **Category:** [Developer Tools](https://agentstack.voostack.com/c/developer-tools)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [FgForrest](https://github.com/FgForrest)
- **Source:** https://github.com/FgForrest/mcp-jdwp-java

## Install

```sh
agentstack add mcp-fgforrest-mcp-jdwp-java
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# MCP JDWP Inspector

MCP server that gives AI agents full debugger control over running Java applications — inspect state, set breakpoints, evaluate expressions, and mutate values at runtime via JDWP/JDI.

> **Release notes** — see [`CHANGELOG.md`](CHANGELOG.md). 2.0.0 is a breaking release: several `jdwp_clear_*` tools were unified, and field watchpoints are new.

**Built on the foundations of [mcp-jdwp-java](https://github.com/NicolasVautrin/mcp-jdwp-java) by [Nicolas Vautrin](https://github.com/NicolasVautrin)** — the original project that provided core JDI connectivity, thread/stack/variable inspection, stepping, and basic breakpoint management. Everything described below as "beyond standard JDWP" was built on top of that base.

## What you get (TL;DR)

An MCP server that speaks **JDWP** — the same protocol IntelliJ / Eclipse / VS Code use to attach — and exposes the debugger to Claude Code as **47 tools + 2 MCP resources** over STDIO. Once the agent attaches to your JVM, it can:

- **See runtime state, not stack traces** — read locals, fields, threads, the entire object graph
- **Evaluate arbitrary Java in the suspended frame** — compiled to real bytecode, full classpath, including private/package-private members
- **Mutate the running program** — set locals, write fields, test "what if?" without restart
- **Stop on the bad case only** — conditional breakpoints, exception breakpoints at the throw site, deferred BPs that arm before the class loads, chains that gate one BP on another
- **Trace without stopping** — line/exception/field logpoints write to an event log, the thread runs on
- **Catch the writer of a mutating field** — watchpoints suspend at the JVM-level store, including reflective `Field.set` that a line BP can never see
- **Pure STDIO** — no daemon, no extra port, no GUI

The agent gets the same power as IntelliJ's debugger, with extras that exist specifically because an agent isn't a human staring at a UI (filtering, blocking resume, one-shot context dumps, recursion guards, reconnect after a wedge).

## Security & trust

This MCP server runs **entirely on your local machine**:

- **No network calls** — the server communicates with Claude Code over STDIO and with the target JVM over a local JDWP socket. It makes zero outbound HTTP/internet requests. No telemetry, no analytics, no phone-home.
- **Built from source** — the JAR is compiled locally on your machine from the source code in this repository (either via the plugin auto-build hook or manually with `./mvnw clean package`). No pre-built binaries are downloaded or distributed.
- **Auditable** — the full source is here. The server is a standard Spring Boot application with no obfuscation or native code. Try asking Claude Code itself: *"audit these sources for anything that touches network or filesystem beyond the JVM target"*.

## Quick start

### Prerequisites

- **JDK 17+** on PATH to **run** the server (must be a JDK, not a JRE — JDI lives in `jdk.jdi`).
- **JDK 21+ to build from source** — the build toolchain pins to JDK ≥ 21 because Error Prone 2.48 ships Java-21 bytecode. The bytecode target stays at Java 17, so the resulting JAR still runs on a JDK 17 runtime.

No separate Maven install is required — the repository ships with the Maven Wrapper (`./mvnw`), which downloads a pinned Maven 3.9.x into `~/.m2/wrapper/` on first use. The SessionStart hook and every build command in this README use the wrapper.

### 1. Install the plugin

**Option A: Plugin marketplace (recommended)**

Installs the MCP server, the `java-debug` skill (debugging workflows, recipes, gotchas), and the `.mcp.json` configuration in one step:

```bash
/plugin marketplace add https://github.com/FgForrest/mcp-jdwp-java.git
/plugin install jdwp-debugging@mcp-jdwp-java
```

The server JAR is built automatically on first session start via the bundled `./mvnw` wrapper (requires JDK 17+ on PATH — no Maven install needed). The hook is content-aware: it rebuilds when `git HEAD` moves, not just when the JAR is missing, so `git pull` + restart picks up updates without manual intervention. Restart Claude Code to pick up the plugin.

Alternative: manual MCP registration (without plugin)

If you prefer to register the MCP server directly without the plugin (no skill, no auto-build):

**1. Build the JAR:**

```bash
git clone https://github.com/FgForrest/mcp-jdwp-java.git
cd mcp-jdwp-java
./mvnw clean package -DskipTests   # use mvnw.cmd on Windows
```

**2. Register with Claude Code:**

```bash
claude mcp add jdwp-inspector -s user \
  -e MCP_TIMEOUT=30000 \
  -e MCP_TOOL_TIMEOUT=120000 \
  -- java --add-modules jdk.jdi,jdk.attach -jar /path/to/mcp-jdwp-java.jar
```

To change the JDWP port (default 5005), add `-DJVM_JDWP_PORT=12345` before `-jar`.

The `MCP_TIMEOUT` and `MCP_TOOL_TIMEOUT` environment variables are important — JVM startup is not instant (class loading, Spring context initialization), so the default MCP timeouts will cause Claude Code to give up before the server is ready. `MCP_TIMEOUT=30000` gives the server 30 seconds to start, and `MCP_TOOL_TIMEOUT=120000` allows up to 2 minutes for long-running tools like first-time expression evaluation (which discovers the target's classpath and compiles bytecode).

Re-installing requires removing first: `claude mcp remove jdwp-inspector -s user`

Drop `-s user` to scope to the current project only.

**`.mcp.json`:**

```json
{
  "mcpServers": {
    "jdwp-inspector": {
      "command": "java",
      "args": [
        "--add-modules", "jdk.jdi,jdk.attach",
        "-jar", "/path/to/mcp-jdwp-java.jar"
      ],
      "env": {
        "MCP_TIMEOUT": "30000",
        "MCP_TOOL_TIMEOUT": "120000"
      }
    }
  }
}
```

The plugin-managed install also passes `-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log` so the server's SLF4J log lands inside the plugin directory; add the same flag if you want a fixed log path here.

### 2. Launch your Java application with JDWP

**Maven Surefire (test debugging):**

```bash
mvn test -Dmaven.surefire.debug
```

Starts the JVM with JDWP on port 5005, suspended until a debugger connects.

**Any Java application:**

```
-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005
```

### 3. Attach from Claude Code

From there, just tell the agent what to do — *"attach to JDWP, set a breakpoint in `OrderService.createOrder` line 42, run the failing test, dump the context when it hits"*. The bundled `java-debug` skill teaches the agent the right tool sequence; the next section gives you a smoke test that exercises the surface.

## Find the Bug — test flights

The `jdwp-sandbox` module ships 9 deliberately broken Java classes. Each one compiles fine, looks reasonable at first glance, and **fails its test with a confusing message**. Your job: attach with the JDWP MCP server and find the root cause.

This doubles as a setup verification — if you can solve these, everything works.

Each flight is built so that one tool group is the path of least resistance, and lists a **par** — the minimum tool calls that cleanly reveal the root cause. Hitting par is the elegant solve; the suite as a whole exercises the full tool surface (expression eval, exception breakpoints, field watchpoints, event history, marks, logpoints, runtime mutation, and multi-thread inspection).

### Just installed the plugin? Grab the sandbox zip

The fastest path for fresh plugin users is a self-contained zip — no clone, no reactor build:

```bash
curl -L -o jdwp-sandbox.zip \
  https://github.com/FgForrest/mcp-jdwp-java/releases/latest/download/jdwp-sandbox.zip
unzip jdwp-sandbox.zip && cd jdwp-sandbox
```

Inside, you get a parentless Maven project with `src/`, a `pom.xml`, a flight-game `README.md`, and a `CLAUDE.md` that briefs the agent on the game's house rules. Open the folder in Claude Code and ask it to play flight #1 — the bundled `CLAUDE.md` keeps it honest (no peeking at source, no spoiler-fetching). Continue with the workflow below.

### How to launch a test flight

Start Claude Code from the sandbox folder (or this repo's root, if you cloned) and type:

```
Use JDWP to debug  in the jdwp-sandbox module — the test is failing, find the root cause.
```

---

### #1 The Phantom Session

**Difficulty:** Moderate | **Test:** `SessionStoreTest` | **Package:** `session` | **Par:** 4 | **Exercises:** expression eval

**Symptom:** `retrieve() returned null` — a session was stored, upgraded, and then... vanished from the map.

**Hint:** The session is still *in* the HashMap. The HashMap just can't *find* it anymore.

Reveal root cause

`UserSession` is used as a HashMap key, with both `userId` and `role` in `hashCode()`. `upgradeUserRole()` calls `session.upgradeRole("PREMIUM")`, which mutates `role` — changing the hashCode while the key is still in the map. The entry sits in the old hash bucket; lookups compute the new hash and search the wrong bucket.

**Debug path:** Breakpoint before and after `upgradeRole()`. Use `jdwp_assert_expression("session.hashCode()", "")` after the upgrade — `MISMATCH` confirms the hash drifted.

---

### #2 The Swallowed Exception

**Difficulty:** Hard | **Test:** `EventBusTest` | **Package:** `events` | **Par:** 4 | **Exercises:** exception breakpoint + trigger gate

**Symptom:** `expected stock 
Reveal root cause

`OrderEvent` narrows the raw quantity through `byte` (200 → -56), so `Inventory.reserve()` throws `IllegalStateException`. `EventBus.dispatch()` runs each handler on a single-thread executor as a fire-and-forget task — the `Future` is never inspected, so the exception is captured inside `java.util.concurrent.FutureTask.run` (a JDK frame) and discarded. No sandbox frame ever holds the throwable, so a breakpoint-context dump has nothing to show, and `getErrorSummary()` stays empty.

**Debug path:** Because the exception *is* caught (by FutureTask), an `uncaught`-only breakpoint never fires. Set a line BP at the test/dispatch entry as a trigger, then `jdwp_set_exception_breakpoint("java.lang.IllegalStateException", caught=true, triggerBreakpointId=)` so bootstrap exceptions don't drown the signal. The throw site suspends with `qty = -56` in the frame's locals.

---

### #3 The Time Traveler's Config

**Difficulty:** Hard | **Test:** `ConfigurationProviderTest` | **Package:** `config` | **Par:** 4 | **Exercises:** field watchpoint + event history

**Symptom:** `expected timeout=5000 but was 0` — the timeout was set during construction, yet it reads back as the default.

**Hint:** The value *was* set correctly. Something wrote over it afterward. There's no half-built object to inspect — the damage is in the order of writes.

Reveal root cause

`ConfigurationProvider`'s constructor calls `init(5000)`, so the timeout is correct. Then `runMaintenanceSweep()` starts a background `config-reaper` thread that wrongly treats the live instance as stale and calls `resetToDefaults()`, writing the timeout back to 0. By the time the test reads it, the heap shows 0 and no local holds 5000 — a single context dump looks like the value was never set.

**Debug path:** `jdwp_set_field_breakpoint(className="…config.Configuration", fieldName="timeout", mode="modification")`, then `jdwp_get_events` shows the two stores in order — 0→5000 on the main thread, then 5000→0 on `config-reaper`. `jdwp_get_stack` on the second event names the reaper as the clobberer.

---

### #4 The Audit That Lies

**Difficulty:** Hard | **Test:** `TransferServiceTest` | **Package:** `bank` | **Par:** 4 | **Exercises:** field watchpoint + stack narration

**Symptom:** `expected discrepancy=0 but was non-zero` — money is neither created nor destroyed, yet the audit says the books don't balance.

**Hint:** The transfer moves money in two steps. The audit snapshot is taken between them.

Reveal root cause

`TransferService.transfer()` is not atomic: it calls `source.withdraw()`, then `auditService.snapshotBalances()`, then `destination.deposit()`. The snapshot captures the intermediate state where money has left the source but hasn't arrived at the destination — showing a total of 1500 instead of 2000.

**Debug path:** `jdwp_set_field_breakpoint` on `AuditService.lastTotalSnapshot` (modification). It fires mid-transfer with the stack at `TransferService.transfer` between the debit and the credit; `jdwp_get_stack` plus a live balance read confirm the captured dip.

---

### #5 The Field That Lies

**Difficulty:** Hard | **Test:** `UserProfileTest` | **Package:** `userprofile` | **Par:** 3 | **Exercises:** event history as evidence

**Symptom:** `expected:  but was: ` — the welcome message rendered correctly, yet the user's stored display name has silently changed casing.

**Hint:** You will not find the write by ripgrep'ing for `setDisplayName` — the public setter is never called. A line BP on the setter never fires. Whatever path the write travels, the field's value still flips. Let the JVM tell you exactly when it changes, regardless of how the write reaches the field.

Reveal root cause

`LoginNormalizer.welcomeMessage` calls a private `canonicalForm` helper which delegates to a static `DisplayNameMirror` nested class. The mirror uses `Field.setAccessible(true)` + `Field.set(profile, canonical)` to write the lower-cased form straight into `UserProfile.displayName` — bypassing the public setter entirely. From the test's perspective the formatter is read-only; from JDI's perspective every reflective write is still a real field modification.

**Debug path:** `jdwp_set_field_breakpoint(className="one.edee.jdwp.sandbox.userprofile.UserProfile", fieldName="displayName", mode="modification")` — JDI watchpoints fire on every JVM-level field store, including stores issued through `Field.set` and `Unsafe`. The next write suspends the thread inside `DisplayNameMirror.mirror`; `jdwp_get_stack` walks back through `canonicalForm` to `LoginNormalizer.welcomeMessage` and names the culprit. A line BP on `UserProfile.setDisplayName` would never fire — the setter is genuinely unused.

---

### #6 The Doppelgänger Cart

**Difficulty:** Moderate | **Test:** `CheckoutTest` | **Package:** `cart` | **Par:** 6 | **Exercises:** marked instances (`$label`)

**Symptom:** `expected 45.0 but was 0.0` — the cart goes through pricing and discounting, yet its total never changes.

**Hint:** The object you get back from the pipeline is not the object you passed in. Prove it.

Reveal root cause

`Checkout.process` runs the cart through `validate → price → discount`. `validate` was changed to return a defensive snapshot — `return new Cart(cart)` — so every later stage mutates the *copy*. `process` reassigns its local to the copy as it threads the stages, so no single frame holds both the caller's cart and the copy at once. The test ignores the return value (assuming in-place mutation), so its cart stays at total 0. The copy's fields are identical to the original — only identity differs.

**Debug path:** Break in the test once the cart exists, `jdwp_mark_instance(label="input", objectId=)` to pin it, then set a breakpoint in `price` (or `applyDiscount`) with condition `cart != $input`. It fires — the stage is working on a doppelgänger, not the caller's cart.

---

### #7 The Heisenbug Race

**Difficulty:** Hard | **Test:** `RaceCounterTest` | **Package:** `race` | **Par:** 3 | **Exercises:** logpoints (non-stopping)

**Symptom:** `expected 2 but was 1` — two threads each increment a counter once, but one increment vanishes.

**Hint:** Two threads, one lost update. Suspending a thread to look changes the timing — watch the reads without stopping anything.

Reveal root cause

`RaceCounter.increment` reads the count, waits at a `CyclicBarrier` so both threads have read before either writes, then writes back `read + 1`. Both threads read 0 and both write 1 — one increment is lost. A suspending breakpoint serializes the threads and makes you juggle two parked threads to reconstruct what happened.

**Debug path:** Set a non-suspending `jdwp_

…

## Source & license

This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [FgForrest](https://github.com/FgForrest)
- **Source:** [FgForrest/mcp-jdwp-java](https://github.com/FgForrest/mcp-jdwp-java)
- **License:** MIT

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-fgforrest-mcp-jdwp-java
- Seller: https://agentstack.voostack.com/s/fgforrest
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
