Repository: langchain4j/langchain4j (Java)

Issue:
[FEATURE] Docker MCP Transport
**Is your feature request related to a problem? Please describe.**
Current MCP transports are HTTP and Stdio.
Most existing MCP servers are provided as Stdio.
But Stdio uses a Process under the cover which is not secure (can access the host) and mandate to have some tool installed (node, docker, ...).

Many existing MCP servers also provide a Docker container (afaik, the Stdio transport is often used with the `docker` command). Having a Docker transport would be more secured (isolated container) and would not mandate any tool installed on the host.

**Describe the solution you'd like**
Provides a Docker transport, based on the Java Docker client.

**Describe alternatives you've considered**
Using the Stdio transport.

**Additional context**
I'm working at Kestra which uses Langchain4J for its AI plugin.
On secured installation, the Stdio transport is excluded as we prohibit starting new processes for security reason.

I'm currently working on a prototype, I would be able to provide a PR for this feature. 




Fix (patch):
diff --git a/docs/docs/tutorials/mcp.md b/docs/docs/tutorials/mcp.md
index 97084c0a0ad..b808d52842e 100644
--- a/docs/docs/tutorials/mcp.md
+++ b/docs/docs/tutorials/mcp.md
@@ -14,6 +14,9 @@ The protocol specifies two types of transport, both of these are supported:
   can run an MCP server as a local subprocess and
   communicate with it directly via standard input/output.
 
+Additionally, LangChain4J supports a Docker stdio transport that can use a stdio MCP server distributed as a 
+container image.
+
 LangChain4j also supports the legacy 
 [HTTP/SSE transport](https://modelcontextprotocol.io/specification/2024-11-05/basic/transports#http-with-sse),
 but this is deprecated and will be removed in the future.
@@ -62,6 +65,25 @@ McpTransport transport = new HttpMcpTransport.Builder()
     .build();
 ```
 
+For the Docker stdio transport, you first need to add a module to your pom.xml:
+
+```xml
+<dependency>
+    <groupId>dev.langchain4j</groupId>
+    <artifactId>langchain4j-mcp-docker</artifactId>
+</dependency>
+```
+
+Then you need to create a Docker transport:
+
+```java
+McpTransport transport = new DockerMcpTransport.Builder()
+    .image("mcp/time")
+    .dockerHost("unix:///var/run/docker.sock")
+    .logEvents(true) // if you want to see the traffic in the log
+    .build();
+```
+
 ### MCP Client
 
 To create an MCP client from the transport:
diff --git a/langchain4j-bom/pom.xml b/langchain4j-bom/pom.xml
index b922c860bec..d9736a6970e 100644
--- a/langchain4j-bom/pom.xml
+++ b/langchain4j-bom/pom.xml
@@ -263,6 +263,12 @@
                 <version>1.7.0-beta13-SNAPSHOT</version>
             </dependency>
 
+            <dependency>
+                <groupId>dev.langchain4j</groupId>
+                <artifactId>langchain4j-mcp-docker</artifactId>
+                <version>1.7.0-beta13-SNAPSHOT</version>
+            </dependency>
+
             <dependency>
                 <groupId>dev.langchain4j</groupId>
                 <artifactId>langchain4j-milvus</artifactId>
diff --git a/langchain4j-mcp-docker/pom.xml b/langchain4j-mcp-docker/pom.xml
new file mode 100644
index 00000000000..e6b75b60f60
--- /dev/null
+++ b/langchain4j-mcp-docker/pom.xml
@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>dev.langchain4j</groupId>
+        <artifactId>langchain4j-parent</artifactId>
+        <version>1.7.0-beta13-SNAPSHOT</version>
+        <relativePath>../langchain4j-parent/pom.xml</relativePath>
+    </parent>
+
+
+    <artifactId>langchain4j-mcp-docker</artifactId>
+    <name>LangChain4j :: Integration :: Model Context Protocol :: Docker transport</name>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>commons-io</groupId>
+                <artifactId>commons-io</artifactId>
+                <version>2.19.0</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>dev.langchain4j</groupId>
+            <artifactId>langchain4j-mcp</artifactId>
+            <version>1.7.0-beta13-SNAPSHOT</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.github.docker-java</groupId>
+            <artifactId>docker-java</artifactId>
+            <version>3.5.3</version>
+            <exclusions>
+                <exclusion>
+                    <groupId>com.github.docker-java</groupId>
+                    <artifactId>docker-java-transport-jersey</artifactId>
+                </exclusion>
+                <exclusion>
+                    <groupId>com.github.docker-java</groupId>
+                    <artifactId>docker-java-transport-netty</artifactId>
+                </exclusion>
+            </exclusions>
+        </dependency>
+        <dependency>
+            <groupId>com.github.docker-java</groupId>
+            <artifactId>docker-java-transport-httpclient5</artifactId>
+            <version>3.5.3</version>
+        </dependency>
+
+    </dependencies>
+
+</project>
diff --git a/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerMcpTransport.java b/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerMcpTransport.java
new file mode 100644
index 00000000000..b93bc917229
--- /dev/null
+++ b/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerMcpTransport.java
@@ -0,0 +1,369 @@
+package dev.langchain4j.mcp.client.transport.docker;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.api.command.CreateContainerCmd;
+import com.github.dockerjava.api.command.CreateContainerResponse;
+import com.github.dockerjava.api.command.InspectContainerResponse;
+import com.github.dockerjava.api.command.PullImageResultCallback;
+import com.github.dockerjava.api.model.Bind;
+import com.github.dockerjava.api.model.HostConfig;
+import com.github.dockerjava.api.model.Volume;
+import com.github.dockerjava.core.DefaultDockerClientConfig;
+import com.github.dockerjava.core.DockerClientConfig;
+import com.github.dockerjava.core.DockerClientImpl;
+import com.github.dockerjava.core.NameParser;
+import com.github.dockerjava.httpclient5.ApacheDockerHttpClient;
+import com.github.dockerjava.transport.DockerHttpClient;
+import dev.langchain4j.mcp.client.protocol.McpClientMessage;
+import dev.langchain4j.mcp.client.protocol.McpInitializationNotification;
+import dev.langchain4j.mcp.client.protocol.McpInitializeRequest;
+import dev.langchain4j.mcp.client.transport.McpOperationHandler;
+import dev.langchain4j.mcp.client.transport.McpTransport;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.PipedInputStream;
+import java.io.PipedOutputStream;
+import java.time.Duration;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+public class DockerMcpTransport  implements McpTransport {
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Logger log = LoggerFactory.getLogger(DockerMcpTransport.class);
+
+    private final String dockerHost;
+    private final String dockerConfig;
+    private final String dockerContext;
+    private final String dockerCertPath;
+    private final Boolean dockerTlsVerify;
+    private final String registryEmail;
+    private final String registryUsername;
+    private final String registryPassword;
+    private final String registryUrl;
+    private final String apiVersion;
+
+    private final String image;
+    private final List<String> command;
+    private final Map<String, String> environment;
+    private final boolean logEvents;
+    private final List<String> binds;
+    private final Duration attachTimeout;
+
+    private volatile McpOperationHandler messageHandler;
+    private volatile String containerId;
+    private volatile DockerClient dockerClient;
+
+    public DockerMcpTransport(DockerMcpTransport.Builder builder) {
+        this.dockerHost = builder.dockerHost;
+        this.dockerConfig = builder.dockerConfig;
+        this.dockerContext = builder.dockerContext;
+        this.dockerCertPath = builder.dockerCertPath;
+        this.dockerTlsVerify = builder.dockerTlsVerify;
+        this.registryEmail = builder.registryEmail;
+        this.registryUsername = builder.registryUsername;
+        this.registryPassword = builder.registryPassword;
+        this.registryUrl = builder.registryUrl;
+        this.apiVersion = builder.apiVersion;
+
+        this.image = builder.image;
+        this.command = builder.command;
+        this.environment = builder.environment;
+        this.logEvents = builder.logEvents;
+        this.binds = builder.binds;
+        this.attachTimeout = builder.attachTimeout;
+    }
+
+    @Override
+    public void start(McpOperationHandler messageHandler) {
+        this.messageHandler = messageHandler;
+        log.debug("Starting docker container with host: {}, image: {}, command: {}", dockerHost, image, command);
+        DockerClientConfig config = DefaultDockerClientConfig.createDefaultConfigBuilder()
+                .withDockerHost(dockerHost)
+                .withDockerConfig(dockerConfig)
+                .withDockerContext(dockerContext)
+                .withDockerCertPath(dockerCertPath)
+                .withDockerTlsVerify(dockerTlsVerify)
+                .withRegistryEmail(registryEmail)
+                .withRegistryUsername(registryUsername)
+                .withRegistryPassword(registryPassword)
+                .withRegistryUrl(registryUrl)
+                .withApiVersion(apiVersion)
+                .build();
+        DockerHttpClient httpClient = new ApacheDockerHttpClient.Builder().dockerHost(config.getDockerHost()).build();
+        this.dockerClient = DockerClientImpl.getInstance(config, httpClient);
+
+        var imageNameWithoutTag = getImageNameWithoutTag(image);
+        var parsedTagFromImage = NameParser.parseRepositoryTag(image);
+        // pullImageCmd without the tag (= repository) to avoid being redundant with withTag below
+        // and prevent errors with Podman trying to pull "image:tag:tag"
+        try (var pull = dockerClient.pullImageCmd(imageNameWithoutTag)) {
+            var tag = !parsedTagFromImage.tag.isEmpty() ? parsedTagFromImage.tag : "latest";
+            var repository = pull.getRepository().contains(":") ? pull.getRepository().split(":")[0] : pull.getRepository();
+            pull.withTag(tag).exec(new PullImageResultCallback()).awaitCompletion();
+            log.trace("Image pulled [{}:{}]", repository, tag);
+        } catch (InterruptedException e) {
+            throw new RuntimeException(e);
+        }
+
+        HostConfig hostConfig = new HostConfig()
+                .withBinds(binds.stream().map(bind -> Bind.parse(bind)).toList());
+        CreateContainerCmd container = dockerClient.createContainerCmd(image)
+                .withTty(false)
+                .withAttachStdin(true)
+                .withAttachStdout(true)
+                .withAttachStderr(true)
+                .withStdinOpen(true)
+                .withCmd(command.toArray(new String[0]))
+                .withEnv(environment.entrySet().stream().map(r -> r.getKey() + "=" + r.getValue()).toList())
+                .withHostConfig(hostConfig);
+        try {
+            CreateContainerResponse exec = container.exec();
+            this.containerId = exec.getId();
+
+            dockerClient.startContainerCmd(containerId).exec();
+            dockerClient.waitContainerCmd(containerId).start().awaitStarted();
+            log.debug("ID of the started container: {}", exec.getId());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private String getImageNameWithoutTag(String fullImageName) {
+        if (fullImageName == null || fullImageName.isEmpty()) {
+            return fullImageName;
+        }
+
+        int lastColonIndex = fullImageName.lastIndexOf(':');
+        int firstSlashIndex = fullImageName.indexOf('/');
+        if (lastColonIndex > -1 && (firstSlashIndex == -1 || lastColonIndex > firstSlashIndex)) {
+            return fullImageName.substring(0, lastColonIndex);
+        } else {
+            return fullImageName; // No tag found or the colon is part of the registry host
+        }
+    }
+
+    @Override
+    public CompletableFuture<JsonNode> initialize(McpInitializeRequest operation) {
+        try {
+            String requestString = OBJECT_MAPPER.writeValueAsString(operation);
+            String initializationNotification = OBJECT_MAPPER.writeValueAsString(new McpInitializationNotification());
+            final CompletableFuture<JsonNode> execute = execute(requestString, operation.getId());
+            return execute.thenCompose(originalResponse -> {
+                final CompletableFuture<JsonNode> execute1 = execute(initializationNotification, null);
+                return execute1.thenCompose(nullNode -> CompletableFuture.completedFuture(originalResponse));
+            });
+        } catch (JsonProcessingException e) {
+            return CompletableFuture.failedFuture(e);
+        }
+    }
+
+    @Override
+    public CompletableFuture<JsonNode> executeOperationWithResponse(McpClientMessage operation) {
+        try {
+            String requestString = OBJECT_MAPPER.writeValueAsString(operation);
+            return execute(requestString, operation.getId());
+        } catch (JsonProcessingException e) {
+            return CompletableFuture.failedFuture(e);
+        }
+    }
+
+    @Override
+    public void executeOperationWithoutResponse(McpClientMessage operation) {
+        try {
+            String requestString = OBJECT_MAPPER.writeValueAsString(operation);
+            execute(requestString, null);
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Override
+    public void checkHealth() {
+        final InspectContainerResponse inspectContainer = this.dockerClient.inspectContainerCmd(containerId).exec();
+        if (inspectContainer == null || Boolean.FALSE.equals(inspectContainer.getState().getRunning())) {
+            throw new IllegalStateException("Container is not alive");
+        }
+    }
+
+    @Override
+    public void onFailure(Runnable actionOnFailure) {
+        // ignore, for docker transport, we currently don't do reconnection attempts
+    }
+
+    @Override
+    public void close() throws IOException {
+        log.debug("Killing container {}", containerId);
+        dockerClient.killContainerCmd(containerId).exec();
+        log.debug("Deleting container {}", containerId);
+        dockerClient.removeContainerCmd(containerId).exec();
+    }
+
+    private CompletableFuture<JsonNode> execute(String request, Long id) {
+        CompletableFuture<JsonNode> future = new CompletableFuture<>();
+        if (id != null) {
+            messageHandler.startOperation(id, future);
+        }
+
+        try (
+                PipedOutputStream out = new PipedOutputStream();
+                PipedInputStream in = new PipedInputStream(out)
+        ) {
+            DockerResultCallback callback = dockerClient.attachContainerCmd(containerId)
+                    .withStdOut(true)
+                    .withStdErr(true)
+                    .withFollowStream(true)
+                    .withStdIn(in)
+                    .exec(new DockerResultCallback(logEvents, messageHandler));
+            callback.awaitStarted(attachTimeout.toMillis(), TimeUnit.MILLISECONDS);
+
+            out.write((request + "\n").getBytes());
+            out.flush();
+
+            if (id != null) {
+                // if there is an ID, wait for a frame to be sent back from the container
+                callback.awaitCompletion();
+            } else {
+                // if we didn't wait for a frame, we still wait a little to be sure the container receive and process the request
+                Thread.sleep(100);
+            }
+            callback.close();
+            // For messages with null ID, we don't wait for a corresponding response
+            if (id == null) {
+                future.complete(null);
+            }
+        } catch (Exception e) {
+            future.completeExceptionally(e);
+        }
+
+        return future;
+    }
+
+    public static class Builder {
+        private String dockerHost;
+        private String dockerConfig;
+        private String dockerContext;
+        private String dockerCertPath;
+        private Boolean dockerTlsVerify;
+        private String registryEmail;
+        private String registryUsername;
+        private String registryPassword;
+        private String registryUrl;
+        private String apiVersion;
+
+        private String image;
+        private List<String> command;
+        private Map<String, String> environment;
+        private boolean logEvents;
+        private List<String> binds;
+        private Duration attachTimeout;
+
+        public DockerMcpTransport.Builder dockerHost(String dockerHost) {
+            this.dockerHost = dockerHost;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder dockerConfig(String dockerConfig) {
+            this.dockerConfig = dockerConfig;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder dockerContext(String dockerContext) {
+            this.dockerContext = dockerContext;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder dockerCertPath(String dockerCertPath) {
+            this.dockerCertPath = dockerCertPath;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder dockerTslVerify(Boolean dockerTlsVerify) {
+            this.dockerTlsVerify = dockerTlsVerify;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder registryEmail(String registryEmail) {
+            this.registryEmail = registryEmail;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder registryUsername(String registryUsername) {
+            this.registryUsername = registryUsername;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder registryPassword(String registryPassword) {
+            this.registryPassword = registryPassword;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder registryUrl(String registryUrl) {
+            this.registryUrl = registryUrl;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder apiVersion(String apiVersion) {
+            this.apiVersion = apiVersion;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder image(String image) {
+            this.image = image;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder command(List<String> command) {
+            this.command = command;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder environment(Map<String, String> environment) {
+            this.environment = environment;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder logEvents(boolean logEvents) {
+            this.logEvents = logEvents;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder binds(List<String> binds) {
+            this.binds = binds;
+            return this;
+        }
+
+        public DockerMcpTransport.Builder attachTimeout(Duration attachTimeout) {
+            this.attachTimeout = attachTimeout;
+            return this;
+        }
+
+        public DockerMcpTransport build() {
+            if (dockerHost == null || dockerHost.isEmpty()) {
+                throw new IllegalArgumentException("Missing host");
+            }
+            if (image == null || image.isEmpty()) {
+                throw new IllegalArgumentException("Missing image");
+            }
+            if (command == null) {
+                command = List.of();
+            }
+            if (environment == null) {
+                environment = Map.of();
+            }
+            if (binds == null) {
+                binds = List.of();
+            }
+            if (attachTimeout == null) {
+                attachTimeout = Duration.ofSeconds(30);
+            }
+            return new DockerMcpTransport(this);
+        }
+    }
+}
diff --git a/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerResultCallback.java b/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerResultCallback.java
new file mode 100644
index 00000000000..a6837d606a7
--- /dev/null
+++ b/langchain4j-mcp-docker/src/main/java/dev/langchain4j/mcp/client/transport/docker/DockerResultCallback.java
@@ -0,0 +1,83 @@
+package dev.langchain4j.mcp.client.transport.docker;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.github.dockerjava.api.async.ResultCallback;
+import com.github.dockerjava.api.model.Frame;
+import com.github.dockerjava.api.model.StreamType;
+import dev.langchain4j.mcp.client.transport.McpOperationHandler;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+class DockerResultCallback extends ResultCallback.Adapter<Frame> {
+    private static final Logger LOG = LoggerFactory.getLogger(DockerResultCallback.class);
+    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+    private static final Logger trafficLog = LoggerFactory.getLogger("MCP");
+    private static final Pattern NEWLINE_PATTERN = Pattern.compile("([^\\r\\n]+)[\\r\\n]+");
+
+    private final boolean logEvents;
+    private final McpOperationHandler messageHandler;
+
+    private final CountDownLatch countDownLatch = new CountDownLatch(1);
+    private final StringBuilder logAggregator = new StringBuilder();
+
+    public DockerResultCallback(boolean logEvents, McpOperationHandler messageHandler) {
+        this.logEvents = logEvents;
+        this.messageHandler = messageHandler;
+    }
+
+    @Override
+    public void onNext(Frame frame) {
+        String frameStr = new String(frame.getPayload());
+        if (frame.getStreamType() == StreamType.STDERR) {
+            LOG.debug("[ERROR] {}", frameStr);
+        } else if (frame.getStreamType() == StreamType.STDOUT) {
+            this.send(frameStr);
+        }
+    }
+
+    @Override
+    public Adapter<Frame> awaitCompletion() throws InterruptedException {
+        countDownLatch.await();
+        return this;
+    }
+
+    @Override
+    public boolean awaitCompletion(final long timeout, final TimeUnit timeUnit) throws InterruptedException {
+        return countDownLatch.await(timeout, timeUnit);
+    }
+
+    private void send(String line) {
+        if (line !=null && !line.isBlank()) {
+            logAggregator.append(line);
+
+            // we aggregate until we have a newline char at then end of the line
+            if (NEWLINE_PATTERN.matcher(line).matches()) {
+                innerSend();
+            }
+        } else {
+            // an empty line with a non-empty aggregator means we have something to send so we send it
+            if (!logAggregator.isEmpty()) {
+                innerSend();
+            }
+        }
+    }
+
+    private void innerSend() {
+        String message = logAggregator.toString();
+        if (logEvents) {
+            trafficLog.debug("< {}", message);
+        }
+
+        try {
+            messageHandler.handle(OBJECT_MAPPER.readTree(message));
+            logAggregator.setLength(0);
+            countDownLatch.countDown();
+        } catch (JsonProcessingException e) {
+            throw new RuntimeException(e);
+        }
+    }
+}
diff --git a/pom.xml b/pom.xml
index 76253f43485..b653f87984e 100644
--- a/pom.xml
+++ b/pom.xml
@@ -20,6 +20,7 @@
 
         <module>langchain4j-easy-rag</module>
         <module>langchain4j-mcp</module>
+        <module>langchain4j-mcp-docker</module>
 
         <!-- http clients -->
         <module>langchain4j-http-client</module>