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

@kuralle-agents/mcp

v0.24.0

Published

MCP client for Kuralle agent plugins

Downloads

854

Readme

@kuralle-agents/mcp

An MCP client for Kuralle agents. It runs on Cloudflare Workers, Node, and Bun, and it projects remote MCP tools into a defineAgent call.

Install

npm install @kuralle-agents/mcp

This installs @kuralle-agents/core, @kuralle-agents/plugins, and @modelcontextprotocol/client (MIT) with it. Every @kuralle-agents/* package versions in lockstep, so install them at the same version.

What it does

mcpTools connects a list of MCP servers and returns an McpToolset — a tool map you attach to an agent, and the close() that ends the connections behind it. Each remote tool is projected under the name ${server}__${tool}.

Key exports:

  • mcpTools(servers, opts?) — connect servers and return McpToolset.
  • rebuildMcpToolsFromStorage(servers, opts, capabilities) — reconnect on wake from a persisted seed.
  • createMemoryMcpConnectionStore / createSqliteMcpConnectionStore — connection stores for Node/Bun and for Durable Objects.
  • composeMcpSystemPrompt — the MCP part of the system prompt.
  • estimateTokens, DEFAULT_DISCLOSURE_BUDGET_TOKENS, MCP_DESCRIBE_TOOL — the disclosure budget surface.

Usage

import { defineAgent } from '@kuralle-agents/core';
import { mcpTools } from '@kuralle-agents/mcp';

const { tools, close } = await mcpTools(
  [{ name: 'docs', type: 'streamable-http', url: 'https://mcp.example.com/mcp' }],
  {
    allowedHosts: ['mcp.example.com'],
    session,
    auth: async () => ({ token: process.env.DOCS_TOKEN! }),
  },
);

const agent = defineAgent({
  id: 'research',
  model,
  instructions: 'Answer using the documentation tools.',
  tools,
  policy: {
    decide: (r) => (r.toolName.startsWith('docs__delete') ? { kind: 'ask' } : { kind: 'allow' }),
  },
});

Call close() when the session ends. The connections stay open until you do — dropping the reference does not close a socket or end an SSE stream.

Credentials and session scope

auth runs before initialize, so the bearer covers the handshake and tools/list, not only tools/call. That is what an OAuth-protected server requires: it rejects the handshake, long before a tool call exists.

Resolving the credential at connect time fixes it onto the connection, so a toolset built with auth belongs to one session. Pass session, build one toolset per session, and close() it when the session ends. Supplying auth without session throws at wiring time rather than silently authenticating every user as whoever connected first.

async function toolsetFor(session: Session) {
  return mcpTools(servers, {
    session,
    allowedHosts: ['mcp.example.com'],
    auth: async (_server, ctx) => ({ token: await tokenFor(ctx.session) }),
  });
}

A server that needs no dynamic credential — static headers from mcp.json, or none at all — needs no session, and one process-wide toolset is correct for it.

auth is re-resolved on every call as well, layered over the connect-time credential, so a token rotated mid-session takes effect without a reconnect.

Cancellation

The turn's AbortSignal reaches tools/call. When a turn is cancelled or a tool times out, the request to the MCP server is cancelled too — not just the promise on this side.

Runtime matrix

| Runtime | Transports | Import | | --- | --- | --- | | Cloudflare Workers / Durable Objects | streamable-http, sse | @kuralle-agents/mcp | | Node | + stdio | @kuralle-agents/mcp/node | | Bun | + stdio | @kuralle-agents/mcp/node |

The root export is fetch-only. stdio lives at the /node subpath because it pulls cross-spawn, which is not workerd-clean. Import @kuralle-agents/mcp/node once at startup on Node or Bun to register the transport.

A stdio server on Workers fails with a named error that states the transport, the runtime limit, and the remedy. Workers have no subprocess, so this is a platform limit and no install fixes it.

The root export needs the nodejs_compat flag on Workers. It imports AsyncLocalStorage from node:async_hooks.

McpOptions

interface McpOptions {
  allowedHosts?: readonly string[] | ((server: string, ctx: { session: Session }) => readonly string[]);
  auth?: (server: string, ctx: { session: Session }) => Promise<{ token: string }>;
  session?: Session;               // required when `auth`, or a resolver `allowedHosts`, is set
  tools?: { allow?: readonly string[] } | { block?: readonly string[] };
  disclosure?: { budget?: number | 'auto'; alwaysLoad?: readonly string[] };
  timeoutMs?: number;              // default 60_000
  storage?: McpConnectionStore;
  onDiagnostic?: (d: Diagnostic) => void;
}

tools is a discovery filter and accepts either allow or block, never both. auth runs at connect time and again per call, never at parse time, and its result never reaches persisted run state.

Tool names

A projected name is ${server}__${tool} and is used verbatim, which is what keeps Policy rules and transcripts readable.

A server name is only unique inside one mcp.json, and Agent Plugins has no global registry, so two plugins both naming a server local is expected rather than exotic. When two servers in one call share a name, both get a suffix hashed from the server's identity (local_a1b2c3d4__search) and a server-name-collision diagnostic reports it. Neither is dropped. Suffixing both — rather than letting the first keep the plain name — is what stops a Policy rule silently repointing at a different backend when a plugin list is reordered.

MCP puts almost no constraint on what a server may publish as a tool name; model providers require ^[a-zA-Z0-9_-]{1,64}$ and reject the whole request otherwise. A name that would be rejected — search.docs, a 90-character name, a non-ASCII one — is therefore rewritten: illegal characters become _, the name is clamped to 64, and a short deterministic hash of the original is appended so two remote tools cannot collide. The rewrite is stable across processes, because durable journal entries are written against it.

Errors

MCP separates two failure kinds, and so does this client.

  • A protocol error (unknown tool, malformed request) rejects the call.
  • A tool execution error — a result with isError: true — also rejects, carrying the server's own message, prefixed MCP tool error:.

Every transport shares one result adapter, so this holds on stdio exactly as it does over HTTP. It did not always: stdio had a second copy that never checked isError.

The second case matters more than it looks. Those messages are written for the model to correct against: "Invalid departure date: must be in the future." Returning that text as an ordinary value would tell the model the call succeeded and record a success in the durable journal, so a replay would skip a call that never worked.

Security posture

  • Policy is the only approval gate. Every MCP tool call passes through Policy.decide. A deny prevents execution and the server receives nothing.
  • Tool annotations are never trusted. A malicious server can mark a destructive tool read-only, so annotations play no part in an approval decision.
  • Server instructions are never forwarded into the system prompt. They are third-party text and would be a prompt-injection seam.
  • allowedHosts is an SSRF guard. A plugin file supplies the URL, so a host outside the list fails with no outbound request.
  • stdio env is an allowlist, not an inherit.
  • A stdio command and cwd are re-checked through realpath before the spawn. A plugin shipping bin/server as a symlink outside its own root is refused, where a lexical check sees a clean plugin-relative path. The entry is skipped; the plugin's other components still load. This is containment, not a sandbox.

Disclosure budget

A broad server can publish hundreds of tools. Injecting every schema into every prompt is the cost progressive disclosure exists to avoid.

This is one code path with a threshold, not a mode flag. The decision is per server:

if tokensOf(server's tool definitions) <= budget  or  server in alwaysLoad:
    project each tool with its full input schema
else:
    project each tool with a deferred schema, and add MCP_DESCRIBE_TOOL

Every tool keeps its real ${server}__${tool} name in both cases, so Policy still discriminates by name and transcripts still record what ran.

The default budget is 20_000 tokens, about 10% of a 200k context window. estimateTokens is a deliberate four-characters-per-token approximation, not a tokenizer, because the threshold is an order-of-magnitude decision and the root export must stay dependency-free.

A deferred tool sheds schema prose, not the argument contract. It keeps parameter names, their scalar types and required, and drops descriptions, enums, formats and nested bodies. mcp__describe_tool still returns the full schema.

That split is load-bearing. An earlier version replaced the whole schema with { type: 'object' }; with no parameter names at generation time the model produced a malformed call in 2 of 5 live runs, against 0 of 5 once names were kept.

On a server so large that even names blow the budget, names are dropped too and the bare object schema is used. That bounds schema bulk at any tool count — the same budget applied twice, not a setting.

It does not bound the prompt. One floor applies to every tier: the catalog of every tool name and description is always present, because that is what the model routes on. The budget governs schema bulk on top of the catalog, never the catalog itself.

So a server broad enough for its catalog alone to exceed the budget is over budget with nothing left to shed. Trimming descriptions would buy the number back by destroying the routing signal, which is the wrong trade at any tool count. The client reports it instead — a disclosure-budget-exceeded diagnostic naming the tool count, the measured floor, and the tools filter as the way down — rather than implying a bound it cannot hold.

Hibernation on Durable Objects

A Durable Object hibernates, so a live connection cannot survive in memory. Persist a seed and rebuild on wake.

import { createSqliteMcpConnectionStore, mcpTools, rebuildMcpToolsFromStorage } from '@kuralle-agents/mcp';

const store = createSqliteMcpConnectionStore(ctx.storage.sql);

// first run
const toolset = await mcpTools(servers, { storage: store, allowedHosts, session });

// after a wake
const rebuilt = await rebuildMcpToolsFromStorage(
  servers,
  { storage: store, allowedHosts, session },
  { stdio: false },
);

A Durable Object is itself the session boundary, so the session-scoped toolset lands here naturally: one DO, one session, one set of connections rebuilt on each wake.

A wake makes no tools/list call. The row carries the catalogue the server published last time, so the tool map is projected from storage before the server is asked anything; the fresh listing is then checked in the background and the map corrected in place if it moved. McpToolset.reconciled settles when that check finishes — await it only if you would rather pay the round trip than risk one stale turn.

Reconciliation does not block on purpose: awaiting the fresh listing before returning the map would restore the exact round trip the cache removes. The admitted cost is a window where the model can call a tool the server has withdrawn since, so each connection tracks what the server currently publishes and refuses anything else with a message the model can act on.

Only an enumerated subset persists: id, name, type, url, tools, toolFingerprints. No function, socket, header, or credential is ever written. tools is public catalogue metadata — the same text the model is shown in its prompt — never what authenticates to the server. Configured headers stay out on purpose — copying them into durable storage would widen a plaintext secret's blast radius from process memory to a database. You supply the config again on wake; the store records only which servers were connected.

toolFingerprints is the tool-drift trust baseline, and it is the opposite of tools: tools is a cache refreshed on every reconcile, while the baseline is recorded once and never rewritten. Each listing is fingerprinted (description, inputSchema, title) and compared per tool. A changed tool is quarantined — it keeps its name so the model can explain the gap, but its description is replaced with ours, its schema is emptied, and calling it is refused. A newly added tool is withheld outright; a removed one is logged and ignored. retrustMcpServer(store, serverName) clears the baseline when a change was legitimate; save() alone cannot, by design. The server itself is never refused, because that would fail closed on a vendor's routine deploy. Drift detection needs storage: with no store there is no baseline and nothing to compare. save() cannot replace a recorded baseline, so re-trusting a server is remove(id) then save(row).

rebuildMcpToolsFromStorage has no fallback to the supplied servers when the store is empty. A wake rebuilds what was persisted, and a cold start is mcpTools. They are different situations. A row whose type or url no longer matches the supplied config is refused outright — a different endpoint is a different catalogue, whatever the entry is still called.

Related