Skip to content

Running MCP and nREPL server in a container

Dr. Christian Betz edited this page Jul 3, 2025 · 1 revision

Sandboxing both the nREPL and the MCP Server

The key reason for this approach is to guarantee path consistency. Especially if you already have an established container it may be easier to bring clojure-mcp into the container.

For MCP tooling to function correctly, both the MCP server (which accesses and modifies files) and the nREPL process (which executes code) must share an identical view of the project's file paths.

When the MCP server runs on the host and the nREPL runs in a container, it can be difficult to ensure their views of the filesystem are the same. A file path on your host machine (e.g., /Users/dev/my-project/src/core.clj) might be different inside the container (e.g., /app/src/core.clj). This discrepancy can break the tooling.

By running both the nREPL process and the MCP server inside the same container environment, they inherently share the same filesystem, ensuring all paths are identical. This is the most robust way to ensure compatibility. This setup is more complex, but it resolves path-related issues while also providing stronger security isolation as a secondary benefit.

Requirements

Your project root dir will have a devenv folder like this:

devenv
├── container
│   ├── Containerfile
│   └── run-container.sh
├── mcp
│   └── clj-mcp
│       └── deps.edn
└── entrypoint.sh

(Yes, you can organize those files differently, but for sake of this guide, this is the structure to explain things).

The mcp/clj-mcp/deps.edn is shown in the README.

1. Project Prerequisite: deps.edn

For any Clojure project you wish to use this setup with, ensure its deps.edn file contains the necessary nREPL dependencies and a server startup alias.

Example deps.edn:

{:paths ["src"]
 :aliases
 {:container-nrepl
  {:extra-deps {nrepl/nrepl {:mvn/version "1.1.1"}
                cider/cider-nrepl {:mvn/version "0.49.1"}}
   :extra-paths ["test"]
   :main-opts ["-m" "nrepl.cmdline"
               ;; CIDER middleware is recommended for a better experience
               ;; with many development tools.
               "--middleware" "[cider.nrepl/cider-middleware]"
               "--port" "7888"
               ;; This bind address is required for Docker
               "--bind" "0.0.0.0"]}}}

Important

Please pay attention to the --bind "0.0.0.0".

A container has its own private network. If the nREPL server binds to localhost (127.0.0.1), it will only accept connections from within that same container. That would be ok for Clojure MCP to connect to this REPL. If you want to connect to the containerized nREPL from an IDE running on your host, you need to bind to 0.0.0.0 and expose the nREPL port: By binding to 0.0.0.0, the server listens for connections from any network interface, which allows it to accept the connection forwarded from your host machine via port mapping (-p 7888:7888).

Caution

Never use --bind "0.0.0.0" when running an nREPL directly on your host machine (outside of a container) on an untrusted network. It would expose the nREPL to any other device on your local network (e.g., your public Wi-Fi), creating a major security vulnerability.

2. The Containerfile

Create a file named Containerfile (with no extension) in a dedicated directory. This is the modern, tool-agnostic name for what was traditionally called a Dockerfile. Both Docker and Podman recognize this name. This file defines our generic Clojure environment.

Containerfile Contents:

 Use the official Clojure tools-deps image
FROM clojure:temurin-21-tools-deps

 Setup an appropriate workdir inside the container.
WORKDIR /usr/app

 Expose the nREPL port
EXPOSE 7888

 expose https sse port for clojure-mcp server.
 Comment or remove, if you want to follow approach 1,
 running clojure-mcp outside of the container.
EXPOSE 7080

 The command to run when the container starts.
CMD ["clojure", "-M:nrepl"]

How to Choose a Base Image

You can find a full list of available base images and their tags on the official Clojure image page on Docker Hub. The choice of base image is a trade-off between convenience and reproducibility.

  • General Tags (e.g., clojure:temurin-21-tools-deps): This tag is great for getting started. It ensures you are on a specific major Java version (Temurin 21) with the tools-deps tooling, and it will receive updates (like security patches) over time. The downside is that a future docker pull might introduce a change that breaks your build.
  • Specific Tags (e.g., clojure:tools-deps-1.12.1.1550): This tag pins the exact version of the Clojure tools. This guarantees perfect reproducibility—your build will work exactly the same a year from now as it does today. This is the best practice for production environments and for ensuring all team members have an identical setup. The trade-off is that you won't automatically get security updates; you must manually update the tag in your Containerfile to a newer version.

3. Building and Running

Follow these two steps to get your nREPL server running.

Step 1: Build the Container Image (Only Once)

Navigate to the directory where you saved your Containerfile and run the build command. You only need to do this once to create the reusable image.

Use

docker build -t clojuremcp-dev-env devenv/container/

from your project root for Docker and

podman build -t clojuremcp-dev-env devenv/container/

for podman.

We've tagged (-t) the image as clojuremcp-dev-env for clarity. Both Docker and Podman will automatically find the Containerfile.

Step 2: Run the Container in Your Project Directory

You should mount both your project and the mcp-clojure directory (as <project-root>/devenv/mcp/clj-mcp/) to your container. This mcp-clojure deps.edn is unchanged.

The first script, devenv/entrypoint.sh, will run inside the container. Its job is to start both the nREPL and the Clojure-MCP server processes.

The second script, devenv/container/run-container.sh, runs on your host machine. Its purpose is to launch the container with all the correct volume mounts and port mappings.

devenv/entrypoint.sh Contents:

!/usr/bin/env bash

get_script_dir()
{
    local SOURCE_PATH="${BASH_SOURCE[0]}"
    local SYMLINK_DIR
    local SCRIPT_DIR
    # Resolve symlinks recursively
    while [ -L "$SOURCE_PATH" ]; do
        # Get symlink directory
        SYMLINK_DIR="$( cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd )"
        # Resolve symlink target (relative or absolute)
        SOURCE_PATH="$(readlink "$SOURCE_PATH")"
        # Check if candidate path is relative or absolute
        if [[ $SOURCE_PATH != /* ]]; then
            # Candidate path is relative, resolve to full path
            SOURCE_PATH=$SYMLINK_DIR/$SOURCE_PATH
        fi
    done
    # Get final script directory path from fully resolved source path
    SCRIPT_DIR="$(cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd)"
    echo "$SCRIPT_DIR"
}

script_dir="$(get_script_dir)"
PROJECT_DIR="$(dirname $script_dir)"

echo "Project directory: $PROJECT_DIR"

 Create logs directory if it doesn't exist
mkdir -p ${PROJECT_DIR}/.logs
mv ${PROJECT_DIR}/.logs/nrepl.out ${PROJECT_DIR}/.logs/nrepl.out.bak 2>/dev/null || true
mv ${PROJECT_DIR}/.logs/mcp-sse.out ${PROJECT_DIR}/.logs/mcp-sse.out.bak 2>/dev/null || true

touch ${PROJECT_DIR}/.logs/nrepl.out
touch ${PROJECT_DIR}/.logs/mcp-sse.out

tail -f ${PROJECT_DIR}/.logs/nrepl.out ${PROJECT_DIR}/.logs/mcp-sse.out &

echo "Starting nREPL server ..."
( cd ${PROJECT_DIR}/ && \
  nohup clojure -M:container-nrepl >> ${PROJECT_DIR}/.logs/nrepl.out 2>&1 & )
sleep 10
echo "Starting Clojure-MCP server..."
( cd ${PROJECT_DIR}/devenv/mcp/clj-mcp/ && \
  clojure -X:mcp >> ${PROJECT_DIR}/.logs/mcp-sse.out 2>&1)

devenv/container/run-container.sh Contents:

!/usr/bin/env bash

get_script_dir()
{
    local SOURCE_PATH="${BASH_SOURCE[0]}"
    local SYMLINK_DIR
    local SCRIPT_DIR
    # Resolve symlinks recursively
    while [ -L "$SOURCE_PATH" ]; do
        # Get symlink directory
        SYMLINK_DIR="$( cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd )"
        # Resolve symlink target (relative or absolute)
        SOURCE_PATH="$(readlink "$SOURCE_PATH")"
        # Check if candidate path is relative or absolute
        if [[ $SOURCE_PATH != /* ]]; then
            # Candidate path is relative, resolve to full path
            SOURCE_PATH=$SYMLINK_DIR/$SOURCE_PATH
        fi
    done
    # Get final script directory path from fully resolved source path
    SCRIPT_DIR="$(cd -P "$( dirname "$SOURCE_PATH" )" >/dev/null 2>&1 && pwd)"
    echo "$SCRIPT_DIR"
}

script_dir="$(get_script_dir)"
PROJECT_DIR="$(dirname $(dirname $script_dir ))"

echo "Project directory: $PROJECT_DIR"

podman run \
  --rm \
  -v "$PROJECT_DIR:/usr/app/":Z \
  -v "$HOME/.m2:/root/.m2" \
  -p 127.0.0.1:7080:7080 \
  -p 127.0.0.1:7888:7888 \
  --name clojure-repl \
  -it \
  clojure-dev-env \
  /usr/app/devenv/entrypoint.sh

So, you got your setup up and running.

You can connect your host-based IDE to the nREPL running at localhost, port 7088.

You can also connect your agent tooling to http://localhost:7080/sse.

And now your good to go, sandboxing achieved!

Proxying SSE to STDIO (e.g., for Claude Desktop)

Some desktop tools cannot directly communicate with the MCP server's web endpoint (SSE). mcp-proxy acts as a translator, converting the server's web-based messages into a standard input/output stream that these tools can understand.

You can easily achieve this by running mcp-proxy as a startup command, e.g. from Claude Desktop, by using this mcp setup (with a matching <path-to> set first:

{
	"mcpServers": {
		"clojure-repl": {
			"command": "<path-to>/mcp-proxy",
			"args": [
				"http://localhost:7080/sse"
			],
			"env": {}
		}
	}
}
Clone this wiki locally