CARVIEW |
Navigation Menu
-
-
Notifications
You must be signed in to change notification settings - Fork 53
Running MCP and nREPL server in a container
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.
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.
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 to0.0.0.0
and expose the nREPL port: By binding to0.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.
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 thetools-deps
tooling, and it will receive updates (like security patches) over time. The downside is that a futuredocker 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 yourContainerfile
to a newer version.
Follow these two steps to get your nREPL server running.
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
.
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 https://localhost:7080/sse.
And now your good to go, sandboxing achieved!
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": [
"https://localhost:7080/sse"
],
"env": {}
}
}
}