In-process client — overview
The seam
Section titled “The seam”createInProcessClient builds an ordinary Client with no baseUrl, no apiKey and no
style. Instead you pass the model itself:
| you supply | receives | returns | streams? |
|---|---|---|---|
generate(request) (every port) |
messages, tools, model, body (the assembled request for this turn) |
one assistant message: { content } to finish, or { toolCalls } to ask for tools |
no: stream / runStream is refused with an error naming why |
generateStream(request, emit) (every port since 0.25.0: Go GenerateStream(ctx, …), Python generate_stream, Elixir :generate_stream, Clojure :generate-stream, …) |
the same request, plus an emit function (Go also gets the run’s ctx) |
the same one message | yes: every emit({type:"text", delta}) becomes a stream delta as it happens (the deltas must add up to content); thought, tool_call and tool_result events pass through to the stream consumer and are never executed |
Return toolCalls and toolnexus executes them against the toolkit, appends the results and calls
you again. Return content and the run is over. The contract is the same one documented in
Run a model in-process, which also covers
retries (in-process failures are final by default) and the per-port request shapes.
A complete example
Section titled “A complete example”A “model” that asks for the add tool on its first turn and streams the answer on the second.
This is the whole integration; swap the body of the generate function for your real model.
import { createInProcessClient, createToolkit, defineTool } from "toolnexus"
const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}`,})
// Your "model": first turn asks for a tool, second turn streams the answer.const client = createInProcessClient({ model: "my-model", generateStream: async (req, emit) => { const last = req.messages.at(-1) if (last.role !== "tool") return { toolCalls: [{ name: "add", arguments: { a: 2, b: 3 } }] } const words = ["The ", "sum ", "is ", String(last.content), "."] for (const w of words) emit({ type: "text", delta: w }) // deltas must add up to content return { content: words.join("") } },})
const toolkit = await createToolkit({ builtins: false, extraTools: [add] })for await (const ev of client.stream("What is 2 + 3?", { toolkit })) { if (ev.type === "text") process.stdout.write(ev.delta) else if (ev.type === "tool_call") console.log(`[calling ${ev.name}]`)}console.log() // The sum is 5.import asynciofrom toolnexus import create_in_process_client, create_toolkit, define_tool
def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b))
def generate_stream(req, emit): # sync: runs on its own thread last = req["messages"][-1] if last["role"] != "tool": return {"tool_calls": [{"name": "add", "arguments": {"a": 2, "b": 3}}]} words = ["The ", "sum ", "is ", str(last["content"]), "."] for w in words: emit({"type": "text", "delta": w}) # deltas must add up to content return {"content": "".join(words)}
async def main(): client = create_in_process_client(model="my-model", generate_stream=generate_stream) toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) async for ev in client.stream("What is 2 + 3?", toolkit): if ev["type"] == "text": print(ev["delta"], end="", flush=True) elif ev["type"] == "tool_call": print(f"[calling {ev['name']}]") print() # The sum is 5.
asyncio.run(main())package main
import ( "context" "fmt" "strconv" "strings"
tn "github.com/muthuishere/toolnexus/golang")
type addArgs struct { A float64 `json:"a"` B float64 `json:"b"`}
func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) }
// GenerateStream: ctx-aware, and every emitted text delta reaches Client.Stream. client := tn.CreateInProcessClient(tn.InProcessOptions{ Model: "my-model", GenerateStream: func(ctx context.Context, req tn.InProcessRequest, emit func(tn.StreamEvent)) (tn.InProcessResponse, error) { last, _ := req.Messages[len(req.Messages)-1].(map[string]any) if last["role"] != "tool" { return tn.InProcessResponse{ToolCalls: []tn.InProcessToolCall{ {Name: "add", Arguments: map[string]any{"a": 2, "b": 3}}, }}, nil } words := []string{"The ", "sum ", "is ", fmt.Sprint(last["content"]), "."} for _, w := range words { emit(tn.StreamEvent{Type: "text", Delta: w}) // deltas must add up to Content } return tn.InProcessResponse{Content: strings.Join(words, "")}, nil }, })
events, err := client.Stream(ctx, "What is 2 + 3?", toolkit) if err != nil { panic(err) } for ev := range events { switch ev.Type { case "text": fmt.Print(ev.Delta) case "tool_call": fmt.Printf("[calling %s]\n", ev.Name) case "error": panic(ev.Err) } } fmt.Println()}// run: java -cp 'lib/*' Main.java (lib/ = toolnexus-0.27.0.jar + its runtime deps)import io.github.muthuishere.toolnexus.InProcess;import io.github.muthuishere.toolnexus.LlmClient;import io.github.muthuishere.toolnexus.NativeTool;import io.github.muthuishere.toolnexus.Toolkit;
import java.util.List;import java.util.Map;
public class Main { public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue()));
// Your "model": first turn asks for a tool, second turn streams the answer. LlmClient client = InProcess.createClient(new InProcess.Options() .model("my-model") .generateStream((req, emit) -> { @SuppressWarnings("unchecked") Map<String, Object> last = (Map<String, Object>) req.messages.get(req.messages.size() - 1); if (!"tool".equals(last.get("role"))) return InProcess.Response.toolCalls(new InProcess.ToolCall("add", Map.of("a", 2, "b", 3))); List<String> words = List.of("The ", "sum ", "is ", String.valueOf(last.get("content")), "."); for (String w : words) emit.accept(LlmClient.StreamEvent.text(w)); // deltas must add up to content return InProcess.Response.content(String.join("", words)); }));
try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add))) { client.stream("What is 2 + 3?", toolkit, ev -> { switch (ev.type()) { case TEXT -> System.out.print(ev.delta()); case TOOL_CALL -> System.out.println("[calling " + ev.name() + "]"); default -> { } } }); System.out.println(); // The sum is 5. } }}// dotnet new console && dotnet add package Toolnexus && dotnet runusing Toolnexus;using static Toolnexus.LlmClient;
var add = NativeTool.Of("add", "Add two numbers.", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}");
// Your "model": first turn asks for a tool, second turn streams the answer.var client = InProcess.CreateClient(new InProcess.Options{ Model = "my-model", GenerateStream = async (req, emit, ct) => { var last = (System.Text.Json.JsonElement)req.Messages[^1]!; if (last.GetProperty("role").GetString() != "tool") return InProcess.Response.FromToolCalls(new InProcess.ToolCall { Name = "add", Arguments = new { a = 2, b = 3 } }); string[] words = ["The ", "sum ", "is ", last.GetProperty("content").GetString()!, "."]; foreach (var w in words) emit(new StreamEvent(StreamKind.Text, Delta: w)); // deltas must add up to content return InProcess.Response.FromContent(string.Concat(words)); },});
await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] });await client.StreamAsync("What is 2 + 3?", toolkit, ev =>{ if (ev.Type == StreamKind.Text) Console.Write(ev.Delta); else if (ev.Type == StreamKind.ToolCall) Console.WriteLine($"[calling {ev.Name}]");});Console.WriteLine(); // The sum is 5.# usage: elixir main.exsMix.install([{:toolnexus, "~> 0.27"}])
add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end )
# Your "model": first turn asks for a tool, second turn streams the answer.client = Toolnexus.Client.create_in_process( model: "my-model", generate_stream: fn req, emit -> last = List.last(req.messages)
if last["role"] != "tool" do %{tool_calls: [%{name: "add", arguments: %{"a" => 2, "b" => 3}}]} else words = ["The ", "sum ", "is ", to_string(last["content"]), "."] Enum.each(words, &emit.(%{type: "text", delta: &1})) # deltas must add up to content %{content: Enum.join(words)} end end )
toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add])
for ev <- Toolnexus.Client.stream(client, "What is 2 + 3?", toolkit) do case ev.type do "text" -> IO.write(ev.delta) "tool_call" -> IO.puts("[calling #{ev.name}]") _ -> :ok endend
IO.puts("") # The sum is 5.Clojure has no stream API: the emitted events reach run’s :on-event as they happen.
;; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}};; run: clojure -M main.clj(ns main (:require [toolnexus.client :as client] [toolnexus.core :as tn] [toolnexus.native :as native]))
(def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))}))
;; Your "model": first turn asks for a tool, second turn streams the answer.(def c (client/create-in-process-client {:model "my-model" :generate-stream (fn [req emit] (let [last-msg (last (:messages req))] (if (not= "tool" (:role last-msg)) {:tool-calls [{:name "add" :arguments {:a 2 :b 3}}]} (let [words ["The " "sum " "is " (str (:content last-msg)) "."]] (doseq [w words] (emit {:type "text" :delta w})) ; deltas must add up to content {:content (apply str words)}))))}))
(def toolkit (tn/build {:builtins false :tools [add]}))
;; No stream API in this port: events go to run's :on-event sink as they happen.(client/run c "What is 2 + 3?" {:toolkit toolkit :on-event (fn [ev] (case (:type ev) "text" (do (print (:delta ev)) (flush)) "tool_call" (println (str "[calling " (:name ev) "]")) nil))})(println) ; The sum is 5.(shutdown-agents)// Cargo.toml:// toolnexus = "0.25"// tokio = { version = "1", features = ["full"] }// serde_json = "1"// run: cargo runuse std::sync::Arc;
use serde_json::json;use toolnexus::client::{ EmitFn, GenerateStreamFn, InProcessOptions, InProcessRequest, InProcessResponse, InProcessToolCall, StreamEvent, create_in_process_client,};use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) });
// Your "model": first turn asks for a tool, second turn streams the answer. let model: GenerateStreamFn = Arc::new(|_cancel, req: InProcessRequest, emit: EmitFn| { Box::pin(async move { let last = req.messages.last().cloned().unwrap_or_default(); if last["role"] != "tool" { let call = InProcessToolCall { id: String::new(), name: "add".into(), arguments: json!({"a": 2, "b": 3}) }; return Ok(InProcessResponse { tool_calls: vec![call], ..Default::default() }); } let sum = last["content"].as_str().unwrap_or_default().to_string(); let words = ["The ", "sum ", "is ", &sum, "."]; for w in words { emit(StreamEvent::text(w)); // deltas must add up to content } Ok(InProcessResponse { content: words.concat(), ..Default::default() }) }) }); let client = create_in_process_client(InProcessOptions { model: "my-model".into(), generate_stream: Some(model), ..Default::default() });
let toolkit = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![add], ..Default::default() }) .await?; let mut events = client.stream("What is 2 + 3?", &toolkit)?; while let Some(ev) = events.recv().await { match ev.kind.as_str() { "text" => print!("{}", ev.delta), "tool_call" => println!("[calling {}]", ev.name), _ => {} } } println!(); // The sum is 5. Ok(())}When to use which
Section titled “When to use which”There are two very different things you can put behind generate, and they split the tool loop
differently:
| who runs the tools | what generate returns |
recipe | |
|---|---|---|---|
| A plain model (weights in-process, a CLI asked for one reply) | toolnexus: the model asks, the client executes | toolCalls or content |
One-shot CLI, Run a model in-process |
| An agent (Claude Code, Copilot, opencode… over ACP) | the agent: it has its own loop, and reaches your tools over MCP | only content: the agent’s final text |
ACP agent |
An agent already has a tool loop of its own. You do not want two loops fighting over the same
call, so in the agent case you give the agent your toolkit as an MCP server, run the toolnexus
client with an empty toolkit, and let generate return the agent’s answer.
Give your toolkit to an external agent: asMcpServer
Section titled “Give your toolkit to an external agent: asMcpServer”Any toolkit, whatever it aggregates (MCP servers, skills, native functions, HTTP tools, builtins,
A2A agents), can be handed to an agent you launch as one MCP server with
toolkit.asMcpServer() (SPEC §8C):
Go AsMCPServer(nil), Python as_mcp_server(), Java asMcpServer(), C# AsMcpServer(), Elixir
Toolnexus.Toolkit.as_mcp_server/2, Clojure toolnexus.serve/as-mcp-server, Rust
as_mcp_server (crate feature serve). It returns {url, token, close}:
- it listens on
127.0.0.1:<ephemeral>only, andurlishttp://127.0.0.1:<port>/mcp; - every request must carry
Authorization: Bearer <token>(a fresh 64-hex token per call), so other local processes cannot call your tools; a missing or wrong token is401; POST /mcpis the stateless streamable-HTTP endpoint; any other method is405(there is no server stream to hold open), any other path404;- the optional config is the usual
{name, version, tools}, andtoolsnarrows what is exposed.
You build whatever mcpServers entry your agent accepts; for an ACP agent that is the entry below.
import { createToolkit, defineTool } from "toolnexus"
const add = defineTool({ name: "add", description: "Add two numbers.", inputSchema: { type: "object", properties: { a: { type: "number" }, b: { type: "number" } }, required: ["a", "b"] }, run: ({ a, b }) => `${a + b}`,})
// Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent.const toolkit = await createToolkit({ builtins: false, extraTools: [add] })const server = await toolkit.asMcpServer() // { tools: [...] } narrows what is exposedconsole.log("MCP endpoint:", server.url) // http://127.0.0.1:<port>/mcp, POST only
// Every request needs the bearer token; this is the entry an ACP agent takes in session/new.const entry = { type: "http", name: "toolnexus", url: server.url, headers: [{ name: "Authorization", value: `Bearer ${server.token}` }] }const res = await fetch(entry.url, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json, text/event-stream", Authorization: entry.headers[0].value }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }),})console.log("tools/list:", res.status)await server.close()import asyncioimport jsonimport urllib.request
from toolnexus import create_toolkit, define_tool
def add(a: float, b: float) -> str: """Add two numbers.""" return str(int(a + b))
async def main(): # Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. toolkit = await create_toolkit(builtins=False, extra_tools=[define_tool(add, name="add")]) server = toolkit.as_mcp_server() # MCPServeConfig(tools=[...]) narrows what is exposed print("MCP endpoint:", server.url) # http://127.0.0.1:<port>/mcp, POST only
# Every request needs the bearer token; this is the entry an ACP agent takes in session/new. entry = {"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": "Bearer " + server.token}]} req = urllib.request.Request( entry["url"], json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).encode(), {"Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": entry["headers"][0]["value"]}) with urllib.request.urlopen(req) as res: print("tools/list:", res.status) server.close()
asyncio.run(main())package main
import ( "context" "fmt" "net/http" "strconv" "strings"
tn "github.com/muthuishere/toolnexus/golang")
type addArgs struct { A float64 `json:"a"` B float64 `json:"b"`}
func main() { ctx := context.Background() add := tn.NativeToolReflect("add", "Add two numbers.", func(_ context.Context, in addArgs) (string, error) { return strconv.FormatFloat(in.A+in.B, 'f', -1, 64), nil }) // Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. toolkit, err := tn.CreateToolkit(ctx, tn.Options{Builtins: false, ExtraTools: []tn.Tool{add}}) if err != nil { panic(err) } server, err := toolkit.AsMCPServer(nil) // &tn.MCPServeConfig{Tools: []string{...}} narrows what is exposed if err != nil { panic(err) } defer server.Close() fmt.Println("MCP endpoint:", server.URL) // http://127.0.0.1:<port>/mcp, POST only
// Every request needs the bearer token; this is the entry an ACP agent takes in session/new. entry := map[string]any{"type": "http", "name": "toolnexus", "url": server.URL, "headers": []map[string]any{{"name": "Authorization", "value": "Bearer " + server.Token}}} req, _ := http.NewRequest("POST", entry["url"].(string), strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}`)) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Authorization", "Bearer "+server.Token) res, err := http.DefaultClient.Do(req) if err != nil { panic(err) } res.Body.Close() fmt.Println("tools/list:", res.StatusCode)}// run: java -cp 'lib/*' Serve.java (lib/ = toolnexus-0.27.0.jar + its runtime deps)import io.github.muthuishere.toolnexus.McpServe;import io.github.muthuishere.toolnexus.NativeTool;import io.github.muthuishere.toolnexus.Toolkit;
import java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.util.List;import java.util.Map;
public class Serve { public static void main(String[] argv) throws Exception { NativeTool add = NativeTool.of("add", "Add two numbers.", Map.of("type", "object", "properties", Map.of("a", Map.of("type", "number"), "b", Map.of("type", "number")), "required", List.of("a", "b")), args -> String.valueOf(((Number) args.get("a")).intValue() + ((Number) args.get("b")).intValue()));
// Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. try (Toolkit toolkit = Toolkit.create(new Toolkit.Options().builtins(false).extraTools(add)); McpServe.LocalServer server = toolkit.asMcpServer()) { // asMcpServer(cfg) narrows what is exposed System.out.println("MCP endpoint: " + server.url()); // http://127.0.0.1:<port>/mcp, POST only
// Every request needs the bearer token; this is the entry an ACP agent takes in session/new. Map<String, Object> entry = Map.of("type", "http", "name", "toolnexus", "url", server.url(), "headers", List.of(Map.of("name", "Authorization", "value", "Bearer " + server.token()))); HttpResponse<String> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(server.url())) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") .header("Authorization", "Bearer " + server.token()) .POST(HttpRequest.BodyPublishers.ofString( "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/list\",\"params\":{}}")) .build(), HttpResponse.BodyHandlers.ofString()); System.out.println("tools/list: " + res.statusCode()); } }}// dotnet new console && dotnet add package Toolnexus && dotnet run (this file as Program.cs)using System.Net.Http.Headers;using System.Text;using Toolnexus;
var add = NativeTool.Of("add", "Add two numbers.", new Dictionary<string, object?> { ["type"] = "object", ["properties"] = new Dictionary<string, object?> { ["a"] = new { type = "number" }, ["b"] = new { type = "number" } }, ["required"] = new[] { "a", "b" }, }, args => $"{Convert.ToDouble(args["a"]?.ToString()) + Convert.ToDouble(args["b"]?.ToString())}");
// Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent.await using var toolkit = await Toolkit.CreateAsync(new Toolkit.Options { Builtins = false, ExtraTools = [add] });await using var server = toolkit.AsMcpServer(); // an MCPServeConfig { Tools = ... } narrows what is exposedConsole.WriteLine($"MCP endpoint: {server.Url}"); // http://127.0.0.1:<port>/mcp, POST only
// Every request needs the bearer token; this is the entry an ACP agent takes in session/new.var entry = new Dictionary<string, object?>{ ["type"] = "http", ["name"] = "toolnexus", ["url"] = server.Url, ["headers"] = new[] { new Dictionary<string, object?> { ["name"] = "Authorization", ["value"] = "Bearer " + server.Token } },};using var http = new HttpClient();var req = new HttpRequestMessage(HttpMethod.Post, (string)entry["url"]!){ Content = new StringContent("""{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}""", Encoding.UTF8, "application/json"),};req.Headers.Accept.ParseAdd("application/json");req.Headers.Accept.ParseAdd("text/event-stream");req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", server.Token);var res = await http.SendAsync(req);Console.WriteLine($"tools/list: {(int)res.StatusCode}");# usage: elixir serve.exsMix.install([{:toolnexus, "~> 0.27"}])
add = Toolnexus.define_tool( name: "add", description: "Add two numbers.", input_schema: %{"type" => "object", "properties" => %{"a" => %{"type" => "number"}, "b" => %{"type" => "number"}}, "required" => ["a", "b"]}, execute: fn %{"a" => a, "b" => b} -> "#{a + b}" end )
# Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent.toolkit = Toolnexus.create_toolkit!(builtins: false, extra_tools: [add])server = Toolnexus.Toolkit.as_mcp_server(toolkit) # %{"tools" => [...]} narrows what is exposedIO.puts("MCP endpoint: #{server.url}") # http://127.0.0.1:<port>/mcp, POST only
# Every request needs the bearer token; this is the entry an ACP agent takes in session/new.entry = %{"type" => "http", "name" => "toolnexus", "url" => server.url, "headers" => [%{"name" => "Authorization", "value" => "Bearer " <> server.token}]}
# Plain OTP :httpc, so the recipe needs no HTTP dependency.{:ok, _} = Application.ensure_all_started(:inets)headers = [{~c"accept", ~c"application/json, text/event-stream"}, {~c"authorization", String.to_charlist(hd(entry["headers"])["value"])}]body = JSON.encode!(%{jsonrpc: "2.0", id: 1, method: "tools/list", params: %{}}){:ok, {{_, status, _}, _, _}} = :httpc.request(:post, {String.to_charlist(entry["url"]), headers, ~c"application/json", body}, [], [])IO.puts("tools/list: #{status}")server.close.();; deps.edn: {:deps {net.clojars.muthuishere/toolnexus {:mvn/version "0.27.0"}}};; run: clojure -M serve.clj(ns serve (:require [koine.http :as http] ; koine ships with toolnexus; no extra dependency [koine.json :as json] [toolnexus.core :as tn] [toolnexus.native :as native] [toolnexus.serve :as serve]))
(def add (native/native-tool {:name "add" :description "Add two numbers." :input-schema {:type "object" :properties {:a {:type "number"} :b {:type "number"}} :required ["a" "b"]} :run (fn [{:keys [a b]}] (str (+ a b)))}))
;; Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent.(def toolkit (tn/build {:builtins false :tools [add]}))(def server (serve/as-mcp-server toolkit)) ; (as-mcp-server tk {:tools [...]}) narrows what is exposed(println "MCP endpoint:" (:url server)) ; http://127.0.0.1:<port>/mcp, POST only
;; Every request needs the bearer token; this is the entry an ACP agent takes in session/new.(def entry {:type "http" :name "toolnexus" :url (:url server) :headers [{:name "Authorization" :value (str "Bearer " (:token server))}]})(def res (http/request {:method :post :url (:url entry) :headers {"Content-Type" "application/json" "Accept" "application/json, text/event-stream" "Authorization" (-> entry :headers first :value)} :body (json/write-str {:jsonrpc "2.0" :id 1 :method "tools/list" :params {}})}))(println "tools/list:" (:status res))((:close server))(shutdown-agents)// Cargo.toml:// toolnexus = { version = "0.25", features = ["serve"] } # "serve" is on by default// tokio = { version = "1", features = ["full"] }// serde_json = "1"// run: cargo runuse serde_json::json;use tokio::io::{AsyncReadExt, AsyncWriteExt};use tokio::net::TcpStream;use toolnexus::{BuiltinsConfig, ToolkitOptions, create_toolkit, define_tool};
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let schema = json!({"type": "object", "properties": {"a": {"type": "number"}, "b": {"type": "number"}}, "required": ["a", "b"]}); let add = define_tool("add", "Add two numbers.", Some(schema), |a, _ctx| async move { Ok::<_, String>((a["a"].as_f64().unwrap_or(0.0) + a["b"].as_f64().unwrap_or(0.0)).to_string()) });
// Any toolkit (MCP servers, skills, native, HTTP, builtins) can be served to a local agent. let toolkit = create_toolkit(ToolkitOptions { builtins: Some(BuiltinsConfig::off()), extra_tools: vec![add], ..Default::default() }) .await?; let server = toolkit.as_mcp_server(None).await?; // Some(McpServeConfig { tools, .. }) narrows it println!("MCP endpoint: {}", server.url); // http://127.0.0.1:<port>/mcp, POST only
// Every request needs the bearer token; this is the entry an ACP agent takes in session/new. let entry = json!({"type": "http", "name": "toolnexus", "url": server.url, "headers": [{"name": "Authorization", "value": format!("Bearer {}", server.token)}]});
// A bare HTTP/1.1 POST, so the recipe needs no HTTP client crate. let body = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}).to_string(); let host = server.url.trim_start_matches("http://").split('/').next().unwrap_or_default(); let request = format!( "POST /mcp HTTP/1.1\r\nHost: {host}\r\nContent-Type: application/json\r\n\ Accept: application/json, text/event-stream\r\nAuthorization: {}\r\n\ Content-Length: {}\r\nConnection: close\r\n\r\n{body}", entry["headers"][0]["value"].as_str().unwrap_or_default(), body.len() ); let mut conn = TcpStream::connect(host).await?; conn.write_all(request.as_bytes()).await?; let mut reply = String::new(); conn.read_to_string(&mut reply).await?; let status = reply.split_whitespace().nth(1).unwrap_or("?"); println!("tools/list: {status}"); server.close(); Ok(())}To serve a toolkit to the network rather than to one local agent, use toolkit.serve(addr, { mcp })
(owns a port, no token) or mount toolkit.mcpHandler(cfg) in your own HTTP server, behind your own
auth: see serve MCP and mcpHandler.
In this section
Section titled “In this section”- ACP agent recipe: drive any ACP agent with
loadACP, your toolkit attached throughasMcpServer, streaming its text. - One-shot CLI recipe: one CLI process per turn (
claude -p,codex exec), toolnexus runs the tools. - Extend it: add your own agent, isolation, model selection, permissions.