dvergr serves the Model Context Protocol so that a coding client can hand work to it: run a
task on forks of a room, evaluate Clojure in the room's REPL, adopt a result by merge, and see
what it cost. The tools are derived from dvergr.ops/specification (and the room-scoped
dvergr.tools registry); a new op appears in MCP, the JSON API and the web without extra code.
A client launches bin/dvergr-mcp (babashka) as a stdio server. It relays to the daemon's
Unix domain socket in the state root ($DVERGR_HOME/mcp.sock; when that path is too long
for a socket, <runtime>/dvergr/<hash>.sock in the user's runtime directory,
$XDG_RUNTIME_DIR or /run/user/<uid>, else /tmp/dvergr-<uid>/<hash>.sock;
server/socket-path). The daemon binds it only in a directory that is this uid's, mode 0700
and not a symbolic link (it closes its own home to 0700, refuses any other), and the relay
checks the same, and that the socket is this uid's, before it sends its credential. So
only this user can be at the other end, and the relay never hands its credential to
whatever holds a TCP port first (another user may bind 17888 before the daemon). --tcp
(with --port, --host) uses the daemon's TCP port instead, when the daemon has one
(:tcp true), and then the credential goes to whoever holds it. Its files live under the state root, DVERGR_HOME
(default <checkout>/.dvergr, made 0700):
it presents the state root's local token ($DVERGR_HOME/local-token, mode 0600, which
the daemon creates; a home from before has it in mcp/http-token, and that value stays
valid and now opens the web and TCP too) as the first line of every connection, or
DVERGR_MCP_TOKEN when it is given one (a run token, below). A daemon accepts no
connection without a credential, its socket included, and the relay says so when it is
refused;
when no daemon listens, it starts one (clojure -M:cli --no-tui --mcp …, log in
$DVERGR_HOME/logs/mcp-daemon.log, nREPL port in $DVERGR_HOME/mcp-daemon.nrepl-port) and
answers initialize itself meanwhile, from the last tool list it saw for the same selection
($DVERGR_HOME/mcp/), so clients with short startup timeouts connect. The first boot takes
about two minutes (dependencies, then the stores); the tools appear when it is up
(tools/list_changed);
it attaches only to a daemon of its own state root: one on the port that reports another
home (server/discover's _meta dvergr/home) is refused, and every call says so. Give the
relay another --port for another home (one daemon per home, one port per daemon);
when the daemon restarts, it reconnects, replays the handshake and sends
tools/list_changed; a call in flight gets an error result saying the connection dropped;
it never starts a second daemon on a state root whose daemon ($DVERGR_HOME/daemon.pid) runs
without MCP; restart that one with --mcp, or set :mcp in its config.
Claude Code (.mcp.json or claude mcp add):
{"mcpServers": {"dvergr": {"type": "stdio",
"command": "/path/to/dvergr/bin/dvergr-mcp",
"args": ["--profile", "offload"]}}}
Codex (~/.codex/config.toml):
[mcp_servers.dvergr]
command = "/path/to/dvergr/bin/dvergr-mcp"
startup_timeout_sec = 30
tool_timeout_sec = 300
Options (flags or environment): --profile / DVERGR_MCP_PROFILE, --toolsets a,b /
DVERGR_MCP_TOOLSETS, --room / DVERGR_MCP_ROOM and --tools a,b / DVERGR_MCP_TOOLS
(below), --port / DVERGR_MCP_PORT, --host (default 127.0.0.1), --dir / DVERGR_DIR
(the checkout the daemon starts in), DVERGR_HOME (state root), DVERGR_MCP_START (the start
command), --no-start, --wait-s (default 120).
To run the daemon yourself: clojure -M:cli --no-tui --mcp, or :mcp {:profile "offload"}
in the config file: MCP on the home's socket. TCP is opt-in, --mcp-tcp [--mcp-port 17888]
or :mcp {:tcp true :port 17888}, for clients that cannot use the socket (the relay then
needs --tcp). The daemon inherits its
environment: a TELEGRAM_BOT_TOKEN there connects that bot.
A connection pinned to one room. --room ROOM --tools read_file,write_file gives a
connection exactly those tools, in that room only: their room parameter disappears (the
connection supplies it) and a call naming another room is refused. This is how an external
agent works in a benchmark Attempt: a claude-code-* candidate runs claude -p INTO its
Attempt's world, with this relay in --mcp-config (its own built-in tools disabled, only
mcp__dvergr__<tool> allowed, its own loop of up to 60 turns); the CLI calls the same tools
every other candidate gets, natively, and the evaluator scores the world when it answers. The
daemon (or any process running an Attempt) serves MCP on its home's socket for it. Each
call gets a run token (principal/mint-run-token!), passed to the relay as
DVERGR_MCP_TOKEN: it authenticates as that agent, and the server pins the connection to the
token's room and tools whatever the relay asks (resources included: only those its tools
name). It carries no scope of its own and is accepted over MCP only, never by the web. It is
revoked when the call ends, however it ends (and expires after an hour); its connections close
then, and its principal authorizes nothing more.
This path needs the relay at bin/dvergr-mcp under the process's working directory (a
checkout; DVERGR_MCP_RELAY names another path) and babashka (bb) to run it. Without the
relay, or without a work room and tools, claude -p answers once and calls tools through the
text <tool_use> protocol instead (dvergr.model.api.claude-code), so scores from the two
paths are not the same measurement.
Over HTTP, without the relay. Give the daemon :mcp {:port 17888 :http {:port 17889}} and
it also serves Streamable HTTP at http://127.0.0.1:17889/mcp. Without an authenticator of
the daemon's own (below), requests need the local token as a bearer: the daemon writes it to
local-token in its state root ($DVERGR_HOME, default .dvergr in the directory it started
in; mode 0600) on its first start; it makes the caller the local principal.
Claude Code:
claude mcp add --transport http dvergr http://127.0.0.1:17889/mcp \
--header "Authorization: Bearer $(cat "${DVERGR_HOME:-.dvergr}/local-token")"
or in .mcp.json:
{"mcpServers": {"dvergr": {"type": "http", "url": "http://127.0.0.1:17889/mcp",
"headers": {"Authorization": "Bearer <token>"}}}}
Other clients that speak Streamable HTTP take the same URL and header. The selection
(dvergr/profile, dvergr/toolsets, dvergr/room, dvergr/tools) goes in each request's
_meta, or in initialize's for a handshake client.
Which to use: the stdio relay starts the daemon when none runs and hides its restarts, so a client never sees its server die; HTTP needs a running daemon, has no relay process, and is the way to a hosted dvergr (behind a TLS proxy; the listener itself stays on loopback). A hosted dvergr also needs an authenticator and an authorizer of its own (below): the defaults trust whoever reaches the daemon the way only its local user can.
Every call has a principal (dvergr.principal), a plain map:
{:principal/id "party/5f0c…" ; stable, compared by =
:principal/kind :human ; :human | :agent | :local
:principal/actor :party/5f0c… ; who it posts as (default: derived from the id)
:principal/scopes #{:toolset/rooms} ; what MCP may show it
:principal/claims {…}} ; the authenticator's evidence, opaque to dvergr
Three seams decide who it is, what it may do, and what it owns. They are set in the daemon
config, :auth {:authenticate f :authorize f :on-created f} (a function, or a qualified symbol
naming one), or, for a host that runs dvergr's rooms without a daemon, process-wide with
principal/configure!:
:authenticate (fn [request] -> principal | nil). request carries :transport
(:mcp-stdio, :mcp-tcp, :mcp-http, :web, :telegram), :bearer (the credential: a
TCP connection's first line, Authorization: Bearer; never a cookie),
:remote-addr and :headers (for information only), :local-token when a listener was
given its own, for Telegram :telegram/user, :telegram/chat-id and :telegram/owner?.
nil refuses the caller: 401 over HTTP, an error (-32001) and a closed connection over TCP, a
refusal in Telegram.
The default, principal/default-authenticate, takes credentials only; where a request
comes from (loopback, a proxy's headers) grants nothing. The stdio transport of
run-stdio is its owner, the local principal. On TCP, HTTP and the web the local token
(principal/local-token, compared in constant time) is the local principal and a live run
token is its agent; anything else is refused. A Telegram sender is the local principal only
when the host names it as an owner (:telegram {:owners [id …]}, numeric ids only: a
username can be changed and taken), telegram/<id>
when a non-empty allowlist lists it, else refused. A configured authenticator replaces the
default; a hosted one should hand out the local principal to nobody, and should map a bearer
it does not know through principal/run-token-principal, so dvergr's own agent runs
(claude -p candidates) keep their credentials.:authorize (fn [principal request] -> decision). request is {:op :action :resource :resolved :args}: the op (:room/post, or :tool/clojure_eval for a registry tool), its
action (:read, :write, :merge, :admin), the resource as the caller named it ([:room "general"], [:job "<id>"], [:agent "var"], [:global]) and what dvergr resolved it to
({:room "<room id>"}; for a job also :owner, the actor that started it; nil for a handle
that resolves to nothing). Deny :resolved nil except where the op creates what it names.
A decision is a boolean or {:allow? … :reason …}. A throw denies, and so does a resolution
that throws. The default, principal/allow-all, allows everything.:on-created (fn [principal resource]), called after a principal other than the local
one created a room (room_create, room_fork, room_import, agent_open, a catalog
fixture room) or an agent (agent_create), so the host can grant it its creator. It may be
called again for the same principal and resource: make it idempotent. When it throws, the
op fails (500) and is recoverable: a fork or an agent whose grant failed is removed again
(a retry creates it anew); a room from room_create or room_import stays, recorded as
that principal's (:room/creator in the system DB), and the same call by the same
principal grants it again instead of answering "exists". A catalog fixture room whose grant
failed stays ungranted; each benchmark makes a new one.A principal never receives a room it did not create: room_create and room_import refuse
an existing slug (409) unless that principal created it, and agent_open gives each
principal other than the local one a DM room of its own (dm-<agent>-<hash>; the local
user keeps the shared dm-<agent>).
The local principal is the process itself and is never asked. It is recognized by identity
(an unforgeable marker), not by :principal/kind: principal/decide and invoke refuse a
principal that claims kind :local without being it, without asking the authorizer.
What each op acts on is declared in dvergr.ops/specification, :authz {:action :resource :also :filter}. Every write declares it; a read with a room argument may leave it to the
default (reading that room). :also adds checks beyond the op's own resource:
room_merge reads the fork and needs :merge on its parent (no parent, no merge);
catalog_benchmark writes [:global] (it creates a fixture room) and reads the room of a
room workflow (<room>/<name>); room_invite and catalog_deploy write the agent they put
to work; attempt_export with file is :admin on [:global] (the host's state root).
Archiving (room_delete), discarding a fork, purging, agent administration, imports from a
host path, promoting a verifier ([:global]) and the admin toolset are :admin; so is a
developer-profile Run (workflow_start/workflow_attempt with profile: developer,
catalog_start on a room), whose agents edit files and run a shell. Listings (room_list,
job_list) show only the rows the principal may read. A job records the principal that
started it as its owner.
Registry tools are authorized per call, for the principal in the tool context (MCP, operator
commands) or the agent whose turn it is (:actor), on the room the call acts for:
clojure_eval, shell and run_tests are :admin (the room's whole capability), the others
:read or :write as their effect is, a tool without a classification :admin. A call that
names neither a principal nor an actor is refused; host code passes
principal/local-principal. dvergr.principal-test checks that every op and every registry
tool has a declaration.
The principal reaches every surface: each MCP connection (and each HTTP request) is
authenticated, every tool call, resource read and subscription is authorized for it, and a
denial is a tool error (isError: true, "Not authorized: …") or, for a resource, -32002.
A subscription is authorized again before each notification: one whose access was revoked is
dropped. What a client asks for in _meta (dvergr/profile, dvergr/toolsets,
dvergr/tools) is capped to the principal's scopes (surface/capped): :toolset/<name>
grants a toolset, :dvergr/admin all of them, :dvergr/read-only keeps it read-only. The cap
holds for resources too. A client narrows its selection, never widens it; a principal without
scopes sees no tools. Tools registered at runtime (register-tool!, the extra toolset) have
no authorization of their own and are the local principal's only. An HTTP handshake session
belongs to the principal that opened it: another principal holding its Mcp-Session-Id gets
404.
room_post posts as the principal (principal/actor); its as is for the local principal
only, and refused for anyone else. Slash commands (dvergr.discourse.commands) run for the
:principal their host passes and are authorized on their room as :command/<name>:
/help reads, a prompt or skill writes (it posts a turn), a handler and every other
built-in (/model, /sandbox, /build, /fork, /invite) is :admin; a command without
a principal is refused. dvergr.ops/invoke takes the principal explicitly; there is
no default. In-process code that holds the daemon (the REPL, the TUI, room-run, the
experiment runner) passes principal/local-principal. A Telegram message is authenticated
once; its ops commands, tool commands and posts all run for that principal.
A host adds its own ops with ops/register-op! (a spec entry as in specification, with an
:authz unless its schema has a room): MCP serves it (toolset host), the JSON API routes
it, the REPL's dvergr.ops has it, and each call is authorized as dvergr's own. A host without
a dvergr daemon calls ops/invoke with {:execution-ctx ctx} as the daemon.
A connection sees one selection of tools, chosen by --profile / --toolsets (the relay puts
them into initialize _meta as dvergr/profile and dvergr/toolsets). Tools outside it are
neither listed nor callable.
| Profile | Toolsets | Tools |
|---|---|---|
offload (default) | rooms, worlds, attempts, catalog, wallets, repl | 26 |
bench | rooms, files, attempts, catalog, bench, wallets | 32 |
code | repl, describe (its REPL calls the ops of rooms, files, worlds, attempts, catalog, bench, wallets) | 2 |
readonly | every read op | read-only only |
admin | everything | all |
| Toolset | What |
|---|---|
rooms | create, list, detail, delete (archives: history kept), messages, post |
files | room_files, room_read, room_write: the room's workspace files (in bench; add with --toolsets files) |
worlds | fork, review, diff, merge, discard |
attempts | workflow_start and the job tools; Attempts, Scorecards |
runs | the blocking workflow_attempt; Runs |
catalog | catalog_list, catalog_start: workflows with their own checker |
bench | catalog_benchmark, catalog_check, catalog_calibrate, catalog_cases, catalog_freeze, catalog_deploy, catalog_export, catalog_import, experiment_progress, experiment_report, attempt_export |
wallets | room_wallet, models_list |
repl | clojure_eval in the room's SCI sandbox |
describe | repl_describe: the REPL's API (dvergr namespaces, functions, signatures, docs), filtered by a query |
agents | agent administration (agent_create/agent_update take tools, below), room_invite |
system | statistics |
admin | room_unarchive brings an archived room back, room_purge removes it and its stores for good, catalog_promote, run_resume (admin profile only) |
host | ops a host registered (dvergr.ops/register-op!); none by default |
code | every dvergr.tools registry tool but clojure_eval and repl_describe: files, search, shell, tasks, and the schedule_* and mail_* tools the daemon registers at start; for hosts without their own (off by default). The list follows the registry (tools/list_changed) |
extra | tools registered at runtime that are neither ops nor registry tools (channels) |
REPL-first (--profile code). Two tools: clojure_eval, whose dvergr.ops namespace
calls every op of the listed toolsets (see below), and repl_describe to discover the API
without writing code. A client writes one program where it would chain tool calls.
The default is small on purpose: some clients cap the tools of all their servers together around 40, and every definition costs context in every session.
Handles, not sessions. Every call names its room (id or slug) or fork; the server keeps
no per-connection state beyond the selection. (MCP 2026-07-28 removes protocol sessions;
this surface already fits it.)
A coding tool always runs in a room: room is required, and the tool runs in that room's
sandbox and workspace, never in the daemon's own directory.
Annotations on every tool (readOnlyHint, destructiveHint, idempotentHint,
openWorldHint, title), from the op's :kind and the tables in dvergr.mcp.surface.
Clients use them for confirmation: clojure_eval, room_merge and room_discard are
destructive, so e.g. Codex asks before calling them.
Results are JSON text plus the same value as structuredContent; text beyond 60k
characters is cut with a note (Claude Code caps a result at 25k tokens).
A failure is an error. An op that refuses or fails throws, and MCP returns it as a tool
error (isError: true) with its message. The JSON API answers with the status the op gave
it: 400 for a request the caller can fix (an agent with a blank id, a bad field value), 404
for an unknown agent, or an unknown room in the agent, room file and experiment_report
ops, 409 for a taken agent id or a write that met a concurrent change; a failure without a
status is 500. Not every op checks its room: several reads answer null for an unknown one.
agent_delete is idempotent: it answers {deleted: id} whether or not the agent existed.
Schemas are flat and closed (additionalProperties: false, no oneOf/anyOf/$ref) and
names match ^[a-z][a-z0-9_]{0,39}$, so strict model APIs accept them; server_test checks
every tool.
Long work is a job. workflow_start returns a job at once; job_status {job, wait-ms}
answers when it finishes or after at most 25 s (below every common client timeout), and
job_cancel stops it. A benchmark job's status carries progress (cells-done,
cells-total, faults, and phase: running, settling while its fixture room is
archived after the last cell, done) and settled; poll until status is no longer
running and settled is not false, as room-run does. A job is a durable Run of its room, and the attempts it starts are its
child Runs, so a client can restart and poll again (job_list), and a job outlives the
daemon: after a restart it reads as it finished, or failed with reason orphaned if the
daemon stopped while it ran. A workflow runs up to parallelism attempts at once (default 4).
A room's reads are live. Every read op is also a resource (room://{room}/messages,
experiment://{room}/progress, …). Subscribing to one about a room sends
notifications/resources/updated after every durable change of that room (a message, a Run,
a tool call, an Attempt), at most a few times a second; re-read the resource for what changed.
Your code in, a patch out. room_import {room, source} creates a room whose workspace is a
clone of a local checkout (its committed state, with history) or an https remote. Work on it with
workflow_start, adopt an attempt with room_merge, then room_export {room} returns the changes
since the import as a patch: git apply it in your checkout. Only these two ops reach a
repository outside the room; a room's own sandbox Git has no network transport.
A room's files without a checkout. room_files {room, path} lists a directory of the room's
workspace (name, kind, size, content id; up to 200 entries per call, offset/next-offset for more),
room_read {room, path} returns a file as text (UTF-8) or base64 with its media-type
(.xlsx, PDF), at most 24 KiB per call (offset, next-offset for the rest), and room_write {room, files, message} writes a batch as one commit of the room's repository: files maps a
path to {"text": …}, {"base64": …} or null (remove it), and only those paths are
committed; writing what is already there commits nothing (commit is null). At most 200
paths and 16 MiB per call, 8 MiB per file. It is all or nothing, with one exception: a refused
or failed write undoes the batch's own changes, and a path another writer changed meanwhile
fails it rather than committing their bytes; but a path changed by someone else between the
write and the undo is left as they made it, and the error names it (conflicted). There is
no client-side compare-and-write argument: each write compares against the batch's own
snapshot. It refuses while the workspace has staged changes; symbolic links are neither read
nor written through. Paths stay in the workspace (no .., nothing under .git, no path the
sandbox's sensitive-path policy refuses); a read-only world refuses the write and receipts
the refusal. There is no Git API:
history is the room's (room_fork, room_diff, room_review, room_merge, room_export).
Files are also resources, room://{room}/files/{+path} (a file whole up to 4 MiB, a directory as
JSON).
Merges are pinned to the review. room_review (and every workflow attempt's review)
carries the fork's state, a hash of its systems' snapshot ids; room_merge {room, expect-state} refuses a fork that changed since. A failed merge is an error, not a success.
A merge adopts a fork's files. A workspace change is only in the diff once committed; an
attempt's world is committed when its Run ends, and room_merge commits a fork's remaining
changes before merging (room_review lists them as uncommitted, and they make a fork
reviewable). Without this a merge silently dropped uncommitted files.
Catalog workflows are scored by their own checker. catalog_start {workflow: "wiki/v1"}
without a room runs on the benchmark set (fixtures seeded into a new room, ten known facts);
with a room, on its own /docs, scored by citations, links and structure. Each attempt's
checks say what it got right (one per fact), reward weighs them.
A benchmark from your own cases, over MCP alone (bench profile; the case-pack section of
room-workflows.md has the details):
room_create {title: "Books", slug: "books"}
room_write {room: "books", message: "Cases from 2025",
files: {"data/cases.csv": {"text": "no;vendor;text;doc;account;amount\n1;Acme;toner;inv1.txt;4930;119,00\n…"},
"data/inv1.txt": {"text": "Invoice 1: toner 119,00 EUR"}}}
catalog_cases {room: "books", name: "invoice-coding", table: "/data/cases.csv",
spec: {id: "no", inputs: ["vendor", "text"], attachments: ["doc"],
expected: {account: {rule: "exact"}, amount: {rule: "number", tolerance: 0.01}}}}
room_files {room: "books", path: "/workflows/invoice-coding"} # the bundle it wrote
room_read {room: "books", path: "/workflows/invoice-coding/certification.edn"}
catalog_calibrate {room: "books", name: "invoice-coding"}
catalog_benchmark {workflow: "books/invoice-coding", models: ["…", "…"], room: "books",
name: "invoice-coding", cases: 30}
job_status {job: <id>, wait-ms: 25000} # until settled
experiment_report {room: "books", experiment: <the job's experiment>}
A benchmark can report into your room. catalog_benchmark {workflow, models, room} (the
bench profile) still
runs every cell in a world forked from a new fixture room, but keeps the job, its Attempts and
the Scorecard in room, where its dashboards (and experiment_progress {room}) show them.
name names the experiment (experiment is its deprecated alias): benchmarking again with
the same name, workflow and models into the same room resumes it (cells with a verdict made
with the current inputs are kept, the others run again). Without room each call keeps its results in its own new
fixture room, and without name each call is a new experiment, so neither resumes.
cases takes a dataset's first N cases of a fixed shuffle, case-ids names them instead.
Reports. experiment_report {room, experiment?, scorecard?, baseline?} renders a room's
experiment report as Markdown from its store. Its experiment is the ExperimentDef content
id, the experiment a catalog_benchmark job returns, not the name you passed it; without
it, the room's newest Scorecard made with the candidates' current inputs. The report's
paired comparisons (exact McNemar on the cases where two candidates differ, Holm-adjusted)
are also in its result as paired. scorecard_detail {room, id, baseline?} returns one
Scorecard as data: the leaderboard, every cell, the pass rate per check, comparison
(each candidate against baseline, default the top of the leaderboard) and paired (each
against baseline when given; else every pair, beyond five candidates each against the
best).
Agents. agent_create and agent_update take tools, a list of tool names ([] for
none); an agent given tools gets exactly those, one without them gets the tools its tags
choose.
Money is in the result. A workflow result has each attempt's spend, the per-model table (cost per completed attempt) and the room's wallet afterwards.
A query cannot reach the host. Datahike queries and transactions from the sandbox
(clojure_eval, d/q on a room's databases) resolve functions as Datahike's server does:
pure clojure.core, clojure.string, subqueries and registered functions, never slurp, a
shell or reflection.
The API is in the REPL too. clojure_eval's dvergr.ops namespace holds every op the
connection may call, as a function taking one map (room-list, catalog-benchmark,
job-status, scorecard-detail, … and (call :room/list {})), documented from the spec
((doc dvergr.ops/job-status), (dir dvergr.ops), apropos) and validated against its
schema. So a client can write one program instead of a chain of tool calls:
(let [scs (dvergr.ops/scorecard-list {:room "r"})
d (dvergr.ops/scorecard-detail {:room "r" :id (:id (first scs))})]
(select-keys (first (:leaderboard d)) [:candidate :pass-rate-interval]))
The REPL has exactly the authority of the connection's tools: only the ops its selection
shows are installed, and each selection has a REPL session of its own (:mcp/offload, …),
so a connection never reaches a function another one installed; a principal other than the
local one has sessions of its own besides. Each function runs for the principal and
selection of the evaluation calling it (dvergr.mcp.repl/*caller*), never for whoever
defined it, and is refused outside an evaluation (a later step of a Spin, another thread). room-delete and
room-purge refuse the room the REPL runs in. An agent's own sandbox gets none of this.
REPL definitions live in the daemon's memory: they do not survive a daemon restart. Keep lasting code in the room's workspace.
| stdio (relay) | TCP (loopback) | Streamable HTTP | |
|---|---|---|---|
| Handshake 2024-11-05 … 2025-11-25 | ✓ | ✓ | ✓ (Mcp-Session-Id; no GET stream) |
| Stateless 2026-07-28 | ✓ | ✓ | ✓ (headers validated; SSE for listen/progress) |
subscriptions/listen | ✓ (tagged on the shared channel) | ✓ | ✓ (its own SSE stream) |
| Progress | ✓ | ✓ | ✓ (SSE) |
| Cancellation | notifications/cancelled | notifications/cancelled | closing the stream |
test/dvergr/mcp/conformance_test.clj checks the versions in process and over TCP,
http_test.clj the HTTP transport, smoke_test.clj a daemon through the relay and over HTTP.
Both eras of the protocol, decided per request, so one connection may carry both:
initialize, then
requests; an unknown version is answered with the latest. Tools, resources (derived from the
read ops) with subscriptions, listChanged and ping. Unknown tools are -32602, unknown
resources -32002.initialize; each request names its version, client info and
capabilities in _meta (io.modelcontextprotocol/protocolVersion, …/clientInfo,
…/clientCapabilities), and its tool selection too (dvergr/profile, dvergr/toolsets,
dvergr/room, dvergr/tools), which holds for that request only. server/discover names
every supported version, the capabilities and the instructions. Every result has
resultType: "complete" and the server's info in _meta (io.modelcontextprotocol/serverInfo);
lists and reads carry ttlMs and cacheScope (private: what a client sees depends on its
selection; a read is live, ttlMs 0). A version the server does not speak is refused with
-32022 and data {supported, requested}; an unknown resource is -32602; ping is gone.
subscriptions/listen opens a subscription: the request stays open, the server first sends
notifications/subscriptions/acknowledged with the part of the filter it honours
(toolsListChanged, resourceSubscriptions for existing resources; no prompts, no resource
list changes), then tagged notifications (io.modelcontextprotocol/subscriptionId, the
listen request's id). notifications/cancelled ends it; at shutdown the server answers the
listen request (resultType: "complete") before closing. A stateless client that did not
subscribe gets no notifications; a handshake connection gets list changes as before.Streamable HTTP (dvergr.mcp.http, :mcp {:http {:port 17889}} in the daemon config): one
POST /mcp endpoint on its own listener (loopback by default). Every request is
authenticated from its Authorization: Bearer <token> (401 when refused; by default the
local token, <state>/local-token, mode 0600, created on first start); an Origin that is not
loopback is refused (403). Stateless requests must carry
MCP-Protocol-Version, Mcp-Method and (for tools/call, resources/read) Mcp-Name
matching the body (else 400, -32020); an unsupported version is 400 (-32022), an unknown method
404. The response is JSON, or SSE for subscriptions/listen (an open stream with keep-alive
comments) and for a long request with a progressToken; closing a stream cancels its request.
Handshake clients get an Mcp-Session-Id from initialize (DELETE ends it); there is no GET
stream (405).
TCP and the Unix domain socket. Newline-delimited JSON-RPC. The first line of a connection
is its credential, {"jsonrpc":"2.0","method":"dvergr/authenticate","params":{"token":"…"}}
(the local token or a run token, server/tcp-auth-line), at most 8 KiB, within 10 s; a
connection without one gets one error (-32001; it also says when the local token was replaced
because its file was readable by others) and is closed. At most 256 connections are open at
once, at most 32 of them still without a credential. A connection a run token opened closes
when the token ends. TCP is off unless the daemon's config says :tcp true.
The relay (bin/dvergr-mcp) speaks both eras too: it forwards server/discover to the
daemon (answering it itself, with the same versions, while the daemon starts), puts its
--profile/--toolsets/--room/--tools pins into each stateless request's _meta (there is
no initialize to carry them), opens a connection for a stateless request, and re-opens the
client's subscriptions after a daemon restart (dropping the repeated acknowledgement). A test
keeps its version lists equal to the server's.
Progress (both eras): a tools/call or resources/read with _meta.progressToken gets
notifications/progress while it runs — a heartbeat every 10 s (progress = elapsed seconds,
only increasing) plus whatever the tool reports through :progress! — and none after its
response.
A connection answers requests concurrently: every tools/call and resources/read runs on
its own virtual thread, so a job_status wait or a long evaluation does not hold up the
connection's other requests (a ping, a list, another tool). The handshake, lists and
notifications stay in order. Responses to concurrent requests may therefore arrive out of
order, as JSON-RPC allows. A request cancelled with notifications/cancelled before it
finishes gets no response. Its work is not interrupted: a job keeps running until
job_cancel.
--mcp-config, above); a read-only eval (needs a restricted sandbox, not only fewer
ops) and evals metered to the wallet.data toolset over pg-datahike (load a pg_dump, migrate on a fork, merge), and a client
test pass over Cursor, VS Code, Gemini CLI, n8n and ChatGPT.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 |