This chapter covers what happens between launching the JAR and shutting it down — including every code path that creates, recreates, or discards server-side state. If you ever wonder "did jdwp_reset actually clear that?" or "why does my session resume after the target restarts?", the answer is somewhere in this chapter.
The MCP server is a Spring Boot application with a one-line main (JDWPMcpServerApplication.java:14):
SpringApplication.run(JDWPMcpServerApplication.class, args);application.properties keeps the surface deliberately small:
spring.main.web-application-type=none— no Tomcat, no Netty. The JVM holds the Spring context, the MCP transport, and a handful of@Servicebeans. That is it.spring.main.banner-mode=off— the banner would pollute stdout, which carries MCP frames.spring.ai.mcp.server.type=SYNC— see threading-and-safety.md. Tool calls run on the STDIO reader thread, one at a time.spring.ai.mcp.server.enabled=true— auto-discovers@McpToolmethods onJDWPToolsand the@McpResourcemethods alongside them.
Spring AI ships a StdioServerTransportProvider bean, but we replace it. StdioTransportConfig.stdioServerTransport(...) (transport/StdioTransportConfig.java:18-22) is marked @ConditionalOnMissingBean on the Spring AI side, so providing our own override suppresses theirs cleanly.
@Bean
public StdioServerTransportProvider stdioServerTransport(McpJsonMapper jsonMapper) {
return new MultiVersionStdioServerTransportProvider(jsonMapper);
}The override exists because the upstream StdioServerTransportProvider in mcp-core 1.1.0 through 2.0.0-M2 hardcodes List.of("2024-11-05") as its advertised protocol versions. A newer client (Claude Code 2.1.143 requests 2025-11-25) gets a silent downgrade, then the session stops responding with -32000 Failed to reconnect. Our subclass overrides protocolVersions() to advertise all four versions the bundled SDK knows about (transport/MultiVersionStdioServerTransportProvider.java:26-33):
List.of(
ProtocolVersions.MCP_2025_11_25,
ProtocolVersions.MCP_2025_06_18,
ProtocolVersions.MCP_2025_03_26,
ProtocolVersions.MCP_2024_11_05
);The two STDIO streams are owned by the framework; we never read or write System.in / System.out ourselves. Logs are explicitly redirected to System.err and to a file — see diagnostics.md.
The Spring context boots without any JDI activity. No VirtualMachine, no event listener thread, no JDWP socket. The server is dormant until the first tool call that needs the target — typically jdwp_connect or jdwp_wait_for_attach. This matters: Claude Code can start the MCP server (and pay its ~3-second cold-start cost) at session start, and the target JVM does not need to exist yet.
Background — how the target JVM exposes JDWP in the first place
The JVM accepts a
-agentlib:jdwp=command-line option that loads the bundled JDWP agent (a shared library shipped with the JDK). The agent parses its options and either listens on a socket (server=y) or attaches outbound to a debugger (server=n). The full option string the README documents —transport=dt_socket,server=y,suspend=n,address=*:5005— tells the agent to use TCP, listen, not suspend on start, and bind to all interfaces on port 5005. The agent is what implements the JDWP server side; the JVM itself does not know JDWP, it provides a JVMTI (Java Virtual Machine Tool Interface) surface that the agent uses to install breakpoints, read frames, etc. Anything the debugger can do is something the JVMTI can do.The
suspend=yvariant pauses every thread at VMStart before the application'smainruns. This is what you want when you need to set a breakpoint before the first interesting line executes; combined withjdwp_wait_for_attach, it is how the test-flight scenarios stay reproducible.
JDIConnectionService.connect(host, port) (JDIConnectionService.java:263-319) does the JDI attach. The sequence:
- Record
lastConnectAttemptsojdwp_diagnosecan report the most recent attempt. - Short-circuit if already attached to the same host:port (
JDIConnectionService.java:267-271). - Otherwise tear down any stale session via
cleanupSessionState()(lines 276 and 281). - Look up the
com.sun.jdi.SocketAttachconnector by name fromBootstrap.virtualMachineManager().attachingConnectors()(lines 284-292). - Set
hostname/portarguments and callconnector.attach(args)(lines 299-304). - On success: store the
VirtualMachine, recordlastHost/lastPort, spawnJdiEventListenerviaeventListener.start(vm)(line 316).
The connector lookup is deliberate: the JDI implementation is plugged in via ServiceLoader, and on some runtimes the dt_socket connector is named differently. Selecting by exact name ("com.sun.jdi.SocketAttach") fails fast if the runtime is missing JDI entirely (Java without --add-modules jdk.jdi), instead of silently picking the wrong connector.
Background — what
attach()does under the hood
SocketAttachingConnector.attach(args)opens a TCP socket to the host/port, then runs the JDWP handshake: write the 14 ASCII bytes"JDWP-Handshake", expect the same 14 bytes back. After the handshake, JDI issues a few opening JDWP commands to learn the target's vendor / version / capabilities (whether it supports field watchpoints, source filename queries, etc. —VirtualMachine.canWatchFieldModification()and friends each map to acapabilitiesreply byte). The returnedVirtualMachineobject is a JDI-side facade; from this point on, every method call on it serialises a JDWP command and waits for the reply on the same socket.A handful of JDWP commands are run eagerly during the attach — for instance, listing already-loaded classes. That is one reason
connect()is slower than a raw TCP socket open: even on a localhost target with no application work to do, the attach completes after several round-trips.
If connector.attach(args) throws, lastConnectError is recorded but lastHost / lastPort are not (JDIConnectionService.java:303-311). That asymmetry is the safety mechanism: ensureConnected() will only auto-reconnect to a target that previously succeeded. A typo-ed host or a never-running port stays "not connected" rather than getting retried every tool call.
getVM() (JDIConnectionService.java:469) is what every tool method calls when it needs the live VM. Internally it invokes ensureConnected() (JDIConnectionService.java:368-376), which:
- Probes
isVMAlive()— callsvm.name()in a try/catch (JDIConnectionService.java:240-251). A live VM returns its name; a dead one throwsVMDisconnectedException. - If alive: return.
- If dead but
lastHost/lastPortare set: re-connect(lastHost, lastPort). Exactly one attempt. - If dead and no
lastHost: throw "Use jdwp_connect first."
The "exactly one attempt" behaviour matters: a getVM() call cannot spiral into a retry loop. If reconnect fails, the tool returns the error and the agent decides whether to call jdwp_wait_for_attach again.
Polls connect() every 200 ms up to a timeout (default 30 s) — JDWPTools.java:296-359. IllegalConnectorArgumentsException fails fast (a bad host or port — no point retrying). IOException and other exceptions are retried (the target is simply not listening yet). On timeout, the tool runs JVM discovery (see diagnostics.md) so the agent can see "what JVMs did come up but on a different port".
On success, the tool returns a one-line hint that the VM is at VM_START — set breakpoints, then jdwp_resume_until_event.
JdiEventListener.start(vm) (JdiEventListener.java:200-212) spawns the daemon thread that drains the JDI event queue. The thread is named jdi-event-listener and is created fresh on every connect() — start() calls stop() first so two listeners never overlap (line 201).
stop() (JdiEventListener.java:243-263):
- Interrupts the thread.
- Joins for up to 500 ms.
- Fires the VM-death latch and
breakpointTracker.fireNextEvent()to wake any tool blocked injdwp_resume_until_event.
The full event-routing contract is in event-pipeline.md.
There are two cleanup paths, and the difference matters.
JDIConnectionService.disconnect() (JDIConnectionService.java:383-389) is called when the agent explicitly asks to detach (jdwp_disconnect). It delegates to cleanupSessionState() (JDIConnectionService.java:428-461):
eventListener.stop()(line 429).breakpointTracker.clearAll(vm.eventRequestManager())deletes live JDI requests, falling back to in-memoryreset()if the VM is already dead (lines 434-440).- Clears
watcherManager,markedInstances,objectCache, classpath cache,discoveredJdkPath,targetMajorVersion,eventHistory(lines 442-448). - Calls
vm.dispose()for a clean JDWP teardown (lines 451-456). Best-effort: any exception is swallowed because the VM may already be gone. - Resets
lastHost/lastPortto 0 / null (lines 459-460).
That last step is the key distinction: after disconnect(), the next tool call cannot auto-reconnect, because there is no remembered target. The agent has to ask explicitly.
The listener wires up a death hook in the connection-service constructor (JDIConnectionService.java:143):
eventListener.setVmDeathHook(this::notifyVmDied);The hook fires when the listener loop observes a VMDeathEvent or VMDisconnectEvent, catches VMDisconnectedException / IllegalStateException from the queue, or is interrupted (JdiEventListener.java:309-339). Gated by a single-shot CAS (vmDeathHookInvoked, JdiEventListener.java:87,218-231) so a noisy shutdown does not invoke the hook twice.
notifyVmDied() (JDIConnectionService.java:399-416) is the post-mortem path. Unlike disconnect():
- It preserves
lastHost/lastPort— so the next tool call'sensureConnected()can auto-reconnect after the target restarts. - It preserves
eventHistory— so theVM_DEATHentry stays queryable for diagnostics.
Everything else (object cache, watchers, marks, classpath cache, target version) is cleared. The model is "the VM is gone but the conversation is not over" — keep just enough state to recover gracefully, drop everything that is tied to a specific target-VM lifetime.
jdwp_reset (in JDWPTools.java, routed to BreakpointTracker.reset() and a handful of cache clears) is the agent's "start fresh while keeping the connection" button. It clears:
- Every active and pending breakpoint
- The synthetic ID counter
- The chain-dependency maps and
triggersFiredAtLeastOncememory - All watchers
- The object cache
- The event history
It does not clear marks (those are persistent labels the agent set deliberately) and does not touch lastHost / lastPort (the connection stays live).
| State | disconnect() |
notifyVmDied() |
jdwp_reset |
connect() same target |
connect() different target |
|---|---|---|---|---|---|
vm reference + vm.dispose() |
yes | yes | no | no-op short-circuit | yes (then re-attach) |
| Event listener thread | stopped | stops itself | no | no | stopped, restarted |
| Active JDI breakpoint requests | yes | reset in-memory | yes | no | yes |
| Pending BPs, ID counter, dependency graph | yes | yes | yes | no | yes |
| Watchers | yes | yes | yes | no | yes |
| Object cache | yes | yes | yes | no | yes |
| Marked instances | yes | yes | no | no | yes |
| Classpath / JDK / target-version caches | yes | yes | no | no | yes |
| Event history | yes | preserved | yes | no | yes |
lastHost / lastPort (auto-reconnect seed) |
cleared | preserved | no | unchanged | replaced |
lastConnectError |
n/a | n/a | n/a | cleared on success | cleared on success |
(All entries reference JDIConnectionService.java:428-461 for cleanupSessionState, JDIConnectionService.java:399-416 for notifyVmDied, BreakpointTracker.java:336-358 for clearAllInMemoryStateLocked — which is what the reset paths call internally.)
The pattern: user-initiated tear-downs are clean slates; VM-death tear-downs preserve just enough to recover.
Several realistic flows fall out of the matrix above:
- Target JVM restarts during a session. The listener catches
VMDisconnectedException, runs the death hook, and the connection service clears caches but keepslastHost/lastPort. The next tool call'sgetVM()runsensureConnected()and reconnects automatically. Breakpoints from before the restart are gone (they lived in the dead VM); the agent re-sets them. - Agent calls
jdwp_disconnectthenjdwp_connect host port. Same as a fresh attach — caches were fully cleared in step 1, thenconnect()rebuilds everything. - Agent re-runs
jdwp_connect host portwhile already attached to the same target. No-op short-circuit atJDIConnectionService.java:267-271. The session continues unchanged. (Usejdwp_resetif the intent is "clear state without dropping the connection".) - Agent runs
jdwp_connect host2 port2while attached tohost1 port1. The "different target" column.cleanupSessionStateruns (clears everything, including marks), then the attach to the new target proceeds.
Logback is configured in jdwp-mcp-server/src/main/resources/logback-spring.xml. Two appenders, root level INFO:
- STDERR —
System.erronly (lines 4-9). Never stdout, which carries MCP JSON. - FILE — path is
${LOG_PATH:-${java.io.tmpdir}/mcp-jdwp-inspector.log}(line 13). PlainFileAppender,append=true, no rotation.
The .mcp.json shipped with the plugin (/.mcp.json:7) sets -DLOG_PATH=${CLAUDE_PLUGIN_ROOT}/logs/mcp-jdwp-inspector.log so the log file lives next to the plugin install rather than in the user's working directory. When running the JAR directly without that flag, the log falls back to ${java.io.tmpdir}. The rationale is documented inline in application.properties:12-18.
The lack of rotation is intentional — sessions are typically short-lived and bounded by the agent's lifetime. For long-running automation, plan to truncate the file externally or wrap the java command in a launcher that handles rotation.