Repository: langchain4j/langchain4j (2026-07-11T12:56:15Z)

Issue:
[BUG] MCP stdio/HTTP transports leak the previous subprocess/SSE connection on every reconnect
**Describe the bug**

When an MCP client reconnects (which the built-in auto health check does on failure, by default every 30s), `McpTransport.start(...)` is called again, but the stdio and legacy HTTP transports do not tear down the previous connection before creating a new one, leaking it on every reconnect.

- `StdioMcpTransport.start(...)` unconditionally does `process = processBuilder.start()` and submits new `JsonRpcIoHandler` / `ProcessStderrHandler` tasks, overwriting the `process`, `jsonRpcIoHandler`, and `stderrHandler` fields without destroying/closing the previous ones. `close()` only destroys the *last* process, so every prior process (and its two handler tasks) is leaked.
- `HttpMcpTransport.start(...)` overwrites `mcpSseEventListener` with a new `EventSource` without cancelling the previous one, leaking an open SSE connection per reconnect.

The reconnect path is: `DefaultMcpClient` health check → `checkHealth()` (which does `transport.checkHealth()` **and** sends a `ping` with a `pingTimeout`) → on failure `triggerReconnection()` → `initialize()` → `transport.start(...)`. Because the health check includes a ping, a *slow-but-alive* server whose ping exceeds `pingTimeout` triggers a reconnect while the old subprocess is still alive — so `StdioMcpTransport` leaks a live subprocess (plus two executor tasks) roughly every health-check interval, unbounded.

The affected fields are also written from the health-check thread but are not `volatile`.

**Log and Stack trace**

No exception — a resource leak (accumulating subprocesses / SSE connections / executor tasks).

**To Reproduce**

Call `StdioMcpTransport.start(...)` twice (as reconnection does):

```java
StdioMcpTransport transport = new StdioMcpTransport.Builder()
        .command(List.of("cat")) // stays alive reading stdin
        .environment(Map.of())
        .build();
transport.start(handler);
Process first = transport.getProcess();
transport.start(handler);              // simulate reconnect
// BUG: `first` is still alive — it was never destroyed
assertThat(first.isAlive()).isTrue();  // leaked
```

**Expected behavior**

`start(...)` should tear down any previous process / I/O handlers / SSE `EventSource` before creating new ones, so reconnection does not accumulate leaked resources.

**Please complete the following information:**
- LangChain4j version: main (1.x)
- Java version: 17

**Additional context**

Found via a code audit of the MCP module, not a specific user report. Will open a PR that tears down the previous process/handlers (stdio) and cancels the previous `EventSource` (legacy HTTP) at the top of `start(...)`, and makes the relevant fields `volatile`. The newer `StreamableHttpMcpTransport.start(...)` only stores the handler and is not affected.


Discussion:

Patch:
diff --git a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/HttpMcpTransport.java b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/HttpMcpTransport.java
index 8aa0edc61af..f9861cdff4b 100644
--- a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/HttpMcpTransport.java
+++ b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/http/HttpMcpTransport.java
@@ -45,7 +45,8 @@ public class HttpMcpTransport implements McpTransport {
     private final OkHttpClient client;
     private final boolean logResponses;
     private final boolean logRequests;
-    private EventSource mcpSseEventListener;
+    // (re)assigned by start(), which may be invoked again from the reconnect thread, so volatile
+    private volatile EventSource mcpSseEventListener;
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final String CONTENT_TYPE = "Content-Type";
     private static final String CONTENT_TYPE_JSON = "application/json";
@@ -74,6 +75,12 @@ public HttpMcpTransport(Builder builder) {
 
     @Override
     public void start(McpOperationHandler messageHandler) {
+        // start() may be called again during reconnection. Cancel any previous SSE channel first,
+        // otherwise the old EventSource (and its connection) is leaked on every reconnect.
+        if (mcpSseEventListener != null) {
+            mcpSseEventListener.cancel();
+            mcpSseEventListener = null;
+        }
         this.messageHandler = messageHandler;
         mcpSseEventListener = startSseChannel(logResponses);
     }
diff --git a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/ProcessStderrHandler.java b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/ProcessStderrHandler.java
index 067fca5948c..77f809ef53f 100644
--- a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/ProcessStderrHandler.java
+++ b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/ProcessStderrHandler.java
@@ -10,11 +10,15 @@
 class ProcessStderrHandler implements Runnable, Closeable {
 
     private final Process process;
-    private static final Logger log = LoggerFactory.getLogger(ProcessStderrHandler.class);
+    // Resolved per instance (in the constructor) rather than in a static initializer so that the
+    // logger reflects any LoggerFactory configuration in effect when the handler is created, and is
+    // not fixed at class-load time (which also makes this class testable in isolation).
+    private final Logger log;
     private volatile boolean closed = false;
 
     public ProcessStderrHandler(final Process process) {
         this.process = process;
+        this.log = LoggerFactory.getLogger(ProcessStderrHandler.class);
     }
 
     @Override
diff --git a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java
index db17796ffef..9e0ef123e5e 100644
--- a/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java
+++ b/langchain4j-mcp/src/main/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransport.java
@@ -27,14 +27,16 @@ public class StdioMcpTransport implements McpTransport {
 
     private final List<String> command;
     private final Map<String, String> environment;
-    private Process process;
-    private JsonRpcIoHandler jsonRpcIoHandler;
+    // These are (re)assigned by start(), which may be invoked again from the health-check thread
+    // during reconnection, so they are volatile to ensure visibility across threads.
+    private volatile Process process;
+    private volatile JsonRpcIoHandler jsonRpcIoHandler;
     private final boolean logEvents;
     private final Logger logger;
     private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
     private static final Logger log = LoggerFactory.getLogger(StdioMcpTransport.class);
     private volatile McpOperationHandler messageHandler;
-    private ProcessStderrHandler stderrHandler;
+    private volatile ProcessStderrHandler stderrHandler;
     private ExecutorService executorService;
     private boolean shouldShutdownExecutorService;
 
@@ -52,6 +54,10 @@ public StdioMcpTransport(Builder builder) {
 
     @Override
     public void start(McpOperationHandler messageHandler) {
+        // start() may be called again during reconnection (e.g. from the health-check thread).
+        // Tear down any previous process and I/O handlers first, otherwise the old subprocess and
+        // its handler tasks are leaked on every reconnect.
+        stopCurrentProcess();
         this.messageHandler = messageHandler;
         log.debug("Starting process: {}", command);
         ProcessBuilder processBuilder = new ProcessBuilder(command);
@@ -130,16 +136,35 @@ public void onFailure(Runnable actionOnFailure) {
         // ignore, for stdio transport, we currently don't do reconnection attempts
     }
 
-    @Override
-    public void close() throws IOException {
-        try {
-            stderrHandler.close();
-        } catch (Exception ignored) {
+    /**
+     * Closes the current I/O handlers and destroys the current subprocess, if any.
+     * Does not shut down the (potentially shared) executor service, so it is safe to call
+     * before starting a replacement process during reconnection.
+     */
+    private void stopCurrentProcess() {
+        if (stderrHandler != null) {
+            try {
+                stderrHandler.close();
+            } catch (Exception ignored) {
+            }
+            stderrHandler = null;
         }
-        try {
-            jsonRpcIoHandler.close();
-        } catch (Exception ignored) {
+        if (jsonRpcIoHandler != null) {
+            try {
+                jsonRpcIoHandler.close();
+            } catch (Exception ignored) {
+            }
+            jsonRpcIoHandler = null;
+        }
+        if (process != null) {
+            process.destroy();
+            process = null;
         }
+    }
+
+    @Override
+    public void close() throws IOException {
+        stopCurrentProcess();
         if (executorService != null && shouldShutdownExecutorService) {
             executorService.shutdown();
             try {
@@ -151,7 +176,6 @@ public void close() throws IOException {
                 Thread.currentThread().interrupt();
             }
         }
-        process.destroy();
     }
 
     public static Builder builder() {


Test patch:
diff --git a/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransportTest.java b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransportTest.java
new file mode 100644
index 00000000000..18cbec49e27
--- /dev/null
+++ b/langchain4j-mcp/src/test/java/dev/langchain4j/mcp/client/transport/stdio/StdioMcpTransportTest.java
@@ -0,0 +1,61 @@
+package dev.langchain4j.mcp.client.transport.stdio;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+import dev.langchain4j.mcp.client.transport.McpOperationHandler;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+class StdioMcpTransportTest {
+
+    /**
+     * When {@link StdioMcpTransport#start} is called again (as happens during reconnection triggered
+     * by the health check), the previous subprocess must be destroyed instead of leaked.
+     */
+    @Test
+    @DisabledOnOs(OS.WINDOWS)
+    void restarting_should_destroy_the_previous_process() throws IOException, InterruptedException {
+        // Use a private executor so the background I/O handler threads spawned for the subprocess do
+        // not leak into the shared default executor and interfere with other tests.
+        ExecutorService executorService = Executors.newCachedThreadPool();
+        try {
+            // 'cat' with no arguments reads stdin indefinitely, so the process stays alive until destroyed
+            StdioMcpTransport transport = new StdioMcpTransport.Builder()
+                    .command(List.of("cat"))
+                    .environment(Map.of())
+                    .executorService(executorService)
+                    .build();
+
+            McpOperationHandler messageHandler = mock(McpOperationHandler.class);
+
+            transport.start(messageHandler);
+            Process firstProcess = transport.getProcess();
+            assertThat(firstProcess.isAlive()).isTrue();
+
+            // Restart (simulating a reconnection)
+            transport.start(messageHandler);
+            Process secondProcess = transport.getProcess();
+
+            // The previous process must have been torn down, not leaked
+            assertThat(secondProcess).isNotSameAs(firstProcess);
+            assertThat(firstProcess.waitFor(5, TimeUnit.SECONDS))
+                    .as("the previous process should have been destroyed on restart")
+                    .isTrue();
+            assertThat(firstProcess.isAlive()).isFalse();
+            assertThat(secondProcess.isAlive()).isTrue();
+
+            transport.close();
+            secondProcess.waitFor(5, TimeUnit.SECONDS);
+        } finally {
+            executorService.shutdownNow();
+        }
+    }
+}