CARVIEW |
Navigation Menu
-
-
Notifications
You must be signed in to change notification settings - Fork 53
Running your nREPL server in a Docker container
The AI can be pretty unpredictable about what it does via the MCP tools, and that's why many AI assistants would ask for a user confirmation before using tools with potentially harmful side effects. If you don't want to always review the commands or if you simply want a better safety by default, it is wise to limit those side effects more or less to only the intended cases. Running the MCP server and the nREPL in an isolated, containerized environment is a powerful way to achieve this.
Beyond just sandboxing an unpredictable AI, adopting a containerized development environment offers substantial benefits for individual developers and teams:
- Reproducibility: A containerized environment ensures that every team member uses the exact same development setup, from the OS libraries to the language runtime and tooling versions. This eliminates the classic "but it works on my machine" problem, creating a consistent experience for everyone.
- Reduced Friction with CI/CD: When your development environment is a container, it can be the same environment used by your Continuous Integration (CI) tooling. This alignment dramatically reduces friction and ensures that code that runs locally will also run in the pipeline.
- Evolving and Branch-Specific Toolchains: Containers make it easy to manage and evolve your development toolchain. You can test new tools or versions in an isolated environment without affecting your host machine. This also allows for powerful workflows like using branch-specific tooling; for instance, you could add a static site generator to a docs branch to build documentation, without that tool being present in the main development environment.
- Supply Chain Security: By isolating the development environment, you create a barrier against a subgroup of supply chain attacks. A compromised dependency would be contained within the sandbox, unable to access or exfiltrate data from your broader host system.
In essence, while the initial motivation might be to safely manage an AI, adopting a containerized workflow brings a host of modern development practices that enhance security, collaboration, and consistency.
There are different solutions for this problem, and Docker is only one of them. While this guide focuses on Docker, it's worth being aware of other approaches.
You might consider other container platforms like Podman, which provides a daemonless alternative and is compatible with Docker commands.
On macOS and Windows, container platforms require a Linux virtual machine (VM) to run. This means you have two layers of abstraction: the guest VM on your developer machine, and the containers running inside that VM. For some, it might be a more straightforward approach to not use containers at all and instead use a lightweight VM directly as the sandboxed environment.
However, this guide will focus on Docker as it is a very common and well-documented solution in the industry.
When using Docker, there are two primary approaches to sandboxing your Clojure development environment in the context of an MCP server like ClojureMCP. Your choice depends on your desired level of isolation and complexity.
This is the most straightforward and common approach. The core idea is to run your Clojure project's nREPL server inside a container, while your development tool (ClojureMCP, VS Code, Emacs) and the MCP server run directly on your host machine.
ClojureMCP is designed so that you only need to sandbox the nREPL process to contain the execution of AI-generated code. Your editor and the ClojureMCP server connect to the containerized nREPL via a published port. This guide focuses primarily on this method.
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. In the first approach this is done using -v "$PWD:$PWD"
, as you will see later on.
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 tools.
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.
The objective is to create a generic, reusable Docker image for Clojure development. This guide will help you build a container that can be launched from any Clojure project directory. The container runs a properly configured nREPL server, allowing you to connect from development tools like ClojureMCP, VS Code (with Calva), or Emacs (with CIDER) for seamless interactive development. Changes made by ClojureMCP will be visible on the host's file system, while preventing ClojureMCP access to files outside the sandbox.
Note It's very important to remember that the ClojureMCP process and the nREPL process need to be looking at the same directory structure for the ClojureMCP tools to work.
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
{: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"
as this is very IMPORTANT for this to work.
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. 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 the Docker port mapping (-p 7888:7888
).
Even if you use "Approach 2" and run your MCP server inside the container, keeping the nREPL port accessible from the host is a good idea. It allows you to connect other tools from your host machine, like an editor or an IDE (e.g., IntelliJ with Cursive), to the same interactive REPL session.
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. Step 2 is shown for both approaches. Pick yours and follow the instructions accordingly.
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
.
Now, from your project root directory (that has the required deps.edn
setup) run the following command (for approach 1):
docker run --rm -p 7888:7888 -it -v "$PWD:$PWD" -w "$PWD" clojuremcp-dev-env
TIP: You can make this a bash script
Your nREPL server is now running. From your host machine, you can connect ClojureMCP to the nREPL at localhost:7888
.
No changes to the claude_desktop_config.json
are needed with this setup.
TIP: to exclude sensitive files from the sandboxed environment, put them in a folder outside of the volume, and symlink them (e.g. ln -s ~/.private/local.edn /dev/resources/local.edn
). Symlinks work locally, but are not included in the sandbox.
Your project root dir will have a devenv
folder like this:
devenv
├── container
│ ├── Containerfile
│ └── start.sh
├── mcp
│ └── clj-mcp
│ └── deps.edn
├── README.md
└── start.sh
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/start.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/start.sh
, runs on your host machine. Its purpose is to launch the container with all the correct volume mounts and port mappings.
We recommend having the script at <project-root>/devenv/start.sh
. Make sure to call the nREPL with the correct aliases.
!/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:dev:test: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)
Now, from your project root directory (that has the required deps.edn
setup) start your container (for approach 2). We recommend keeping this script next to the Containerfile
in <project-root>/devenv/container/start.sh
!/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/start.sh
So, you got your setup up and running. Some tools will need to bridge the sse endpoint to an stdio endpoint for the MCP server, e.g. Claude desktop. We use mcp-proxy for this.
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": {}
}
}
}
You can easily make other host directories available inside your container by adding more -v
(or --volume
) flags to the docker run
command. This is useful for linking projects or caching dependencies.
Example 1: Linking Another Clojure Project
Imagine your local file structure has two related projects:
/path/to/your/
├── current-project/ <-- You are here ($PWD)
└── other-clj-project/ <-- You want to access this
You can mount other-clj-project
into a specific path inside the container, like /other-clj-project
. Note that the relative path ../
is resolved on your host machine.
Using backslashes for readability
docker run --rm -it \
-p 7888:7888 \
-v "$PWD:$PWD" \
-w "$PWD" \
-v "$PWD/../other-clj-project:$PWD/../other-clj-project" \
clojuremcp-dev-env
Now, from within the REPL, you can access files from the second project at the path /other-clj-project
.
Example 2: Caching Dependencies (Recommended)
To avoid re-downloading project dependencies every time you start the container, you can mount your local Maven cache (~/.m2
) into it.
Using backslashes for readability
docker run --rm -it \
-p 7888:7888 \
-v "$PWD:$PWD" \
-w "$PWD" \
-v "$HOME/.m2:/root/.m2" \
clojuremcp-dev-env
This command mirrors your host's ~/.m2
directory inside the container, which is where the default user looks for the cache.
You can combine these flags to mount as many directories as you need for your workflow.
Now you have a single container that you can reuse to run a sandboxed nREPL in many clojure projects. You can create a little sandbox.sh
bash script in the root directory of your projects to help you mirror the needed folders. You will need to consult the Chat model of your choice to help you set up more complicated containers.