examples/presa/example/nrepl.clj
(presa.example.nrepl) puts a running Clojure or Babashka REPL behind MCP
tools. An agent evaluates code in the live process, loads files into it and
looks symbols up, over nREPL, the protocol editors use.
State persists between calls.
It is an example, not part of the library: the jar does not contain it, and it runs from a checkout of this repository. It is a part (see Parts), and shows what a part with long-running calls involves: output as progress, cancellation, a timeout that leaves work running, input asked of the client, and guides served as skills.
A REPL earns its place when state lives in a running process and is slow or impossible to rebuild: an application with a long startup, a live service, data loaded once, an open connection. In trials with Claude Code on tasks without such state, it did not help:
eval and load_file run arbitrary code inside the REPL's process, with
everything that process can do: read and write files, start programs, use
the network.
eval without review is close to allowing any command.:required-scopes (see
Authorization). The bridge is meant for stdio, on the
machine of the person who started the REPL.Start an nREPL server in the project, then serve the bridge over stdio:
bb --config /path/to/presa/bb.edn -m presa.example.nrepl --dir /path/to/project
--dir is the project directory. The port is read from its .nrepl-port
file at each connect, so a restarted REPL is found again.--port names the port instead, and --host a host other than
127.0.0.1.A JVM REPL started with nrepl.cmdline or by an editor, and
bb nrepl-server, each write .nrepl-port in the directory they start in.
Register it with a host as any stdio server:
claude mcp add repl -- bb --config /path/to/presa/bb.edn -m presa.example.nrepl --dir /path/to/project
codex mcp add repl -- bb --config /path/to/presa/bb.edn -m presa.example.nrepl --dir /path/to/project
The bridge connects on the first call, not at startup, so it can be registered before the REPL is running. A call made while no REPL answers is a tool error that says so.
| Tool | What it does |
|---|---|
eval | evaluates one or more forms and returns each value with what was printed |
load_file | loads a source file, with its name and line numbers, as an editor's load-file does |
await | waits for an evaluation that an earlier call left running |
interrupt | stops the running evaluation |
lookup | the doc string, argument lists and source location of a symbol |
guide | reads one of the part's guides (see Skills below) |
(require '[presa.example.nrepl :as nrepl])
;; a part connects on its first call, so building one needs no REPL
(map :name (:tools (nrepl/part {:port 7888})))
;; => ("eval" "load_file" "await" "interrupt" "lookup" "guide")
A result is text written for a model:
:hi
=> nil
=> #'user/x
=> 42
Printed output comes first, then each form's value after =>. An exception
makes an error result that names its class; the exception is in *e for the
next call. load_file ends with the file it loaded and the namespace the
file declares.
*1 and *e
carry over between calls. The bridge clones one nREPL session and every
call uses it.:max-output-chars and :max-output-lines, with a line
saying how much was dropped. A long value is cut the same way.timeout_ms returns what it has, marked [running], and the evaluation
goes on. await waits for it again and returns what came since;
interrupt stops it. Until it finishes, eval and load_file are
refused, because a session runs one evaluation at a time.:interrupt-grace-ms gives up its session: later calls get a new
one, with definitions kept and *ns*, *1 and *e reset.*in*, such as
(read-line), becomes a form elicitation for a line. A client that cannot
be asked, or that declines, gives the code end of input, and the result
says so.The part serves six guides as skills (see Skills) and names them in its instructions, so a model reads the one that fits its task:
| Skill | For |
|---|---|
repl-workflow | the edit, load, call loop; files as the source of truth; stale definitions |
repl-long-running | timeouts, await and interrupt; servers and jobs on other threads |
repl-exploring | namespaces, documentation, the shape of data, Java classes |
repl-debugging | the last exception, capturing values, stale code |
repl-testing | running clojure.test from the REPL |
babashka-tooling | Babashka as a tooling language: files, processes, HTTP, scripts, tasks |
(map :name (:skills (nrepl/part {:port 7888})))
;; => ("repl-workflow" "repl-long-running" "repl-exploring" "repl-debugging" "repl-testing" "babashka-tooling")
(:skills (nrepl/part {:port 7888 :skills? false}))
;; => []
Each is at skill://<name>/SKILL.md. Claude Code reads them as resources.
Codex gives its model no way to read a resource, so the guide tool returns
the same text by name. :skills? false leaves out the skills, the tool and
the lines of the instructions that list them.
part returns a part, to combine with a project's own tools. The examples
directory has to be on the classpath:
(require '[presa.part :as part])
(part/serve! {:name "dev"
:parts [(part/prefix (nrepl/part {:dir "."}) "repl")
{:tools [{:name "deploy" :handler (fn [_ _] "deployed")}]}]})
Its options:
| Option | Meaning | Default |
|---|---|---|
:port | the nREPL server's port | the port in :dir's .nrepl-port |
:host | its host | 127.0.0.1 |
:dir | the project directory, for .nrepl-port and relative load_file paths | the working directory |
:timeout-ms | how long a call waits when it names no timeout_ms | 30000 |
:interrupt-grace-ms | how long an interrupted evaluation has to stop | 3000 |
:max-output-chars | the most characters kept of stdout, of stderr and of each value | 4000 |
:max-output-lines | the most lines kept of stdout and of stderr | 100 |
:structured? | add structuredContent to results | false |
:skills? | serve the guides | true |
Results are text only by default: a host that gets both text and
structuredContent shows the model the JSON, which is harder to read than
the text. :structured? true adds a map of status, values, out,
err, ns, ex, loaded and note for a caller that wants data.
The part's :close! interrupts a running evaluation and closes the session
and the connection.
What the bridge has been run against:
| REPL | Result |
|---|---|
| JVM nREPL 1.3.1 | everything works |
| JVM nREPL with cider-nrepl middleware | everything works |
Babashka (bb nrepl-server) | everything works |
nbb (nbb nrepl-server) | works, with no interrupt and no read-line. A promise is awaited and its value printed as #<Promise v>. An evaluation that is abandoned gives up its session |
| ClojureScript through piggieback on Node | single forms work. It evaluates only the first form of a call, which the result says; wrap several in (do ...). Printed output is not returned, promises are not awaited, and lookup finds nothing without cider-nrepl |
shadow-cljs, Basilisp and Scittle are untried.
| Host | Result |
|---|---|
| Claude Code | tools; a line of input through elicitation in an interactive session, declined when run headless; skills read as resources |
| Codex | tools; guides through the guide tool |
*ns*, *1 and
*e.lookup knows only namespaces that are loaded.Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |