All notable changes to the jdwp-debugging plugin are documented in this file.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
Two diagnostics defects predating 2.10.0, found while revalidating the 2.10.0 fixes.
jdwp_get_locals returned the opaque Error: null — discarding even the this
receiver it had already resolved — whenever the current method was compiled without a
local-variable table (-g:vars), because the underlying AbsentInformationException
carries no message. Separately, jdwp_wait_for_attach labelled an already-connected
session as "suspended at VM_START", which is actively misleading after a same-port
target restart, where the fast-path can hand back a stale handle.
jdwp_get_localsdegrades gracefully — a missingLocalVariableTablenow keeps thethisline and appends a frame-tailored hint (instance frames point at the receiver andjdwp_get_fields; static frames say there is no receiver) instead of replacing the whole response withError: null.- No more
Error: nullor bareError:— tool error replies now fall back to the exception's simple class name when the detail message is null or blank (~35 catch sites acrossJDWPToolsandJDIConnectionService). jdwp_wait_for_attachis honest about already-connected sessions — theVM_STARTguidance is emitted only on a genuine fresh attach; a pre-existing session is described as such and points atjdwp_diagnose/jdwp_disconnectfor the same-port-restart recovery path.
Follow-up defects tracked in #36; the two deferred items there (attach PID-identity race, off-classpath expression-eval diagnostics) remain open.
jdwp_set_exception_breakpoint and jdwp_set_exception_logpoint could fail with
the opaque Cannot invoke "Object.hashCode()" because "key" is null and leave
null-named pending entries behind — whenever the exception class name arrived
null. A null class took the deferred "class not loaded" path and then reached
ConcurrentHashMap.containsKey(null). The class argument now short-circuits with a
clear error before any registry or VM work, and — see Breaking below — the tools
accept the class under the same className parameter the other breakpoint tools
use, so a client that reuses className everywhere binds correctly instead of
dropping the value to null. Resolves #34.
Separately, a breakpoint / logpoint / field-watchpoint condition that failed to
compile or threw at runtime used to suspend on every hit while only logging
server-side — so an agent could believe it was filtering when it was not (most
often a private-field reference, which the bare-field auto-rewrite cannot touch).
Such failures now record a CONDITION_ERROR event, making the fall-open decision
visible in jdwp_get_events / jdwp_resume_until_event.
- Fail-fast class guard — a null or blank class name returns
Error: className is required …instead of an NPE, leaving the breakpoint registry untouched. - Loud, not silent, fail-open — conditions still suspend on evaluation failure
(so nothing is missed), but each failure emits a
CONDITION_ERRORevent naming the breakpoint, location, condition, and cause. Thejava-debugskill now documents thegetDeclaredField/setAccessibleblock-form workaround for private-field conditions.
jdwp_set_exception_breakpoint and jdwp_set_exception_logpoint previously named
their class argument exceptionClass; it is now className, consistent with
jdwp_set_breakpoint and jdwp_set_field_breakpoint. Clients that read the tool
schema on each call (all LLM-driven clients) adapt automatically; only a caller
hard-coded to send exceptionClass= needs updating. This naming inconsistency was
the root cause of the null-class NPE fixed above.
Every evaluated expression, logpoint, conditional breakpoint and watcher is
compiled against the target VM's classpath, which the server discovers by walking
the live classloader hierarchy. That walk is the source of truth, but it does not
see everything: Tomcat's webapp loader, Spring Boot's dev-tools restart loader, and
hand-rolled URLClassLoaders routinely hide their JARs from getURLs(). When that
happened, evaluation failed with a bare X cannot be resolved and no way forward.
The server now augments the remote classpath with a local-project classpath
computed from the machine the MCP server runs on, filling exactly those gaps.
Resolves #32.
- Three additive sources — the
JDWP_EXTRA_CLASSPATHenvironment variable (verbatim entries you control), a depth-bounded filesystem scan fortarget/classes/target/test-classesunder the server's working directory, and Mavendependency:build-classpathfor eachpom.xmlit finds. The result is unioned remote-first, so live target-VM types always win resolution and local entries only fill what the remote walk missed. - Never blocks the debugger — the cold-cache Maven resolution (which can take
1–3 minutes on a fresh
~/.m2) is never paid on the JDI event thread. The first breakpoint warms only the fast remote discovery; the local fallback, including Maven, is resolved lazily on your first actual evaluation and memoized for the rest of the connection. - Inspectable via
jdwp_diagnose— a new "Local project classpath" section reports the working directory, whether apom.xmlwas found, a per-source entry breakdown, and the three-wayJDWP_EXTRA_CLASSPATHstate (unset/set, no entries/set). The diagnose path is non-blocking — it peeks the cache and never triggers discovery. - Disable it by launching the server from a directory with no
pom.xmland notarget/classes, and leavingJDWP_EXTRA_CLASSPATHunset — the merged classpath is then identical to the remote-discovered one.
The new discovery paths touch the filesystem and shell out to Maven, so they are
held to a strict safety contract: no file is ever deleted or moved, Maven output
stays under its own target/ directory, and symlinks are never followed by either
the filesystem scan or the Maven-output harvester (a symlinked target/, pom.xml
or wrapper script is rejected rather than traversed outside the project tree).
- Leak-proof Maven subprocess — a timed-out or interrupted Maven run forcibly destroys the whole process tree it owns (never unrelated siblings), and the wait for the tree to exit is bounded for the tree as a whole rather than per-process.
- Robust under restricted environments — descendant-process enumeration,
unreadable directories, and an unset
user.dirare all tolerated without aborting discovery or failing server startup.
Every evaluated expression, logpoint, conditional breakpoint and watcher compiles
against the target VM's classpath, which the server discovers once per connection
and caches. Three rough edges in that lifecycle made failures slow and confusing —
most visibly after a disconnect/reconnect cycle, where the first evaluation could
fail with a cryptic io cannot be resolved and no hint as to why. The warming,
caching and failure-reporting paths are now tightened.
- First stop pre-warms the classpath — the one-time discovery (it walks the
target's classloader hierarchy and can take 1–3 s) now runs on the first
suspending breakpoint / step / exception / watchpoint instead of lazily on your
first
jdwp_evaluate_expressionor logpoint hit. The cost is paid predictably at the first stop rather than on the critical path of the first evaluation. - Reconnect no longer compiles against the old target — the compiler's classpath is reset on reconnect. Previously a failed re-discovery left the previous target's classpath in place, so expressions could silently compile against stale classes.
- Discovery failures say so — when the classpath / JDK can't be discovered, the
evaluation now fails with an actionable message ("Classpath discovery failed …
application types cannot be resolved") instead of surfacing a bare JDT diagnostic
like
io cannot be resolved. Logpoints record the same as aLOGPOINT_ERROR. (resolves #30)
- README restructured for first-time readers — reordered to lead with what the plugin is and how to attach to a JVM before the deeper material, and fact-checked against the source so the tool list and behavior descriptions match what ships.
Two paths returned dead-ends that steered agents the wrong way.
jdwp_resume_until_event could wake without a stop and flatly blame a concurrent
jdwp_reset / jdwp_disconnect — telling you to re-arm breakpoints that were, in
fact, still armed on a live VM. And any expression evaluated on a stack frame from
a target compiled without a local-variable table failed with the opaque
Expression evaluation failed: null, even when the expression touched no locals at
all — the common case for field logpoints using only $oldValue / $newValue.
Both now do the check they were skipping.
[NO_EVENT]checks before it advises — the no-stop branch now probes VM liveness and the breakpoint registry. If the VM is alive with breakpoints still armed, it says so and tells you to simply call again (do not re-arm); only a genuinely cleared session gets the re-setup guidance.[NO_EVENT]is now also listed in the tool's documented return values. (resolves #27)- Expressions evaluate without a local-variable table — a frame from a target
built without
-g:vars(plainjavac,-g:none, or a stripped release) no longer aborts the whole evaluation. Locals are skipped when absent and the expression runs againstthis+ the synthetic bindings; an expression that does name a missing local now gets a clear "cannot be resolved" compile error instead ofnull. (resolves #29)
- Reflective writes are not caught — corrected —
docs/breakpoints.mdclaimed a reflectiveField.set/Unsafewrite was "fully visible to a watchpoint", contradicting the java-debug skill and the JVM itself. Verified live on JDK 17: a modification watchpoint fires on constructor and direct assignments but stays silent on a reflectiveField.setof the same field. The docs now say so, drop a test-flight spoiler, and add the reference-vs-contents distinction — a watchpoint fires on the field's slot, not on in-place mutation of the object it references. (resolves #28)
The session-epoch work in 2.7.0 made the event log segment cleanly across a
VM restart, but the teardown tools themselves stayed mute about what they threw
away. jdwp_disconnect returned a bare "Disconnected" while it silently
cleared every breakpoint, watcher, mark, the object cache and the event
history; jdwp_connect switching to a different target tore down the prior
session just as quietly. An agent that had armed a dozen breakpoints got no
signal they had vanished. jdwp_reset and jdwp_reconnect already itemize what
they touch — this brings disconnect and connect up to the same parity.
jdwp_disconnectnow reports what it cleared — an itemized summary (breakpoints by kind, watchers, marks, event history, object cache) instead of one word. Counts are snapshotted before the wipe, so they reflect what the call actually discarded.- A reconnect hint, only when it matters — when breakpoints or watchers were
cleared, the reply points at
jdwp_reconnect(re-attach to the same target, specs preserved) as the way to keep them across a VM restart. A bookkeeping disconnect with nothing set stays a terse one-liner — token-optimized. jdwp_connectannounces a target switch — attaching to a differenthost:portnow prepends a one-line "released previous session — N breakpoint(s), M watcher(s) cleared" notice, with the same reconnect pointer. (resolves #25)
A 9-flight test-flight retrospective surfaced the same recurring friction:
jdwp_get_threads tells you a thread is in MONITOR status, but not what
it's blocked on or who holds the lock — so reconstructing a deadlock meant
suspending each thread and reading locals by hand. The new tool answers it in
one call, the natural sequel to 2.6.2's deadlock-inspection error message.
jdwp_dump_locks— takes a balanced VM-wide suspend/resume snapshot, reads each thread's contended monitor and its owning thread, and prints the blocked-thread table plus any deadlock cycle (e.g.transfer-A-to-B → transfer-B-to-A → …). Cycle detection is a pure, JDI-freeDeadlockAnalyzer. The suspend/resume is balanced, so a genuine deadlock stays put and a healthy VM is undisturbed; only synchronized-monitor contention shows (notObject.wait()/java.util.concurrentLocks). (resolves #23)
A breakpoint set on a fully-qualified class name that doesn't match anything
(Config for Configuration) deferred forever, with a message
indistinguishable from a genuinely not-yet-loaded class — a silent dead end
that cost a flight ~13 wasted calls.
- Near-match class suggestions — when a breakpoint defers because its class isn't loaded, the response now scans the loaded classes for resembling names (simple-name exact/prefix match + bounded edit distance) and appends a "did you mean…" hint. Applies to line, logpoint, field, and exception breakpoints; best-effort, never fails a breakpoint set. (resolves #24)
- Length-safe event timestamps — the event listing formatted times with a
fixed
substring(11, 23)that threw on a whole-secondInstant(which prints without a fraction), collapsingjdwp_get_eventsinto an error. It now uses a length-safeHH:mm:ss.SSSformatter.
The jdwp_get_events log is deliberately preserved across a VM death (so the
VM_DEATH and the events leading to it stay readable), but on an auto-reconnect
to a relaunched target the old VM's events bled into the new session's stream
with no way to tell them apart.
- Session-epoch tagging — every event carries a monotonic session epoch,
bumped on each attach;
jdwp_get_eventsshows a per-lines1/s2tag and a divider at each new-VM boundary, so a death-then-reconnect reads as two clean segments without discarding the pre-death evidence. (resolves #25)
java-debugskill discoverability — a deadlock recipe built aroundjdwp_dump_locks; guidance to launch fast-finishing tests withsuspend=y+jdwp_wait_for_attachso the VM can't die before breakpoints are armed; and a note that the event log survives a VM death (usejdwp_clear_eventsto start clean). (resolves #26)
A thread blocked on a monitor or parked in Object.wait() that you did not
stop at a breakpoint reports Suspended: no and never halts on its own — the
classic deadlock case. jdwp_get_stack answered that with a dead-end "Thread
must be stopped at a breakpoint", and jdwp_get_locals had no suspend check at
all and leaked a raw IncompatibleThreadStateException. Both pointed away from
the one tool that actually helps: jdwp_suspend_thread, which freezes the
thread in place so its frames and locals become readable.
- Actionable not-suspended error —
jdwp_get_stackandjdwp_get_localsnow detect a MONITOR/WAIT thread that isn't JDI-suspended and namejdwp_suspend_thread(id)as the deadlock-inspection path (andget_localsgained the suspend check it was missing). The invoke-family tools (evaluate_expression/to_string/assert_expression) keep their existing guard — suspending such a thread makes it inspectable, not invocable. (resolves #22)
- Two
java-debugskill notes from a live test-flight retro — how to read a deadlocked thread (jdwp_suspend_threadit first), and the reminder thatjdwp_disconnectdoes not stop the target JVM: the JVM owns its JDWP port, so a target with live non-daemon threads keeps the port bound after you disconnect — kill the lingering process before relaunching on the same port.
The liveness check behind jdwp_connect's "already connected" guard and the
jdwp_diagnose connection status probed the target with vm.name(), which JDI
caches after its first fetch — so a VM whose socket had already closed (an
orphaned test JVM left over from a previous debug session, or one mid-exit)
still read as alive. connect then short-circuited onto the stale VM and
jdwp_diagnose reported a live connection that wasn't; breakpoints never fired
and the disconnect only surfaced later.
- Round-tripping liveness probe — these two paths now issue a bounded
vm.allThreads()(a real JDWP exchange) instead of the cached name: a closed socket is detected promptly and a wedged VM is bounded by a short timeout, soconnectre-attaches to the VM you actually launched andjdwp_diagnosetells the truth. The cheap cached check stays on the per-tool-call hot path, where a false positive is harmless (the next real JDI call surfaces the disconnect). (resolves #21)
- "The Field That Lies" reads as a puzzle again — the test-flight scenario
had promised that a field-modification watchpoint catches the reflective
Field.setthat mutates the field; a flight on JDK 21 disproved it (JDI watchpoints fire on theputfieldbytecode, not on reflective /Unsafestores). The scenario no longer hands over the answer, and thejava-debugskill gained a caveat: watchpoints miss reflective /Unsafewrites regardless of finality — bisect with line breakpoints +identityHashCodeinstead.
- Actions pinned to Node 24 —
actions/checkoutandactions/setup-javawere bumped past the Node 20 runtime deprecation in both the CI and release workflows.
Every tool used to confirm what it did and stop; the calling agent had to already know which tool to reach for next, and a logpoint gave no hint that its output lands in the event log. An audit of the full tool surface drove a pass that makes responses self-navigating — each one now points to the sensible next tool (or says what to expect), so the set → run → read chain is visible in the output instead of being carried only by the skill.
- Logpoint confirmations point to the event log —
jdwp_set_logpoint,jdwp_set_field_logpoint, andjdwp_set_exception_logpointnow state that hits are recorded as they fire and read on demand withjdwp_get_events(logpoints never suspend and never push, so there is no "event fired" to react to). - Next-step pointers across the surface —
jdwp_resumepoints to the blockingjdwp_resume_until_event;connect/resetsay to set breakpoints then resume;set_local/set_fieldpoint to step/resume; the inspectors and watcher / mark tools point to their natural follow-ups. Hints are terse footers added only where a next step is genuinely useful — tool descriptions were left untouched to avoid a per-turn token cost. - Failure paths navigate too — a failed
jdwp_connectnow points tojdwp_diagnoseto list local JVMs and their JDWP ports.
$newValueon field-access events — amode="both"field logpoint or condition that referenced$newValuefailed to compile on every access hit (one[FIELD_LOGPOINT_ERROR]per read), because the binding was populated only for modification events.$newValueis now bound as a typed-null on access events, so the same expression compiles on both halves and renders… -> nullon reads. (resolves #14)- Actionable
[NO_EVENT]fromjdwp_resume_until_event— when the wait was released without a real stop (a concurrentjdwp_reset/jdwp_disconnectfreeing the waiter, then nulling the snapshot), the tool returned a dead-end "this should not happen" message. It now returns an actionable[NO_EVENT]naming the likely cause and the recovery path. (resolves #15)
java-debugskill guidance — set a line breakpoint at the assertion as a safety net before a field watchpoint on short-running tests (avoids theVM_DEATHrace where the write lands but the suspend never does); set the breakpoint at thejoin()/ assertion line for concurrency flights, not right afterThread.start(); don't pipe the launch command throughtail/headin a background shell. (resolves #16)
- Test gate on push and PR — a new GitHub Actions workflow runs the full
./mvnw testsuite (including the NullAway / Error Prone gate) on every push tomainand every pull request, so regressions are caught on the server rather than only at release time. (resolves #18)
A field watched for modification fires on every write, including the storm of
writes that happen inside the declaring class's constructors before any
interesting state exists — debugging a balance field meant stepping past three
BankAccount constructor writes before reaching the first real mutation. Both
jdwp_set_field_breakpoint and jdwp_set_field_logpoint now take an optional
excludeConstructors flag that filters those out.
excludeConstructors=true— writes originating inside the declaring class's<init>/<clinit>are dropped at the listener: no event recorded, no chain trigger, no suspend. The watchpoint fires only on post-construction mutations. The check is a direct frame test — a method called by a constructor still counts as a real hit, so collaborators invoked during construction are not silently swallowed.
A thread that is JDI-suspended on top of a Java-monitor block or inside
Object.wait() reports isSuspended() == true, so the existing guards let it
through — but JDI's invokeMethod then resumes only that one thread, which
cannot make progress because the lock it needs is held by another suspended
thread (or the notify() that would wake it can never fire). With no
JDI-level timeout, the MCP server blocked indefinitely. This was the failure
mode anyone debugging a deadlock would hit.
- Pre-flight guard —
jdwp_evaluate_expression,jdwp_assert_expression,jdwp_to_string, andjdwp_evaluate_watchersnow refuse a thread inTHREAD_STATUS_MONITOR/THREAD_STATUS_WAITwith an explicit error that points atjdwp_get_stack+jdwp_get_threadsinstead. The refusal turns a silent hang into a useful diagnostic — when you see it, you've usually found a deadlock or a missing notify.
- Skill prologue tightening — the
java-debugskill gained recipes for reconnecting after a target restart (jdwp_reconnectpreserves breakpoints, watchers, and chain edges across the cycle), catching the failure point of an asserting test (AssertionErrorexception breakpoint), and calling a method on a non-public peer field via a block-mode reflection snippet. Two new gotchas cover theMONITOR/WAITguard and the kill-and-reconnect recovery for the residual hang case. - Test-flight assignment prompt — the sandbox README now ships a copy-pasteable prompt that briefs the agent on the game's house rules and a par-based scoring scheme (⭐⭐⭐ at par tool count, ⭐⭐ within 2×, ⭐ solved).
The sandbox flight suite leaned heavily on field watchpoints (four of six flights), so a strong score didn't prove the agent could wield the rest of the toolset. The suite is now nine flights, each built so a different tool group is the path of least resistance, and each carries a par — the minimum tool calls that cleanly reveal the root cause — for style scoring.
- Dropped "The Vanishing Pennies" (mechanically a subset of "The Audit That
Lies"). Reshaped two flights so a single breakpoint-context dump can no
longer shortcut them: "The Swallowed Exception" now throws on a background
executor task with no user-code catch frame (forcing a
caught=trueexception breakpoint + trigger gate), and "The Time Traveler's Config" now overwrites a correctly-set field from a reaper thread (forcing a field watchpoint + event history to read the write order). - Added four flights for the under-exercised groups: The Doppelgänger Cart
(marked instances — a copy-constructor swaps the object mid-pipeline),
The Heisenbug Race (logpoints — a lost update you watch without suspending),
The Magic Patch (
set_local— confirm a fix by mutating live state), and The Polite Standoff (multi-thread inspection — a lock-ordering deadlock diagnosed withjdwp_get_threads+jdwp_get_stack, no breakpoints).
Every place that takes a Java expression — jdwp_evaluate_expression,
jdwp_assert_expression, breakpoint conditions, logpoint expressions and
conditions, exception logpoints, field-watchpoint conditions/expressions, and
watchers — now also accepts a brace-wrapped statement body. Previously each
input had to be a single expression, so anything that needed an intermediate
local, a try/catch, or an early return could not be evaluated at a
breakpoint. Resolves #8.
{ …; return X; }block syntax — when the trimmed input starts with{and ends with the matching}, it is spliced into the wrapper method body verbatim instead of being wrapped as a single expression. You write your ownreturn X;to yield a value; a trailingreturn null;fallthrough guard keeps the wrapper type-correct if the block doesn't return on every path. Intermediate locals,try/catch, early returns, and loops are all available.- Literal-aware mode detection — the brace match is tokenizer-aware and
ignores braces inside string, char, and text-block literals, so
"{x}".length()still routes through expression mode while{ try { return foo(); } catch (Exception e) { return null; } }routes through block mode. - Discoverable from any eval param — all eight eval-bearing tool
methods/params carry a "supports
{ …; return X; }block syntax" suffix in their descriptions, so the feature surfaces wherever an eval-shaped parameter appears. - Auto-rewrite interaction — the bare-field
this.fieldauto-rewrite is skipped in block mode (an identifier-level rewriter cannot tell a field reference from a local declaration); block-mode users write explicitthis.field/_this.field, which the keyword rewrite still handles.
When a breakpoint or logpoint targets a class that has not loaded yet, or a line that compiles to more than one bytecode location, the install used to succeed silently and leave you guessing why nothing fired. Two no-behaviour-change diagnostics now make those cases visible. Resolves #9.
CLASS_PREPARErecorded in event history —jdwp_get_eventspreviously showed only[VM_START, VM_DEATH]for a session parked on a deferred breakpoint, so an agent could not tell "the class never loaded" from "the class loaded but the breakpoint never fired." The class-prepare event is now captured, making the distinction observable.- Multi-location warning — when
locationsOfLinereturns more than oneLocation(typical for lambdas: one in the enclosing method, one in the syntheticlambda$…$N), the server emits aBP_MULTI_LOCATIONevent, a WARN log, and aWARNINGsuffix on thejdwp_set_breakpoint/jdwp_set_logpointresponse. The bind logic is unchanged — it still binds the first location — so this is diagnostics only; a proper multi-location bind is tracked separately.
Expression evaluation always emitted its wrapper class into a fixed
mcp.jdi.evaluation package, so a non-public this type and its
package-private fields were unreachable — the this.field auto-rewrite
short-circuited on the common case (e.g. a package-private
BitmapSlicer.translator). Resolves #7.
- Wrapper emitted into
this's own package whenthisis non-public and the package is addressable (notjava.*/javax.*/sun.*/jdk.*, not a local/anonymous class). Same-package reachability then lets the wrapper dereference package-private types and members directly; the field-rewrite filter widens to public/protected/package-private (private still needs reflection). - Define-time fallback to the default package — if a sealed package,
module strong-encapsulation, or a restrictive classloader rejects
defineClassfor the application package, evaluation retries once inmcp.jdi.evaluation. The user expression never ran (the failure is in the define phase, before invoke), so the retry is safe and preserves the pre-target-package behaviour for public-only expressions. - Non-public interface types resolve to a reachable supertype instead of dead-ending the declared-type walk.
- Pipeline-exception subtype preserved — a define-vs-invoke failure keeps
its original
JdiEvaluationExceptionsubtype and message instead of being flattened into a generic wrapper.
- Release workflow skill — added a
releaseskill capturing the plugin-release procedure (marketplace.json + CHANGELOG bump, annotated tag, cherry-pick/back-fill recovery), plus a fix to its predecessor-tag selection so release notes diff against the immediately-lowerv*tag.
When the target JVM stops responding to JDI commands (paused in a native section, GC-stalled, frozen by another debugger client, or wedged behind a deferred-class-load deadlock), the MCP server used to hang every subsequent tool call indefinitely. Cancellation from the client was a no-op because the worker was parked in a JDI native wait. The agent had no signal to distinguish "the user hasn't hit my breakpoint yet" from "the JDI connection is dead." Resolves #4.
Four cooperating pieces:
JdiHealthMonitor— a daemon-thread service that observes JDWP traffic passively (every event drained byJdiEventListenerbrushes againstnotifyTraffic) and escalates to a singlevm.allThreads()probe wrapped inFuture.get(5s)after 30 s of silence. The snapshot (RESPONSIVE/UNRESPONSIVE/DISCONNECTEDwith last-traffic and last-probe timestamps) is read-only state for downstream renderers — the monitor never auto-recovers.- Soft wait protocol —
jdwp_resume_until_eventandjdwp_wait_for_attachnow lead their timeout responses with a structuredstill_waitingenvelope citing the current JDI Health classification plus thewait_more/reconnect/abortoptions. No hard server-side backstop: the agent re-calls to wait more. jdwp_reconnect—vm.dispose()s the current connection and re-attaches to the lasthost:port, preserving the synthetic breakpoint-ID space, line / exception / field BP specs, conditions, logpoint expressions, chain edges, watchers, and event history. Marked instances, the object cache, last-suspended-thread context, and the classpath cache cannot survivevm.disposeand are dropped (the response enumerates what was preserved vs lost). A different target host:port needsjdwp_connect.jdwp_diagnoseextension — a new JDI Health block renders status, last-traffic age, last-probe outcome, and the recommended action when the connection is wedged.
Until this release, every jdwp_set_* tool (line, exception, field BPs and
logpoints) invoked Class.forName(name) inside the target VM when the
class was not yet visible to JDI. That changes target-application
behaviour the way a debugger never should: it triggers <clinit> early,
cascade-loads dependencies, can fire user breakpoints, and masks the
lazy-load diagnostics users attach a debugger to investigate. Resolves #3.
- Default behaviour is now passive —
JDIConnectionService.findLoadedClassdoesclassesByName+allClassesscan only (noinvokeMethod). Unresolved classes are deferred viaClassPrepareRequest, matching IntelliJ / Eclipse /jdbsemantics. forceLoadopt-in — all six set-breakpoint MCP tools accept a BooleanforceLoad; defaultfalse. Settrueonly when you need the bootstrap-class case for exception BPs (the primary motivator).BreakpointTracker.tryPromotePendingnow usesfindLoadedClassexclusively; the unusedpreferredThreadparameter is dropped.
tryPromotePending was synchronized and called findOrForceLoadClass
(which issues a target-VM invokeMethod) while holding the
BreakpointTracker monitor. The JDI event listener takes that same
monitor in promotePendingFieldsForClass, so a worker parked in
invokeMethod blocked the listener that had to deliver the reply,
permanently wedging the MCP server. Any subsequent MCP tool call queued
behind the worker on JDIConnectionService's monitor, so the whole server
became unresponsive. Resolves #1.
tryPromotePendingrefactored to snapshot the three pending maps under a brief synchronized block, drop the monitor before any JDI round-trip, and re-acquire it per entry for an atomic recheck-and-mutate.JDIConnectionService.findOrForceLoadClasssplit into three phases (lookup under monitor →invokeMethodoutside monitor → post-invoke lookup on capturedvmRef) so the same hazard cannot recur on the connection-service monitor.getVM()restructured to release its monitor before callingtryPromotePending, closing the reentrant-hold variant of the same cycle.- Two double-promotion races closed —
promotePendingToActiveandpromotePendingExceptionToActiveare nowsynchronizedand returnboolean;falsemeans "already promoted, caller musterm.deleteEventRequestthe surplus request." All call sites updated.
A round of code-review follow-ups before the release cut:
jdwp_reconnectleaves a recoverable tracker on attach failure — active-map entries pointing at dead JDI request handles used to survive a failed reattach and tripVMDisconnectedExceptionon the nextjdwp_reconnect. The tracker is now restored to pure-pending state BEFORE the attach attempt.JdiHealthMonitorprobe / stop race closed — the three snapshot publishes inrunActiveProbeand the in-threshold publish inprobeTickare wrapped insynchronized(this)blocks with avmRef.get() == vmre-check, so a concurrentstop()cannot be overwritten back toRESPONSIVE/UNRESPONSIVE.notifyTraffic()is nowsynchronizedand bails whenvmRefis null, so a late event-listener drain after disconnect cannot revive a stopped monitor.cancelProbeTaskswitched fromcancel(false)tocancel(true)to interrupt an in-flight probe promptly.vmDeathHookdetach window narrowed duringreconnectPreservingSpecs— the hook is now detached only around the listenerstop()call (and re-attached in an innerfinally) so any realVMDeath/VMDisconnectof the fresh VM during dispose, attach, listener start, or pending-BP promotion still invokesnotifyVmDied(). The original intent — preventingnotifyVmDied → watcherManager.clearAll()from firing during the intentional stop — is preserved.- Watchers preserved across
jdwp_reconnect—JdiEventListener.stop()invokes the VM-death hook; the hook now detaches around stop so the contract's "watchers survive" promise is honoured. Pinned by a new reconnect-watcher-preservation test.
Expression evaluation silently failed on machines where the local JDK was
installed via SDKMAN (typical macOS / Linux developer setup): the discovery
service only probed /usr/lib/jvm, /opt/jdk-*, and a few Windows paths,
so ~/.sdkman/candidates/java/17.0.18-tem/ never matched. The MCP server
logged [ERROR] JDK path not discovered, cannot configure compiler and
every evaluate_expression then returned Compiler is not configured.
- SDKMAN paths now enumerated dynamically —
~/.sdkman/candidates/java/<major>.*entries are listed at discovery time and ordered with the newest patch first by numeric semver compare (so17.0.18-temoutranks17.0.5-tem, which a naive lexicographic sort would invert). JAVA_HOMEnow used as a fast shortcut — when set, the env var is honored ahead of the directory scan, but only after reading<jdkHome>/releaseand confirming theJAVA_VERSIONmajor matches the target VM. A mismatched JAVA_HOME is skipped with a debug log instead of feeding the JDT compiler a wrong-version--systemand producing cryptic class-file-version errors downstream.- Error message updated to list
$JAVA_HOME,/opt/jdk-*, and the SDKMAN path alongside the Windows //usr/lib/jvmlocations.
So the next incident leaves enough breadcrumbs to localize the cost:
JDIConnectionService.discoverClasspathlogs… in {}mson every exit branch (success, empty result,JdkNotFoundException, generic failure).JdiExpressionEvaluator.evaluatelogsExpression evaluated in {}ms (cache hit/miss)on success andExpression evaluation failed after {}ms: …on exception. The cache-hit/miss tag separates compile-bound runs from invoke-bound ones.
The mcp-jdwp-inspector.log file used to land in whatever directory Claude
Code was launched from (typically the user's project root), because the
logback FileAppender resolved a relative path against CWD.
.mcp.jsonnow passes-DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.logso plugin-launched runs write the log next to the plugin install. Logback creates thelogs/subdirectory automatically.logback-spring.xmlfallback changed from CWD to${java.io.tmpdir}— barejava -jarruns (tests, dev, debugging the server itself) now land the log in the OS temp dir instead of polluting CWD whenLOG_PATHis unset.
application.properties documents the wiring so future readers know where
the log lives and why.
A re-audit caught two leaks in the prior audit fix bundle plus a handful of medium-severity polish items.
jdwp_get_breakpoint_contextnow uses the kind-aware event tag — previously it still formattedbreakpoint=nullafter a STEP or EXCEPTION snapshot (the F-RA2 sweep missed this third renderer).jdwp_evaluate_watchersafter STEP — the STEP snapshot now inherits the BP id it stepped FROM, so watchers attached to that BP resolve at the step landing instead of silently emittingNo breakpoint ID available.- Transport-loss exceptions now land on
[VM_DEATH]/[VM_GONE]—SIGKILLof the target JVM used to surface asError evaluating expression: Connection refused(rawIOException). A newisVmGone(Throwable)predicate walks the cause chain matchingVMDisconnectedException,SocketException,EOFException, plus observed message fragments (Connection refused,Connection reset,Connection closed by peer,Broken pipe,Pipe closed,closed by the remote host,handshake failed,Bad file descriptor). Walk is depth-bounded to defeat cause-chain cycles.vmGoneEnvelopewalks to the deepest matching cause so the rendered reason names the actual transport failure instead of an outer wrapper's generic message. - STEP / EXCEPTION tag instead of stale
breakpoint=N— three renderers (jdwp_resume_until_event,jdwp_get_current_thread,jdwp_get_breakpoint_context) now showvia=step/via=exceptionfor STEP and EXCEPTION snapshots. BP snapshots keepbreakpoint=N.
The fix bundle landed in code via the prior audit pass. Summary for the release notes:
- Re-fetch
StackFrameper watcher iteration so two watchers on one BP no longer crash the second withInvalidStackFrameException. evaluate_expression/assert_expressionnow thread marked-instance bindings so$markidentifiers resolve.- Field watchpoint events bind
$oldValue/$newValue/$objectunconditionally — null-valued fields are observable. BP_PROMOTION_FAILEDevent recorded + surfaced inside[VM_DEATH]so deferred BPs on comment / blank lines report the failure to the agent instead of going dark.resume_until_eventshort-circuits via apendingFireflag so astep → otherTool → resume_until_eventsequence does not lose the STEP signal or overshoot the suspended state.jdwp_attach_watcherrejects unknown BP ids with an[ERROR]envelope instead of silently registering an inert watcher.jdwp_diagnosesuppresses the disconnected[DIAGNOSTIC]block and filters the MCP server's own PID out of the JVM list.
Output formats tightened so common turns spend fewer tokens:
jdwp_get_threadsdefaults to a compact one-line-per-thread table (~25 lines for a 200-thread Tomcat vs ~1000 lines verbose); passverbose=truefor the legacy block format.jdwp_overviewhides empty category headers by default; passshowEmpty=trueto restore them.jdwp_attach_watcher,jdwp_get_version, emptyjdwp_get_events, and emptyjdwp_resetcollapsed to one-line responses.jdwp_diagnosefilters disconnected JVMs from the local-JVM list when a connection is live.
jdwp_resume_until_eventincludesat Class:linein the "Event fired" line — saves one round-trip per hit.jdwp_step_*responses include the start location so the agent can see where the step originated.jdwp_evaluate_expressionechoes the expression (Result of <expr>: <value>) so several batched evals can be attributed.jdwp_to_stringflags the defaultObject.toStringform (ClassName@hexhash) so the agent knows to usejdwp_get_fieldsinstead of trusting the string.jdwp_wait_for_attachsuccess points atjdwp_resume_until_eventas the next call.jdwp_mark_instancelists the tools that accept$labelidentifiers in its success footer.
Agent-chosen labels that pin a JDI ObjectReference in the target heap and
expose it to expression evaluation as a synthetic $label binding. Mirrors
IntelliJ IDEA's "Mark Object" feature; the agent can now reference a specific
instance by name inside conditional breakpoints, logpoint expressions,
watchers, and exception logpoints — across method boundaries where the local
variable name differs or is absent.
jdwp_mark_instance(label, objectId, pin=true)— register a label.disableCollection()is called by default so the mark survives across events. Rejects collisions, reserved binding names (exception,oldValue,newValue,object,fieldName,mode,_this), Java keywords, and non-identifier labels with descriptive errors.jdwp_unmark_instance(label)— releases the pin and frees the slot.jdwp_rename_mark(oldLabel, newLabel)— keeps the underlying object and pin.- Marks are auto-cleared on VMDeath, disconnect, and
jdwp_reset(matching the object cache lifecycle). jdwp_get_localsandjdwp_get_breakpoint_contextnow append a "Marked instances visible to expressions" footer listing every live mark so the agent does not need a separate call to remember them.
jdwp_overview(types?, filter?)— single read-only listing of breakpoints, exception breakpoints, field breakpoints, logpoints, watchers, AND marked instances. Filter by type (comma-separated subset ofbreakpoint, exception_breakpoint, field_breakpoint, logpoint, watcher, markorall) and/or by case-insensitive substring across class / label / expression / type. Chain status (chain=trigger=#N) is rendered inline for any BP that is part of a chain.jdwp_clear(types, filter?)— bulk-clear by type and/or filter.typesis required to prevent an unguarded blanket wipe (use'all'to clear every kind). To preview a clear safely, calljdwp_overviewwith the sametypes/filterfirst — the matching rows are exactly whatjdwp_clearwould remove.
Replaced by the unified jdwp_overview / jdwp_clear pair. Agents or
scripts that referenced these will need to switch to the unified surface
(no functional gap — chain status, condition, and expression info all
render under jdwp_overview):
jdwp_list_breakpoints— usejdwp_overview(types="breakpoint").jdwp_list_exception_breakpoints— usejdwp_overview(types="exception_breakpoint").jdwp_list_field_breakpoints— usejdwp_overview(types="field_breakpoint").jdwp_list_all_watchers— usejdwp_overview(types="watcher").jdwp_clear_all_breakpoints— usejdwp_clear(types="breakpoint,exception_breakpoint,field_breakpoint")orjdwp_clear(types="all")to also clear watchers/marks in one call.jdwp_clear_all_watchers— usejdwp_clear(types="watcher").
Per-ID delete tools are unchanged: jdwp_clear_breakpoint(id) and
jdwp_detach_watcher(id) remain the canonical "delete this one thing" path.
jdwp_list_watchers_for_breakpoint(breakpointId) is also retained — it is
the only per-BP watcher query (the substring filter on jdwp_overview is
not breakpoint-id-aware).
Claude Code 2.1.143 (and any client requesting an MCP protocol newer than
2024-11-05) could not use the plugin: /mcp reported
Failed to reconnect to jdwp-inspector: -32000 and every tool call was
silently dropped after initialize.
Root cause was upstream: mcp-core's StdioServerTransportProvider.protocolVersions()
hardcodes List.of("2024-11-05") in every published version through
2.0.0-M2 (the latest shipped with Spring AI 2.0.0-M6). The server
downgraded the session in its initialize response, then McpAsyncServer
stopped responding to all subsequent requests.
The plugin now ships a local MultiVersionStdioServerTransportProvider that
advertises all four protocol versions known to the bundled SDK
(2024-11-05, 2025-03-26, 2025-06-18, 2025-11-25) and registers it as
stdioServerTransport, displacing Spring AI's @ConditionalOnMissingBean
auto-config bean. Existing clients on 2024-11-05 continue to negotiate
successfully.
This release renames and removes several breakpoint-clear tools to unify them around a single ID-keyed surface. Agents or scripts that referenced the old tool names will need to update — the new surface is smaller and more consistent. The headline new feature is field watchpoints.
jdwp_clear_breakpoint(className, lineNumber)— removed. Clear by ID using the unifiedjdwp_clear_breakpoint(id)instead.jdwp_clear_breakpoint_by_id(id)— renamed tojdwp_clear_breakpoint(id).jdwp_clear_exception_breakpoint(id)— removed. Exception breakpoints now clear through the unifiedjdwp_clear_breakpoint(id).jdwp_set_exception_breakpoint—logOnlyandexpressionparameters removed. The tool is now strictly suspending. For non-stopping exception tracing use the new dedicatedjdwp_set_exception_logpoint.
After upgrade, the single rule is: every breakpoint kind (line, exception,
field) is cleared by ID via jdwp_clear_breakpoint(id).
JDI watchpoints that suspend or log on every read / write of a specific
field — including reflective writes via Field.set and Unsafe, which line
breakpoints on a setter would miss entirely.
jdwp_set_field_breakpoint(className, fieldName, mode, ...)—modeisaccess,modification, orboth(IntelliJ-style, both directions bind to one synthetic ID).jdwp_set_field_logpoint(...)— non-stopping variant that records to event history.jdwp_list_field_breakpoints()— active + pending field BPs. (Removed in Unreleased; replaced byjdwp_overview(types="field_breakpoint").)
Conditions and logpoint expressions get five synthetic bindings:
$oldValue, $newValue, $object, $fieldName, $mode. Pending field
BPs promote synchronously on ClassPrepareEvent, so writes inside
<clinit> are captured.
jdwp_set_exception_logpoint(exceptionClass, expression, condition?, ...)— non-stopping exception trace with$exceptionbound to the thrown object.jdwp_diagnose()— single-call snapshot of attach state, active breakpoints, recent events, plus local JVM discovery and a VM-capability probe (reports whether the target supports field access / modification watchpoints, with a perf warning for full-class-attribute access watchpoints).
- Chained breakpoints with one-shot / sticky modes — trigger BPs arm
dependent BPs, with cascade-on-clear semantics. New event types in history:
CHAIN_ARMED,CHAIN_DISARMED,CHAIN_BROKEN. - Diagnostic timeout response — when
jdwp_resume_until_eventtimes out, the response now includes a structured diagnostic block (active BPs, pending BPs, recent events) instead of a bare timeout string. - Test flight #6 — "The Field That Lies" — a new sandbox scenario that exercises field-modification watchpoints by mutating a private field via reflection. A line BP on the public setter never fires; the field watchpoint does.
New event types: FIELD_ACCESS, FIELD_MODIFICATION, FIELD_LOGPOINT,
FIELD_LOGPOINT_ERROR, FIELD_BREAKPOINT_SUPPRESSED, CHAIN_ARMED,
CHAIN_DISARMED, CHAIN_BROKEN.
- README: new "Field breakpoints (watchpoints)" section, refreshed tool reference (46 tools), flight #6 added.
skills/java-debug/SKILL.md: new recipes — "Who is overwriting this field?" and<clinit>capture — plus field-BP perf gotcha.docs/EXPRESSION_EVALUATION.md: synthetic-bindings table covering$exception,$oldValue,$newValue,$object,$fieldName,$mode.
Prior baseline. See git history for details.