Skip to content

toolnexus.tool/tool

Clojure (JVM) + cljgo · package net.clojars.muthuishere/toolnexus · SPEC §1 · clojure/src/toolnexus/tool.cljc

(toolnexus.tool/tool
{:name "get_weather" ; required
:description "Current weather" ; optional — "" when absent
:input-schema {:type "object"} ; optional — {:type "object"} when absent
:source "native" ; optional — "custom" when absent
:execute (fn [args] ...)}) ; required — may also take [args ctx]
;; =>
{:name "get_weather"
:description "Current weather"
:input-schema {:type "object"}
:source "native"
:execute #function[...]}

A Tool in this port is a plain map with one closure in it. No protocol, no record, no deftype — a map of data plus a fn needs nothing but fn, get and assoc, and that is the only shape that behaves identically on Clojure (JVM) and on cljgo, the two runtimes this one .cljc tree runs on unchanged. Everything MCP, skills, builtins, HTTP and A2A produce is this same map; tool is the constructor you call when the value comes from somewhere the port has no builder for.

The constructor does exactly three things: it asserts (:pre) that :name is present and :execute is a function, it strs the name, and it fills the three defaults above. There is no validation of :input-schema — it goes through to the adapters verbatim.

:execute returns a ToolResult map, not a string. Build it with toolnexus.tool/success or toolnexus.tool/failure. If it throws instead, toolnexus.tool/execute turns the throw into a failure result: a misbehaving tool must not take down the loop (SPEC §0.8).

  • Adapting a source the port does not build for you — a queue consumer, an in-process service, a fixture. Anything that can answer a map with a map can be a Tool.
  • Overriding a tool by name. Registration order is the precedence rule: toolnexus.tool/add-tools lets a later tool win a name collision, which is exactly how SPEC §0.11 gives MCP precedence over a builtin of the same name.
  • Tests — a two-line Tool with a deterministic result exercises the toolkit, the adapters and the client loop without a network or a child process.

tool also does not sanitize the name. SPEC §0.2 naming is applied by the sources that derive a name from a remote one — toolnexus.mcp/mcp-tool-name calls toolnexus.tool/sanitize for you. A name you write by hand is used as written, so keep it inside [a-zA-Z0-9_-] yourself, or run it through sanitize.

Key Required Default What it does
:name yes The name the model calls. stred, never sanitized.
:execute yes (fn [args]), or a two-arity fn that also accepts ctx. Returns a ToolResult map.
:description no "" Handed to the model verbatim by the adapters.
:input-schema no {:type "object"} JSON Schema for the arguments; passed through untouched.
:source no "custom" Provenance label — "mcp", "native", "builtin", "a2a", "http". Not behavioural; useful for filtering and metrics.

1. A tool written by hand, run through a toolkit

Section titled “1. A tool written by hand, run through a toolkit”
(require '[toolnexus.tool :as tool])
(def greet
(tool/tool {:name "greet"
:description "Greet someone by name"
:input-schema {:type "object"
:properties {:name {:type "string"}}
:required ["name"]}
:source "native"
:execute (fn [args] (tool/success (str "Hello, " (:name args))))}))
(def tk (tool/toolkit [greet]))
(tool/execute tk "greet" {:name "Muthu"})
;; => {:output "Hello, Muthu" :isError false}
(tool/tool-names tk)
;; => ["greet"] — sorted, always: two runtimes must not disagree on order

2. Later wins — the mechanism behind MCP precedence

Section titled “2. Later wins — the mechanism behind MCP precedence”
(require '[toolnexus.tool :as tool])
(def builtin-read
(tool/tool {:name "read" :source "builtin" :execute (fn [_] (tool/success "builtin"))}))
(def mcp-read
(tool/tool {:name "read" :source "mcp" :execute (fn [_] (tool/success "mcp"))}))
(def tk (-> (tool/toolkit [builtin-read])
(tool/add-tools [mcp-read])))
(count (:tools tk)) ;; => 1 — one name, one slot
(:output (tool/execute tk "read" {})) ;; => "mcp"

This is why toolnexus.core/build registers builtins first and MCP last. The order in that function is load-bearing, not stylistic.

3. A throwing tool is still a well-behaved tool

Section titled “3. A throwing tool is still a well-behaved tool”
(require '[toolnexus.tool :as tool])
(def tk
(tool/toolkit [(tool/tool {:name "boom"
:execute (fn [_] (throw (ex-info "kaboom" {})))})]))
(tool/execute tk "boom" {})
;; => {:output "kaboom" :isError true}
(tool/execute tk "nope" {})
;; => {:output "unknown tool: nope" :isError true}

toolnexus.tool/execute catches Throwable — a bare symbol, which is the one catch target both hosts accept — so SPEC §0.8 is written once and holds on the JVM and on cljgo alike. An unknown name is the same kind of answer: a value the model can read and retry from, never an exception thrown at your loop.