Skip to content

Latest commit

 

History

History
190 lines (127 loc) · 14.7 KB

File metadata and controls

190 lines (127 loc) · 14.7 KB

Lifecycle: bootstrap, connect, disconnect, reset

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.

1. Server bootstrap

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 @Service beans. 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 @McpTool methods on JDWPTools and the @McpResource methods alongside them.

The custom STDIO transport

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.

What is not started at boot

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=y variant pauses every thread at VMStart before the application's main runs. This is what you want when you need to set a breakpoint before the first interesting line executes; combined with jdwp_wait_for_attach, it is how the test-flight scenarios stay reproducible.

2. Attaching to the target — jdwp_connect and jdwp_wait_for_attach

JDIConnectionService.connect(host, port) (JDIConnectionService.java:263-319) does the JDI attach. The sequence:

  1. Record lastConnectAttempt so jdwp_diagnose can report the most recent attempt.
  2. Short-circuit if already attached to the same host:port (JDIConnectionService.java:267-271).
  3. Otherwise tear down any stale session via cleanupSessionState() (lines 276 and 281).
  4. Look up the com.sun.jdi.SocketAttach connector by name from Bootstrap.virtualMachineManager().attachingConnectors() (lines 284-292).
  5. Set hostname / port arguments and call connector.attach(args) (lines 299-304).
  6. On success: store the VirtualMachine, record lastHost / lastPort, spawn JdiEventListener via eventListener.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 a capabilities reply byte). The returned VirtualMachine object 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.

Failure handling — no silent retry

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.

Auto-reconnect on the next 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:

  1. Probes isVMAlive() — calls vm.name() in a try/catch (JDIConnectionService.java:240-251). A live VM returns its name; a dead one throws VMDisconnectedException.
  2. If alive: return.
  3. If dead but lastHost / lastPort are set: re-connect(lastHost, lastPort). Exactly one attempt.
  4. 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.

jdwp_wait_for_attach

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.

3. The event listener — started here, stopped on disconnect

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):

  1. Interrupts the thread.
  2. Joins for up to 500 ms.
  3. Fires the VM-death latch and breakpointTracker.fireNextEvent() to wake any tool blocked in jdwp_resume_until_event.

The full event-routing contract is in event-pipeline.md.

4. Disconnecting — disconnect() vs notifyVmDied()

There are two cleanup paths, and the difference matters.

User-initiated: disconnect()

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):

  1. eventListener.stop() (line 429).
  2. breakpointTracker.clearAll(vm.eventRequestManager()) deletes live JDI requests, falling back to in-memory reset() if the VM is already dead (lines 434-440).
  3. Clears watcherManager, markedInstances, objectCache, classpath cache, discoveredJdkPath, targetMajorVersion, eventHistory (lines 442-448).
  4. Calls vm.dispose() for a clean JDWP teardown (lines 451-456). Best-effort: any exception is swallowed because the VM may already be gone.
  5. Resets lastHost / lastPort to 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.

VM-death: notifyVmDied()

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's ensureConnected() can auto-reconnect after the target restarts.
  • It preserves eventHistory — so the VM_DEATH entry 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.

5. jdwp_reset — clear without disconnecting

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 triggersFiredAtLeastOnce memory
  • 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).

6. The clearing matrix

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.

7. Re-attach scenarios

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 keeps lastHost/lastPort. The next tool call's getVM() runs ensureConnected() and reconnects automatically. Breakpoints from before the restart are gone (they lived in the dead VM); the agent re-sets them.
  • Agent calls jdwp_disconnect then jdwp_connect host port. Same as a fresh attach — caches were fully cleared in step 1, then connect() rebuilds everything.
  • Agent re-runs jdwp_connect host port while already attached to the same target. No-op short-circuit at JDIConnectionService.java:267-271. The session continues unchanged. (Use jdwp_reset if the intent is "clear state without dropping the connection".)
  • Agent runs jdwp_connect host2 port2 while attached to host1 port1. The "different target" column. cleanupSessionState runs (clears everything, including marks), then the attach to the new target proceeds.

8. Where logs go

Logback is configured in jdwp-mcp-server/src/main/resources/logback-spring.xml. Two appenders, root level INFO:

  • STDERRSystem.err only (lines 4-9). Never stdout, which carries MCP JSON.
  • FILE — path is ${LOG_PATH:-${java.io.tmpdir}/mcp-jdwp-inspector.log} (line 13). Plain FileAppender, 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.