toolnexus (Rust)
The Rust port of toolnexus, async on tokio: MCP servers, agent skills, native
and HTTP tools, the built-in coding tools and remote ACP agents behind one Tool interface,
schema adapters for OpenAI / Anthropic / Gemini, and a unified client with the tool-calling
loop. The same library, byte-identical, also in JavaScript, Python, Go, Java, C#, Elixir and
Clojure. It is held to conformance tier full (conformance/options_manifest.json), like
the other seven ports; the remaining gaps are listed at the end of this page.
Docs: docs site · Rust API reference (every runnable example there is compiled and run in CI).
The crate is published to crates.io from the next release. Until then, depend on it by git:
toolnexus = { git = "https://github.com/muthuishere/toolnexus" }.
[]
= "0.22.0"
# toolnexus = { version = "0.22.0", default-features = false } # MCP over stdio only, no `serve` (A2A / MCP server)
= { = "1", = ["full"] }
Zero to agent
use ;
use ;
let tk = create_toolkit.await?;
let client = create_client;
let result = client.run.await?;
println!;
tk.close.await;
The environment key is used only for the host it belongs to (api.openai.com →
OPENAI_API_KEY, api.anthropic.com → ANTHROPIC_API_KEY, openrouter.ai →
OPENROUTER_API_KEY). Any other base_url needs api_key or api_key_env.
Examples
All under rust/examples/, run with cargo run --example <name>:
| example | what | needs |
|---|---|---|
basic |
toolkit over the shared examples/ fixtures: MCP status, tools, skill catalog, OpenAI schema, a skill call. Same output as golang/examples/basic (JSON key order aside) |
npx for the MCP server |
agent |
a real model running skills + builtins + a native tool | OPENROUTER_API_KEY |
native_http |
define_tool and http_tool through the toolkit |
network only if you pass a URL |
streaming |
client.stream with tool calls, offline (in-process model) |
nothing |
pending |
the builtin question suspending, with and without wait_for (§10) |
nothing |
acp |
an ACP coding agent as the client's model, streaming its own tool calls | an agent command, or the bundled fake |
agents |
the §7D runtime: a coordinator fans out to sub-agents via task, then a child parks durably and is resumed |
nothing |
prompt_cache |
prompt cache + tool tiers against a local mock Anthropic: a stable tools array, call_tool unwrapped for hooks, cache-read usage. Mirrors golang/examples/promptcache |
nothing |
Toolkit (§4)
create_toolkit(ToolkitOptions { mcp_config, skills_dir, skills, skill_provider, skills_filter, skill_sample_limit, extra_tools, builtins, agents, disable_tools, disable_skills, wait_for }) assembles
MCP → skill → builtin → A2A agents → extra tools, first name wins, and an extra tool shadows a builtin of the
same name. tools(), get, execute, skills_prompt, mcp_status, to_openai /
to_anthropic / to_gemini, register (safe mid-turn) and close.
Built-in tools (§4A)
On by default: bash, read, write, edit, grep, glob, webfetch, question, apply_patch, todowrite, with the same names, schemas and outputs as the other ports. BuiltinsConfig
carries the toggles (enabled / disabled, per-tool tools), the host boundary (shell,
base_dir, confine_to_base_dir, kill_grace_ms) and the opt-in extras: background
(bash run_in_background + bash_output / kill_shell), web_search (your provider) and
notebook_edit. bash kills its whole process group on timeout or cancel (TERM, grace, KILL).
Every builtin carries ToolHints. grep takes context / before / after; read and
grep report truncated in metadata.
Client (§8)
ClientOptions has every option the other ports have: base_url, style (OpenAI |
Anthropic), model, api_key, api_key_env, headers, system_prompt, max_turns,
hooks (before_llm, after_llm, before_tool, after_tool), retries, retry_base_ms,
retryable_statuses (Retry-After is honoured), timeout_ms, store, on_metric, wait_for /
wait_for_ctx, request_params, body_transform, http_client, on_error, prompt_cache,
tool_tiers. run, ask (conversation memory by id), stream (an
mpsc::UnboundedReceiver<StreamEvent>), list_models, and create_in_process_client for a
model you run yourself. One message's tool calls run concurrently, each with its
ToolContext.call_id. Usage reports cache_read_tokens / cache_write_tokens.
Suspension (§10)
A tool returns pending(Request { kind, prompt, .. }) (or auth_required(url, prompt)). With
wait_for, the loop asks the host, then re-runs the tool once with ToolContext.answer. Without
it, run returns status: "pending" with the request, and a stream emits a pending event.
Relay tools and durable resume
toolnexus::relay::relay_tool(name, description, schema) declares a tool the HOST executes: the
call surfaces as a kind: "tool_call" Request (every relay call of the turn rides one Request, in
call order, with the provider call id). With wait_for the host's data.output becomes the tool
result inline; without it the run halts pending and client.run_with_answer(cancel, tk, history, &pending, &answer) (or ask_with_answer by conversation id) resumes it, replacing the
halt's placeholders so every call has one result. answer_output, answer_declined and
relay_answer build the Answer. A relay tool may not share a builtin's name.
Agents (§7D–§7F)
toolnexus::agents: agents::new(name, Spec { does, tools, soul, soul_file, team, budget, model, wait_for, hooks, on_metric, guardrails, completion, .. }), agent.run(opts, prompt),
agent.as_tool(opts) / as_tool_with(per_call), and agent.loop_(client_opts, toolkit) with
the completion gate (all_todos_done) and first-deny-wins guardrails. Runtime::new(Options { http_client + llm | in_process, registry, inbox_cap, max_concurrent_turns, shutdown, store, clock, hooks, on_metric, wait_for }) gives the six verbs (spawn, post, wake, wait,
interrupt, close) plus run_turn, resume, list, inspect, trace: bounded inboxes,
per-parent concurrency with FIFO slot transfer, a global turn gate around only the provider call,
hierarchical budgets with a named limit on every stop, nearest-interpreter escalation, and
durable pending with data.path and task reattachment on resume. Personas: from_dir,
compose_soul, memory_tool, start_agent (heartbeat on the runtime Clock). Compaction:
compactor(CompactorOptions { max_tokens, keep_tail, summarize, count_tokens, flush_to_memory, on_compact }) as a before_llm hook. Tests mirror golang/agents (trace strings and the
unset-run golden are byte-identical).
HTTP tools (§7)
toolnexus::http::http_tool(HttpToolOptions { name, url, method, headers, query, body, input_schema, .. }): {placeholder} URL segments, ${ENV} headers that are never logged, and
HTTP <status>: <body> errors.
A2A agents (§7A)
ToolkitOptions.agents (or a top-level agents block in the MCP config, or
tk.add_agent(card_url) at runtime) turns every skill on a remote Agent Card into a tool named
<card name>_<skill id> with source: ToolSource::A2a. A call sends one non-blocking
SendMessage, polls GetTask every poll_every_ms until the Task is terminal or the budget
(timeout_ms, or ToolContext.timeout_ms) runs out, and returns the artifact text; metadata
carries agent, taskId, state, polls, ms. ${ENV} headers are expanded at call time and
never logged. Cancelling the call's token stops it, even mid-request (A2A task <id> canceled).
use Agent;
let tk = create_toolkit.await?;
Serve: A2A agent and MCP server (§7B, §7C; serve feature, on by default)
use McpServeConfig;
use ;
let handle = tk.serve.await?;
println!; // dropping the handle (or handle.stop()) stops the server
With a2a, GET /.well-known/agent-card.json publishes a card built from the toolkit's skills
(never raw tools) and a JSON-RPC POST answers SendMessage / GetTask. Tasks run in the
background through client.run, or client.ask keyed by the message's contextId. A suspended
run is input-required with the prompt. A2aConfig.store is "memory" (default),
"file:<dir>" or a custom TaskStore. With mcp, a stateless streamable-HTTP MCP server (rmcp)
at /mcp exposes every toolkit tool, filtered by McpServeConfig.tools; §1B parts follow the text
block as native MCP content. Neither profile ⇒ every request is a 404. Inline options win over the
config's top-level a2a / mcpServer blocks. on_task / on_call observe each Task and call.
ACP client
Drive a local coding agent (Claude Code via claude-code-acp, gemini --experimental-acp,
codex-acp, goose, ...) over the Agent Client Protocol: one warm session per agent process.
use ;
let opts = new
.args
.cwd
.on_permission;
let agent = load.await?;
let mut turn = agent.prompt.await?;
while let Some = turn.next.await
let result = turn.finish.await?; // result.text = message chunks only; result.stop_reason
agent.close.await; // SIGTERM the process group, then SIGKILL after kill_grace
-
initializewaits up to 120 s by default (initialize_timeout), because adapters run throughnpxstart slowly the first time. -
Without
on_permission, a permission request is answered with the firstallow*option, as in the Go port. A handler slower thanpermission_timeout(30 s) is answeredcancelled, and the turn fails withAcpError::PermissionTimeout. -
agent.cancel()sendssession/cancel; the turn ends with stop reasoncancelled. -
An agent that exits mid-turn ends the turn with
AcpError::AgentExited. -
Requests the client does not serve (for example
fs/*) get JSON-RPC-32601. -
Turns on one session are serialized.
-
agent.generate(&messages)is Go'sGenerateseam (full transcript rendered, plus the supersedes marker). -
The agent runs in its own process group (POSIX) or Job Object (Windows), so
closealso stops what annpxadapter started. On Windows there is no graceful signal, so the job is terminated at once. The Windows path is compile-checked but has not been run. -
AcpError::AgentExited { code }carries the exit code when the agent exits.
Skills
let src = load_skills;
let system_extra = src.prompt; // the catalog for the system prompt
let tool = src.tool; // the one `skill` tool
The output is byte-identical to the other ports. This was checked by running Go and Rust over
the same 16-skill corpus (Unicode path, CRLF, symlinked dir, nested duplicates, sample cap)
and comparing the prompt, every skill tool output and the skip list. Also available:
skills as data (SkillDef), an allowlist, list_skills with typed skip reasons, and a
sample cap.
MCP
let cfg = parse_mcp_config_file?; // mcpServers | servers | mcp
let src = load_mcp.await; // never fails as a whole
// src.tools: `server_tool` names; src.status: connected | disabled | failed
src.close.await;
It is built on the official rmcp SDK. It supports stdio servers (own process group / Job
Object) and streamable HTTP (mcp-http feature, on by default; ${ENV} headers, never
logged). A failing server is isolated, and the per-server tools allowlist works. Each call's
timeout is an idle timeout: progress for that call resets it. Cancelling ToolContext.cancel
ends a call at once. A remote server that does not speak streamable HTTP is retried over legacy
SSE. Non-text content becomes ToolResult.parts, tool annotations become ToolHints,
resource_tools() lists and reads resources, and load_mcp_with(cfg, McpLoadOptions { on_server, wait_for }) reports each server as soon as it is ready. With a wait_for (or
ToolkitOptions.wait_for) the client advertises elicitation and bridges a server's
elicitation/create onto §10: form → kind:"input" (schema in data.schema), URL →
kind:"authorization"; ok → accept, reason:"declined" → decline, else cancel.
Tools and adapters
define_tool / define_typed_tool (native tools: Ok ⇒ output, Err ⇒ isError), and
to_openai / to_anthropic / to_gemini.
Translate, classifier and judge
Client::translate (§11) is the one-turn translator: OpenAI-shaped messages and tools in,
exactly one provider call, an OpenAI-shaped result out, no tool executed and no state kept.
create_classifier (§8B) is the judgment seam (styles SystemOne / Llm / Custom /
Static; noul, choice and score questions; byte-identical canonical requests), with
toolnexus::judge for ask / gate / Policy / Tape and toolnexus::judge::batteries for
ToolGuard, ToolRelevance, SkillRelevance, ToolResultFilter, IsComplete, AgentRouter,
ContentGuard and the opt-in ModelRouter (hooks via as_hook). All run against the shared
examples/judge/ fixtures.
Tests
cargo test is hermetic: a scripted fake ACP agent (examples/fake_acp_agent.rs), local fake
HTTP / MCP / SSE servers, no network and no real model. CI runs cargo fmt --check,
cargo clippy --all-targets -- -D warnings, cargo test and cargo test --no-default-features.
Not done yet
A Gemini client style
(Gemini is a schema adapter, as in Go). The Windows code paths compile but have not been run.
Tracked in openspec/changes/add-rust-port.
Contract across all eight ports: SPEC.md.