npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

toolnexus

v0.12.0

Published

Dynamic MCP servers + agent skills as uniform tools for any LLM (opencode-style).

Readme

toolnexus

npm license

Build an agent in a few lines. Point at an mcp.json and a skills/ folder, call run(), and you have a working agent — MCP servers, agent skills, your own functions, and HTTP endpoints unified as one tool set, driving any LLM.

Right-sized. Not a framework (no builders, advisors, runnables, config graphs), not a toy that falls over the moment you need streaming or a retry. Everything a real agent needs — the loop, hooks, streaming, retries, memory — and nothing it doesn't.

The JS/TypeScript port of toolnexus — the same library, byte-identical, also in Python, Go, Java, and C#. Built on @modelcontextprotocol/sdk (the MCP SDK opencode uses).

Install

npm install toolnexus

Quick start

Built-in tools are on by default, so an empty toolkit can already act:

import { createToolkit, createClient } from "toolnexus"

const tk = await createToolkit()                        // 10 built-in tools, on by default
const agent = createClient({
  baseUrl: "https://openrouter.ai/api/v1",              // any OpenAI- or Anthropic-style endpoint
  style: "openai",                                      // or "anthropic"
  model: "openai/gpt-4o-mini",
})

const { text } = await agent.run("What files are in this folder?", { toolkit: tk })
console.log(text)

createClient reads the key from OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY (or pass apiKey). The loop runs call → execute tools → feed back → repeat (up to maxTurns, default 10), with hooks, streaming (agent.stream(...)), retries/backoff, timeout, and conversation memory available.

With MCP + skills

Point the toolkit at an mcp.json and a skills/ folder — every MCP server tool and every SKILL.md becomes part of the same tool set. Skills are injected into the system prompt; a single skill tool loads each on demand (progressive disclosure).

const tk = await createToolkit({
  mcpConfig: "./mcp.json",     // path or a config object; local stdio + remote streamable-HTTP
  skillsDir: "./skills",       // a folder of <name>/SKILL.md (string or string[])
})

const { text } = await agent.run("Refund order 1234 for the customer.", { toolkit: tk })
console.log(text)
await tk.close()               // disconnect MCP servers

Suspension — login / approval / input (Pending / waitFor)

Some tools can't finish in one shot: they need an out-of-band, async resolution — a human logs in, approves a trade, types a value. That's one idea, not one feature: login is just the kind:"authorization" case. A tool returns Pending(request); the client calls your waitFor(request) → answer, then retries the tool with ctx.answer, and the model gets the real answer. Everything crossing a boundary is plain data, so it works the same across processes, agents, and restarts — and identically in all five language ports.

import { authRequired } from "toolnexus"

// a tool that needs login the first time:
defineTool({
  name: "get_balance", description: "…", inputSchema: { type: "object", properties: {} },
  run: (_args, ctx) =>
    authed ? `balance: ₹67,417` : authRequired("https://example.com/login", "Log in to continue"),
})

// the host supplies ONE function — how the link reaches the human is your call:
const agent = createClient({
  baseUrl, style: "openai", model,
  waitFor: async (request) => {          // interactive: open a browser + poll
    open(request.url); await pollUntilLoggedIn(); return { id: request.id, ok: true }
  },
  // headless desk: waitFor = send request.url to your channel + poll
  // another agent: waitFor = forward the request over A2A, await its answer
})

Omit waitFor for a durable host: run() doesn't hang — it returns { status: "pending", pending: request }, so you can deliver the link out-of-band (file, HTTP, channel) and resume later, even in another process. stream() emits a { type: "pending", request } event so a channel can push the link in real time. Runnable demo: examples/pending.ts.

Conversations / memory

run() is stateless — each call starts fresh. To keep a thread's history, use ask() with an id, or a conversation() object.

ask(prompt, { toolkit, id? }) — with an id, the client's ConversationStore remembers the thread: it loads that id's transcript, runs, saves the updated transcript, and returns the answer. The next ask with the same id continues it. Without an id it's a stateless one-shot (identical to run).

const agent = createClient({
  baseUrl: "https://openrouter.ai/api/v1",
  style: "openai",
  model: "openai/gpt-4o-mini",
  // store defaults to InMemoryConversationStore (process lifetime).
})

await agent.ask("My name is Muthu.", { toolkit: tk, id: "user-42" })
const { text } = await agent.ask("What's my name?", { toolkit: tk, id: "user-42" })
console.log(text)   // → "Your name is Muthu."

Persist across processes. A ConversationStore is two methods — implement it for a file, DB, or Redis and pass it as store:

import type { ConversationStore } from "toolnexus"

interface ConversationStore {
  get(id: string): Promise<any[] | undefined>   // stored transcript for id, or undefined
  save(id: string, messages: any[]): Promise<void>
}

const agent = createClient({ baseUrl, style: "openai", model, store: myFileStore })

The default is InMemoryConversationStore (also exported). For a quick in-process thread without ids, agent.conversation({ toolkit }) returns an object whose .send(prompt) retains history automatically (.messages, .reset()).

The same store powers inbound A2A: toolkit.serve keys each peer's turns by the A2A contextId through client.ask, so a remote agent's conversation is remembered too (see below).

Streaming with memory. The id also works while streaming. Pass on_text to ask to stream text deltas as they arrive — ask still returns the final RunResult — or iterate stream() directly. With an id, the thread is loaded before the stream and saved on the done event.

// block-style: stream deltas to stdout, still get the RunResult back — remembered under `id`
const res = await agent.ask("Draft a reply.", {
  toolkit: tk,
  id: "user-42",
  on_text: (delta) => process.stdout.write(delta),
})

// iterator: consume text + tool events; `id` makes it stateful (load before, save on `done`)
for await (const ev of agent.stream("And summarise it.", { toolkit: tk, id: "user-42" })) {
  if (ev.type === "text") process.stdout.write(ev.delta)
  else if (ev.type === "done") console.log("\n", ev.result.usage)
}

Observability / metrics

Zero-dependency, two outputs from one internal instrumentation — both opt-in, no cost when unused.

onMetric — a semantic event feed. Pass it to createClient and it receives a readable MetricEvent at each significant point: { event: "llm", model, status, ms, promptTokens, completionTokens } per model call, { event: "tool", tool, source, isError, ms } per tool call, and a terminal { event: "run", model, turns, toolCalls, totalTokens, ms, error? } per run/ask. Forward it anywhere (statsd, logs, OpenTelemetry) — the library holds no opinion.

const agent = createClient({
  baseUrl, style: "openai", model,
  onMetric: (ev) => console.log("[metric]", ev.event, JSON.stringify(ev)),
})

client.metrics() — built-in Prometheus text. The same events feed a tiny in-memory registry that renders the Prometheus text exposition format (no third-party dep). Mount it at GET /metrics:

import { createServer } from "node:http"

createServer((req, res) => {
  if (req.url === "/metrics") {
    res.setHeader("Content-Type", "text/plain; version=0.0.4")
    res.end(agent.metrics())
  } else {
    res.statusCode = 404
    res.end()
  }
}).listen(9090)

Series: toolnexus_llm_requests_total{model,status}, toolnexus_llm_tokens_total{type}, toolnexus_tool_calls_total{tool,source,is_error}, toolnexus_run_errors_total{model}, plus the toolnexus_llm_request_duration_seconds and toolnexus_tool_duration_seconds histograms. The rendered text is byte-identical across all five ports; OTLP push is a planned future companion.

Add your own tools

import { defineTool, httpTool } from "toolnexus"

tk.register(
  // a plain function → a tool  (run returns a string or a full ToolResult)
  defineTool({
    name: "add",
    description: "Add two numbers",
    inputSchema: {
      type: "object",
      properties: { a: { type: "number" }, b: { type: "number" } },
      required: ["a", "b"],
    },
    run: (args) => `${(args.a as number) + (args.b as number)}`,
  }),
  // a REST endpoint → a tool
  httpTool({
    name: "create_ticket",
    description: "Create a ticket",
    method: "POST",
    url: "https://api.example.com/tickets",
    headers: { Authorization: "Bearer ${API_TOKEN}" },   // ${ENV} expands from process.env, never logged
    inputSchema: {
      type: "object",
      properties: { title: { type: "string" } },
      required: ["title"],
    },
  }),
)

For httpTool: URL {placeholders} are filled from args; query arg names go to the querystring (all args on GET); the rest become the JSON body (body: "json" | "form" | "raw"). Non-2xx → { isError: true, output: "HTTP <status>: <body>" }.

Built-in tools

A fifth source ships 10 built-in toolsbash, read, write, edit, grep, glob, webfetch, question, apply_patch, todowrite (names + input schemas match opencode) — so an agent can act with zero wiring. They appear in the tool schema (toOpenAI()/toAnthropic()/toGemini()), like MCP tools — not the system prompt.

On by default. One global toggle turns the whole source off, or a per-tool tools map disables individual builtins on the all-on baseline:

const tk = await createToolkit({ builtins: false })
// also accepts { disabled: true } or { enabled: false }

// per-tool: drop bash, keep the other nine (unknown names ignored; whole-source-off still wins)
const tk2 = await createToolkit({ builtins: { tools: { bash: false } } })

bash/write/edit/apply_patch run commands and mutate the filesystem — the toggle is the off-switch for locked-down hosts.

A2A agents (agent-to-agent)

Call remote A2A agents (each of their skills becomes a tool) and serve your own toolkit as an agent other A2A peers can call. A genuine, minimal subset of real A2A (JSON-RPC 2.0; Agent Card at /.well-known/agent-card.json; SendMessage → poll GetTask). No streaming / push / auth in v1.

Outbound — call a remote agent. Each advertised skill becomes a tool named <agent>_<skill> (source: "a2a"):

import { createToolkit, agent } from "toolnexus"

const tk = await createToolkit({
  agents: [agent({ card: "https://researcher.example.com/.well-known/agent-card.json" })],
})

// or add one at runtime (an Agent descriptor or a bare card URL):
await tk.addAgent("https://writer.example.com/.well-known/agent-card.json")

agent({ card, headers?, timeout?, pollEvery? })headers support ${ENV} expansion (never logged); timeout defaults to 300000ms, pollEvery to 1000ms. A config object can also carry an agents block (mirrors mcpServers). A failing agent is isolated — logged, contributes no tools, never fatal.

Inbound — serve your toolkit as an agent. Opt in with the a2a profile; the Agent Card is built from your SKILL.md skills (never raw tools):

const handle = await tk.serve("127.0.0.1:0", {
  client: agent,                  // a createClient(...) instance fulfils each task
  a2a: {
    name: "research-agent",
    description: "Answers research questions.",
    // skills: ["hello-world"],   // subset of skills to advertise; omit ⇒ all
    store: "memory",              // "memory" (default) | "file:<dir>" | a custom TaskStore
  },
})
console.log(handle.url)   // GET /.well-known/agent-card.json ; POST / (SendMessage / GetTask)
await handle.stop()

serve(addr, { client, a2a?, onTask? }) fulfils each inbound SendMessage task via the client. Task persistence is a pluggable TaskStore — in-memory default, "file:<dir>", or your own. And it remembers: a message's A2A contextId keys the conversation through client.ask, so a peer's turns in the same context carry history via the client's ConversationStore.

Serve as an MCP server (be a gateway)

The inbound mirror of A2A: expose your whole toolkit as an MCP server so any MCP client — an IDE, another agent, a remote host — can call its tools. Point toolnexus at N MCP servers + skills + your own functions, then re-expose the union as one MCP server. Unlike A2A (which advertises skills and runs the client loop), the MCP client is the LLM host, so each tools/call dispatches straight to Tool.execute — no client, no tasks, no store.

// streamable-HTTP — an embeddable MCP server mounted at POST /mcp, beside any A2A routes:
const srv = await tk.serve("127.0.0.1:0", {
  mcp: { name: "my-gateway" /*, tools: ["echo"]  // subset; omit ⇒ every toolkit tool */ },
  onCall: (ev) => console.error(ev.name, ev.ms, ev.isError),
})
console.log(srv.url + "/mcp")   // connect any MCP client here
await srv.stop()

tools/list advertises every toolkit tool (name verbatim, inputSchema = the tool's parameters); mcp.tools narrows the surface. The mcp profile can also live in the config file as a top-level mcpServer block (singular — distinct from the client-side mcpServers). (Transport is streamable-HTTP; a stdio transport for local clients like Claude Desktop is a planned follow-up.)

Bring your own loop

Don't want the host loop? Use the schema adapters and execute calls yourself:

const tools  = tk.toOpenAI()       // or tk.toAnthropic() / tk.toGemini()
const system = tk.skillsPrompt()   // skills catalog for your system prompt
// when the model returns a tool call { name, arguments }:
const res = await tk.execute(name, args)   // → { output, isError, metadata }

The four sources

| Source | How | |--------|-----| | MCP servers | an mcp.json (mcpServers/servers/mcp); local stdio + remote streamable-HTTP, headers for auth | | Agent skills | a folder of <name>/SKILL.md; a skill tool loads each on demand + a system-prompt catalog | | Native tools | defineTool({...}) — a function becomes a tool | | HTTP / REST | httpTool({...}) — an endpoint becomes a tool, ${ENV} headers |

All appear as one uniform Tool in tk.tools(). Built-ins are a fifth source; remote A2A agents a sixth; in-process sub-agents a seventh (below).

Sub-agents & teams

An Agent is a Tool: a system prompt × a scoped toolkit view × the client loop. One agent delegates to another in-process via one task tool — isolated context, one result back, tokens rolled up, hierarchical budgets, durable suspension (SPEC.md §7D). The surface lives in the agents namespace (never the A2A agent above):

import { agents } from "toolnexus"

const explore = agents.agent("explore", {
  does: "read-only research",
  uses: { tools: [lookup] },              // this child sees ONLY these tools
})
const coder = agents.agent("coder", {
  does: "implements changes",
  soulFile: "./AGENTS.md",                // identity file → system prompt
  team: [explore],                        // team = the task tool's only targets; no team ⇒ no task tool
  budget: { maxTokens: 10_000 },
})

const r = await coder.run("fix the failing test", {
  llm: { baseUrl: "https://openrouter.ai/api/v1", style: "openai", model: "openai/gpt-4o-mini" },
})
console.log(r.status, r.text, r.totalTokens)   // "done" | "incomplete" | "pending", final text, tree-wide tokens

agents.asTool(...) drops an agent back into extraTools for the classic API. Full guide: Sub-agents & teams.

API

| Member | Description | |--------|-------------| | createToolkit(opts) | async factory → Toolkit (mcpConfig, skillsDir, builtins, agents, extraTools) | | createClient(opts) | the unified host loop (baseUrl, style, model, store?, hooks?, maxTurns?, retries?, timeoutMs?, onMetric?) | | client.run(prompt, { toolkit, signal?, history? }) | stateless run → RunResult | | client.ask(prompt, { toolkit, id?, on_text?, signal? }) | stateful with id (loads → runs → saves via store); one-shot without id; on_text streams text deltas | | client.conversation({ toolkit }) | in-process multi-turn object — .send(prompt), .messages, .reset() | | client.stream(prompt, { toolkit, id? }) | async-iterate StreamEvents (text deltas, tool calls/results, usage, done); id ⇒ stateful | | client.metrics() | Prometheus text exposition of cumulative metrics — mount at GET /metrics | | ConversationStore | get(id) / save(id, messages) — implement for file/db; default InMemoryConversationStore | | tk.tools() / tk.get(name) | the uniform tools | | tk.register(...tools) | add native/http/custom tools at runtime | | tk.execute(name, args, ctx?) | run a tool → ToolResult | | tk.skillsPrompt() | system-prompt skill catalog | | tk.mcpStatus() | per-server connection status | | tk.toOpenAI() / toAnthropic() / toGemini() | provider tool schemas | | tk.serve(addr, { client?, a2a?, onTask?, mcp?, onCall? }) | serve the toolkit over HTTP — A2A agent and/or streamable-HTTP MCP server (/mcp) → ServeHandle | | tk.addAgent(cardUrl \| Agent) | fetch a remote agent's card and register its skills as tools | | tk.close() | disconnect MCP servers | | defineTool / httpTool / agent | build native / HTTP / A2A tools | | InMemoryConversationStore · InMemoryTaskStore / FileTaskStore | default stores (memory / A2A tasks) |

More

Full docs, the other four language ports, and the shared cross-language behavior contract (SPEC.md) — plus runnable examples/: https://github.com/muthuishere/toolnexus

MIT licensed.