Design of the MCP (Model Context Protocol) server that gives coding agents the REPL's capabilities in dev mode: evaluating ClojureScript in the runtimes, seeing edits compiled and hot reloaded, the build's status, and looking up the build's namespaces and vars.
It's configured like the nREPL server, under :repl:
;; cljs.edn
{:repl {:mcp-port 0}} ; with :nrepl-port too, or alone
// vite.config.ts
cljf({ compilerOptions: { repl: { mcpPort: 0 } } })
:mcp-port: 0 picks a free port, or a fixed one.:mcp-host: 127.0.0.1 by default. It has to be a loopback address.:mcp-port-files: [".cljf/mcp-port"] by default. A port file is written only if no other live server has its port in it, and is deleted on exit, as nREPL's .nrepl-port is.agent ──stdio──▶ cljf mcp ──HTTP──▶ watcher's JVM: cljf.esm.mcp ──▶ cljf.esm.repl ──▶ runtimes
└─(fixed port, with the token: HTTP directly)──▲
The server runs in the watcher's JVM (cljf.esm.mcp), which cljf.esm.repl/start! starts under :mcp-port. It runs alongside the REPL, the compiler environment and the build's status. It serves MCP's Streamable HTTP at /mcp, with JSON responses only (no tool streams), on the JDK's own HTTP server. That means no dependencies, and no nREPL.
It works wherever the REPL works: the Vite dev server, vite build --watch with replSocket, and cljf watch.
cljf mcp (src/main/js/cljf/mcp.js) is a stdio bridge, which is how agents are configured:
{ "mcpServers": { "clojurific": { "command": "npx", "args": ["--no", "cljf", "mcp"] } } }
--no: npx runs the project's own cljf (the clojurific package's bin), never a registry package named cljf, which it would install and run, without asking, while the project's node_modules is missing.
.cljf/mcp-port for every request, so it reconnects across dev server restarts.mcp-tools.json.initialize itself, saying how to start one, and tool calls fail with the same message.:mcp-port, agents can use http://127.0.0.1:<port>/mcp directly, with the token (Authorization: Bearer <token>, the port file's second line).--dir <dir> reads <dir>/.cljf/mcp-port, for an agent started in another directory (a monorepo's root, with a server per project). Files the agent names relative to its own directory are passed as absolute paths.Security: the tools compile forms in the JVM, running their macros, so only this machine may connect:
Host header naming anything other than a loopback name, which blocks pages of other sites through DNS rebinding;Origin header, which browsers send and MCP clients don't: a page of this machine, on any port (another dev server's, a script in the app's page), could run code otherwise;Content-Type: application/json), which a page can't send without a preflight request, which the server refuses;Authorization: Bearer <token>), which the port file has on its second line. The file is written owner-only, so other users' processes, and processes that can't read the project's files, can't use the server. cljf mcp reads it with the port.The port file (.cljf/mcp-port) is the server's own: it's always written, also over a port file a killed server left, whose port another process may have taken since. With :mcp-port-files, list only files of this server.
Messages of the plugin: a runtime's connection (a page's websocket) only forwards the messages runtimes send (hello, focus, result, console-eval), not the plugin's own (dev-server, vite-error, vite-update), which tell agents of the dev server and its errors.
runtime_id: eval, load_file, wait_for_reload and tag_runtime. It's an id of runtimes or a tag. Agents can't see which tab the user focused, and a default changing as the user switches tabs would confuse them.sessionStorage and sends it in hello.sessionStorage, so the same key: it gets a new id, and a key of its own in welcome.kind: browser, server, worker or bundle, from the runtime's hello. focused marks the tab the user focused last, for the agent's information only.The initialize result's instructions give a snapshot for the agent's context:
ignoreWarnings on a dev-server REPL message once the compiler starts, middleware mode included, and again with the URLs once the server listens);runtime_id and wait_for_reload.The snapshot isn't updated during the session, and the instructions say so.
Results have a text content for the model and structuredContent. Values are printed with *print-length* 50 and *print-level* 8 unless given, and long texts are truncated at 20,000 characters. Failures (errors, warnings, a disconnected runtime) set isError.
| tool | |
|---|---|
runtimes | the runtimes, and the dev server's URLs |
build_status | compiling or not; the last compile, or its error with a source excerpt; the warnings holding back hot reloads; the runtimes |
eval | cljf.esm.repl/cljs-eval: code, runtime_id, ns, await, timeout_ms, warnings_ok, print_length, print_level |
load_file | cljf.esm.repl/load-file |
wait_for_reload | compiles files unless the watcher did, then waits for the runtime to run the build's last compile. Results: reloaded, error, warnings, timeout, no-runtime |
tag_runtime | cljf.esm.repl/tag! |
console_logs | the runtime's last 500 console.* calls, uncaught errors and unhandled rejections, read with a logs runtime op, their stacks mapped to the sources. since (a previous call's cursor, which names the page load: a reloaded page's entries all come back), levels, limit |
vite_errors | the dev server's own errors, which the plugin sends on vite-error REPL messages: its overlay's (until an update replaces it) and the last 50 it logged, not the compiler's (build_status) nor the pages' (console_logs) |
namespaces | the build's namespaces and their files |
ns_info | a namespace's file, docstring, publics with arglists, requires and dependents |
doc, source, apropos, macroexpand | from a copy of the compiler environment no compile is changing |
script/gen-mcp-tools writes the bridge's copy of the tools, src/main/js/cljf/mcp-tools.json.
:mcp-port the watcher defines cljf.esm.repl-runtime/CONSOLE_LOG.server.sourcemapIgnoreList (as well as Vite's default, or the project's own list), so browsers show the code logging as a message's source rather than the wrapper.*print-length* 20 and *print-level* 4.uncaughtExceptionMonitor, which changes nothing else. An unhandledRejection listener would turn off Node.js' default handling of them (exiting).cljf.esm/compiled-status).build_status and wait_for_reload report the errors outstanding, most recent first.wait_for_reload only compiles the Clojure and ClojureScript files it's given; it lists the others as skipped, since Vite reloads them.wait_for_reload waits for the runtime despite warnings when the dev server ignores them (ignoreWarnings).The watcher starts the MCP server before its first build, which can take minutes when cold. Until the build is done, the session's instructions and build_status say it's compiling, and the other tools fail with that.
Its templates start the MCP server (mcpPort: 0), and every project gets .mcp.json (configuring cljf mcp) and an AGENTS.md explaining the tools, from create-clojurific/common/.
clj_eval, evaluating Clojure in the compiler's JVM:
eval and changed by editing their files.nREPL (:nrepl-port) gives Clojure evaluation to those who need it.
Screenshots and DOM interaction: browser MCP servers do these.
Completion, MCP resources and prompts.
.cljf/mcp-port and its own entry in the agent's config.cljf mcp --port <port> and a port file of its own (:mcp-port-files), whose token cljf mcp can't read otherwise.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 |