toolnexus.mcp/from-config
Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §2 · clojure/src/toolnexus/mcp.cljc
(toolnexus.mcp/from-config config) ; config: a JSON string or an already-parsed map(toolnexus.mcp/from-config config conn-opts) ; conn-opts: {:wait-for (fn [request] answer)}
;; =>{:tools [{...} {...}] ; §0.1 Tools, sorted by name :statuses {"docs" "connected" "old" "failed" "off" "disabled"} :errors {"old" "mcp server \"old\" failed at initialize: transport (connect-failed)"} :connections [{:name "docs" :status "connected" :transport {...} :server-info {...} :tools [...]}]}The whole MCP source in one call. from-config runs
parse-config over the config, then connects each server in
turn — initialize, notifications/initialized, tools/list (following nextCursor) — and turns
every listed remote tool into an ordinary Tool named
sanitize(server)_sanitize(tool).
Local stdio and remote streamable-HTTP are the same code path. A transport here is data —
{:rpc! fn :notify! fn :close! fn …} — so the two legs differ only in those closures, and
everything above them (lifecycle, paging, result shaping, naming) is written exactly once and
cannot drift between them.
Servers are connected serially, in name order. Serial is slower; it is also deterministic, and determinism is the premise of the whole project. A tool-name collision resolved by whichever server happened to answer first is exactly the drift these ports exist to prevent.
Nothing throws across this boundary. SPEC §0.3 requires a bad server to be isolated, and an
exception crossing here is how “isolated” quietly becomes “fatal”. Every failure is data with a
stable name — transport, http-status, malformed-body, rpc-error, timeout, peer-eof,
closed — the same vocabulary for both transports, so your retry logic never has to know which leg
it is on.
The optional second argument threads the §10 host resolver into every server it connects. With a
:wait-for present, initialize advertises capabilities.elicitation and a server may ask the user
something mid-tools/call; without one the capability is not advertised at all, so a
spec-compliant server never asks. Promising a capability you would then have to refuse is worse than
degrading quietly.
When to use it
Section titled “When to use it”- MCP is your only source — a CLI, a bridge, a test — and you want the tools without the rest of the toolkit machinery.
- You need the per-server status.
:statusesand:errorsare what a doctor command, a startup health check or a dashboard reads. - You want the connections.
:connectionscarries the transport, the peer’sserverInfoand, for a failed stdio server, the child’s last stderr lines — usually the only thing that explains why it died.
Why this and not the alternative
Section titled “Why this and not the alternative”Reach for parse-config instead when you only want to know whether
the config is well-formed. It touches no network and starts no process.
Arguments
Section titled “Arguments”| Argument | What it accepts |
|---|---|
config |
A JSON string, parsed with keyword keys · {:mcpServers {…}}, with servers and mcp as wrapper aliases · or a bare map of servers, in which case the object itself is the server map minus the reserved sibling keys :builtins, :agents, :a2a, :mcpServer. |
conn-opts |
Optional. {:wait-for (fn [request] answer)} — the one §10 host resolver, applied to every server. Omit it and no server is told the client can be elicited. |
What you get back
Section titled “What you get back”| Key | What it is |
|---|---|
:tools |
Every connected server’s tools, flattened and sorted by name. |
:statuses |
server -> "connected" / "disabled" / "failed". Every configured server appears. |
:errors |
server -> message for the failed ones only. The message names the phase and the stable error, and appends the child’s stderr when there is any. |
:connections |
One connection map per server, for disconnect / disconnect-all and for :server-info, :phase, :error and :exit-code. |
Examples
Section titled “Examples”1. Connect a config and call one of its tools
Section titled “1. Connect a config and call one of its tools”(require '[toolnexus.mcp :as mcp] '[toolnexus.tool :as tool])
(def res (mcp/from-config (slurp "mcp.json")))
(try (:statuses res) ;; => {"docs" "connected"} (mapv :name (:tools res)) ;; => ["docs_fetch" "docs_search"] — sorted, server-prefixed
(let [tk (tool/toolkit (:tools res))] (tool/execute tk "docs_search" {:query "suspension"})) ;; => {:output "…" :isError false}
(finally (mcp/disconnect-all res)))The tool name is not the remote name: SPEC §0.2 joins the sanitized server name and the sanitized
remote name with _. toolnexus.mcp/mcp-tool-name computes it, so you can predict a name from the
config without connecting. Each Tool also keeps :server and :remote-name for when you need to
get back to the original.
2. One dead server does not sink the others
Section titled “2. One dead server does not sink the others”(require '[toolnexus.mcp :as mcp])
(def res (mcp/from-config {:mcpServers {"live" {:url "http://127.0.0.1:8931/mcp" :timeout 5000} "dead" {:url "http://127.0.0.1:1/mcp" :timeout 2000} "off" {:url "http://127.0.0.1:8931/mcp" :enabled false}}}))
(:statuses res);; => {"live" "connected" "dead" "failed" "off" "disabled"}
;; the live server's tools are all there, and usable(count (:tools res))
;; the failure is reported with a phase and a stable error name(get (:errors res) "dead");; => "mcp server \"dead\" failed at initialize: transport (connect-failed)"
(->> (:connections res) (filter #(= "dead" (:name %))) first :error :error);; => "transport"
(mcp/disconnect-all res)A disabled server is never contacted at all — disabled:true and enabled:false are the same
statement, and both mean no process is spawned and no request is sent.
3. Letting a server ask the user, mid-call
Section titled “3. Letting a server ask the user, mid-call”(require '[toolnexus.mcp :as mcp] '[toolnexus.client :as client])
(def res (mcp/from-config {:mcpServers {"forms" {:command ["node" "forms-server.js"] :timeout 5000}}} {:wait-for (fn [request] ;; a real host shows this to a human and blocks (println (:prompt request)) (client/make-answer (:id request) true {:name "Muthu"}))}))The elicitation is answered inline, while the tools/call that triggered it is still in flight —
nothing re-executes and no suspension reaches your loop. Drop the second argument and the same
config connects with a bare capabilities, which is the byte-identical behaviour of every version
before this option existed. See
elicitation->request for what your resolver receives and what it
may return.
4. Reading a failure properly, including the child’s stderr
Section titled “4. Reading a failure properly, including the child’s stderr”(require '[toolnexus.mcp :as mcp])
(def res (mcp/from-config {:mcpServers {"broken" {:command ["node" "typo.js"] :timeout 5000}}}))
(def conn (first (:connections res)))
(:status conn) ;; => "failed"(:phase conn) ;; => "initialize" — how far it got(get-in conn [:error :error]) ;; => a stable name: "transport" / "timeout" / "rpc-error" / …(:exit-code conn) ;; => the child's exit status, or nil when genuinely unknown(mcp/stderr conn) ;; => the last stderr lines the child managed to write
(mcp/disconnect-all res)A stdio peer that stops talking gives you EOF, and EOF alone cannot say whether the child exited,
crashed, or merely closed stdout. The port waits up to ~250 ms for the process reaper and then
reports peer-exited (status N) when it knows and peer-eof (stdout closed, exit status unknown)
when it does not — rather than confidently claiming the peer is still alive. The status says that
it died; mcp/stderr says why.
See also
Section titled “See also”toolnexus.mcp/parse-config— validate the config offline firsttoolnexus.mcp/elicitation->request— the §10 bridge:wait-fordrives- Inventory without connecting — why
:statusesis the closest thing - Bounding a load —
:timeout, and how teardown is kept bounded toolnexus.core/build— MCP merged with every other sourcetoolnexus.tool/tool— the shape each returned tool has