Skip to content

MCP

MCP (Model Context Protocol) lets an agent call tools hosted on external servers. Agent Plugins declare servers in mcp.json; Kuralle connects them with mcpTools(servers, opts) and projects remote tools into the agent's tool map as durable defineTool entries qualified as serverName__toolName.

Terminal window
npm install @kuralle-agents/mcp @kuralle-agents/plugins

After loading a plugin (see Agent Plugins), pass its mcpServers to mcpTools:

import { loadAgentPlugin } from '@kuralle-agents/plugins';
import { mcpTools } from '@kuralle-agents/mcp';
const result = await loadAgentPlugin(fs, '/plugins/acme');
if (!result.ok) throw new Error(result.rejection.message);
const { tools: remoteTools, close } = await mcpTools(result.plugin.mcpServers, {
allowedHosts: ['127.0.0.1', 'api.example.com'],
onDiagnostic: (d) => console.warn(d.message),
});
const agent = defineAgent({
id: 'support',
model,
tools: { ...localTools, ...remoteTools },
});
// When the session ends. The connections stay open until this runs.
await close();

Each MCP tool executes through the runtime effect log (replay: true). Connection failures for one server emit a diagnostic and skip that server — siblings still connect. The turn's AbortSignal reaches tools/call, so cancelling a turn cancels the outstanding request to the server.

TransportCloudflare Workers / DONode / Bun (root @kuralle-agents/mcp)Node / Bun (@kuralle-agents/mcp/node)
streamable-httpyesyesyes
sseyesyesyes
stdiono — no subprocess in workerdskipped (diagnostic)yes

On the root export, a stdio config produces no tools and a diagnostic with rule: "unsupported-transport" naming Workers/workerd and pointing to @kuralle-agents/mcp/node. That is intentional: keeping stdio off the root preserves Cloudflare portability.

Prove the matrix live:

Terminal window
bun packages/mcp/examples/runtime-matrix.ts
function mcpTools(
servers: readonly McpServerConfig[],
opts?: McpOptions,
): Promise<McpToolset>;
interface McpToolset {
readonly tools: Record<string, AnyTool>;
readonly reconciled: Promise<void>;
close(): Promise<void>;
}

reconciled settles once every cached tool listing has been checked against its server. It is already resolved unless this toolset was rebuilt from storage, so you never have to test for it — see Hibernation and reconnect.

McpServerConfig (from @kuralle-agents/plugins) is a discriminated union:

typeFields
stdioname, command, optional args, env, cwd, cwdRoot, pluginRoot, pluginDataRoot
streamable-httpname, url, optional headers
ssename, url, optional headers
OptionPurpose
allowedHosts?SSRF guard for remote URLs — see below
auth?(server, { session }) => Promise<{ token }> — resolved before the handshake and again per call; requires session
session?The session a toolset belongs to. Required with auth, or with a resolver allowedHosts
tools?Discovery-time { allow: string[] } or { block: string[] } — hide tools from the model (mutually exclusive)
disclosure?Schema disclosure budget — see below
timeoutMs?Per-connection timeout (default 60_000 ms)
storage?Persist remote server metadata for hibernation wake (rebuildMcpToolsFromStorage)
onDiagnostic?(Diagnostic) => void — connection, auth, and transport failures
fetch?Test hook: custom fetch for HTTP transports

The stdio fields beyond command/args/env/cwd are produced by the plugin parser, not written by hand: cwdRoot records whether cwd was declared against ${PLUGIN_ROOT} or ${PLUGIN_DATA}, and the two root fields carry the plugin's location so the launcher can supply PLUGIN_ROOT and PLUGIN_DATA and check containment. See Agent Plugins.

Qualified tool names use a double underscore: payments__create_invoice (server payments, remote tool create_invoice).

MCP barely constrains 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 is rewritten — illegal characters to _, clamped to 64, with a short deterministic hash of the original appended so two remote tools cannot collide. Legal names pass through verbatim, so Policy rules keep matching what you wrote.

A server name is only unique inside one mcp.json. Agent Plugins has no global registry — §5.5 constrains a plugin name's character set, not its uniqueness — so two independently authored plugins both calling a server local is the expected case, not an edge one.

When two servers in one mcpTools call share a name, both are projected under a suffixed name (local_a1b2c3d4__search) and a server-name-collision diagnostic names the clash. Neither server is dropped.

Both are suffixed rather than the first keeping the plain name, because first-wins would make every projected name depend on the order you happened to load plugins in — reorder the list and a Policy rule written against local__transfer silently starts matching a different backend. The suffix hashes the server's identity (its URL, or its command line), so it is stable across processes and independent of load order.

A name that does not collide is untouched. Rename one of the two servers to get the plain name back.

auth is resolved before initialize, so the bearer covers the handshake and tools/list — which is what an OAuth-protected server demands, since it rejects the handshake long before a tool call exists. Fixing the credential onto the connection means 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 with no session throws at wiring time.

A server with static headers (or none) needs no session, and one process-wide toolset is right for it.

Discovery filters (tools.allow / tools.block) control visibility — tools filtered out never reach the model. Policy controls authorization — a visible tool the policy denies returns toolDeniedResult with your reason, and execute never runs.

Wire a restrictive policy on agents that call untrusted plugin servers:

import { readOnlyPolicy } from '@kuralle-agents/core';
createRuntime({
agents: [agent],
policy: readOnlyPolicy(['write_file', 'payments__charge_card']),
});

Plugin-supplied URLs are untrusted input. Without a guard, a server entry could point at an internal metadata endpoint. Set allowedHosts to the hostnames you expect:

await mcpTools(servers, {
allowedHosts: ['127.0.0.1', 'mcp.example.com'],
});
  • undefined — no hostname check (appropriate only for fully trusted configs).
  • string[] — remote URL hostname must match an entry (case-insensitive).
  • (server, { session }) => string[] — per-server, per-session allowlist.

A blocked host emits a diagnostic (rule: "connection-failure") and skips that server.

Large MCP servers can flood the prompt with tool input schemas. Kuralle applies a per-server disclosure budget before projecting schemas into the tool map and system prompt surface.

Constant / fieldDefaultMeaning
DEFAULT_DISCLOSURE_BUDGET_TOKENS20_000≈10% of a 200k context window
disclosure.budget20_000 or 'auto'Token budget per server (4 chars ≈ 1 token)
disclosure.alwaysLoad[]Server names that always inline full schemas

For each connected server, Kuralle estimates the token cost of inlining every tool's description + JSON Schema. If the estimate is over budget, schemas are deferred: tools still appear, but their input schema collapses to a stub and descriptions gain "Full input schema available via mcp__describe_tool."

When any server defers, Kuralle registers mcp__describe_tool — call it with the qualified tool name to fetch the full JSON Schema before invoking the deferred tool.

The budget governs schema bulk only. Under it sits a floor no tier drops: every tool's name and description, which is what the model routes on. A server broad enough for that catalog alone to exceed the budget is over budget with nothing left to shed, and Kuralle emits a disclosure-budget-exceeded diagnostic naming the count, the measured floor, and the tools filter as the way down — rather than trimming descriptions and destroying routing to make a number fit.

await mcpTools(servers, {
disclosure: {
budget: 20_000,
alwaysLoad: ['trusted-small-server'],
},
});

composeMcpSystemPrompt(tools, opts) builds the MCP-related system prompt fragment: inlined schemas only for servers under budget; deferred schemas omitted. Use it when assembling custom system prompts alongside the auto-registered tools.

estimateTokens(text) is exported so you can measure a prompt yourself. It is a deliberate four-characters-per-token approximation, not a tokenizer. A tokenizer would add a dependency to the workerd-clean root export, and the threshold it feeds is an order-of-magnitude decision.

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

That split is not cosmetic. An earlier version replaced the whole schema with { type: 'object' }, and the model — left with no parameter names at generation time — had to copy them out of a describe_tool result. It did that badly:

Deferred schemaMalformed tool call
Bare { type: 'object' }2 of 5 runs
Names, types and required0 of 5 runs

A malformed call looks like a tool name with the argument folded into it. Names and types are a small fraction of schema tokens; descriptions are the bulk. So the cheap half stays.

The MCP protocol lets servers advertise free-text instructions. Kuralle discards them — they are never appended to the system prompt. Third-party prose reaching the system prompt verbatim is a prompt-injection seam; tool descriptions and schemas (subject to the disclosure budget) are the only server-originated text that enters the model context.

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

import {
createSqliteMcpConnectionStore,
mcpTools,
rebuildMcpToolsFromStorage,
} from '@kuralle-agents/mcp';
const store = createSqliteMcpConnectionStore(ctx.storage.sql);
// Cold start.
const toolset = await mcpTools(servers, { storage: store, allowedHosts, session });
// After a wake, before the first tool call.
const rebuilt = await rebuildMcpToolsFromStorage(
servers,
{ storage: store, allowedHosts, session },
{ stdio: false },
);

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

Use createMemoryMcpConnectionStore() on Node and Bun, and in tests.

The row also carries the catalogue the server published last time, so a wake projects a tool map from storage before it asks the server anything. Without it, every wake paid connect → handshake → tools/list → project before the first token of the turn.

The freshly listed catalogue is then checked in the background, and the tool map is corrected in place if it moved:

const rebuilt = await rebuildMcpToolsFromStorage(servers, opts, { stdio: false });
// Usable immediately — projected from the persisted listing, no round trip.
agent.tools = rebuilt.tools;
// Optional: wait for the catalogue to be re-checked against the server.
await rebuilt.reconciled;

Reconciliation is deliberately not blocking. Awaiting the fresh listing before returning the map would restore the exact round trip the cache removes, so there would be no reason to have built it.

The admitted cost is a window where the model can call a tool the server has withdrawn since. That case is handled rather than hoped about: each connection tracks what the server currently publishes, and a call for anything else fails with a message the model can act on — "MCP tool docs__search is no longer published by server docs." Because tools is corrected in place, a turn already in flight keeps the snapshot it started with, and the next turn sees the correction.

Await reconciled when you would rather pay the round trip than risk one stale turn.

A PersistedServer row holds exactly six fields:

FieldExample
id"docs"
name"docs"
type"streamable-http"
url"https://mcp.example.com/mcp"
toolsPersistedTool[][{ name: "search", description: "…", inputSchema: { … } }]
toolFingerprintsRecord<string, string> — the trust baseline, one digest per tool

No function, socket, fetch override, auth resolver, token, or configured header is ever written. The store is a one-wake capability seed, not a config source.

tools is public catalogue metadata — the same text the model is shown in its prompt — never what authenticates to the server. A stored listing that no longer parses is dropped rather than thrown: it is a cache, so losing it costs one tools/list on the next wake, where throwing would strand a Durable Object that could reconnect perfectly well.

Tool drift — the rug pull, and what stops it

Section titled “Tool drift — the rug pull, and what stops it”

tools and toolFingerprints look similar and are opposites. tools is a cache, refreshed on every reconcile so a wake can project without a round trip. toolFingerprints is a trust baseline, recorded once and never rewritten — because a baseline that follows the server is a baseline that follows an attacker.

A remote server can change a tool's description or inputSchema after you trusted it. Those strings are what the model reads, so a server that redefines issue_refund after the fact changes what every prompt naming that tool actually asks for. On each listing — cold start and background reconcile alike — the fresh catalogue is fingerprinted and compared against the baseline, per tool:

DriftWhat happens
description or inputSchema changedthe tool is quarantined: it keeps its name, but its description is replaced with ours, its schema is emptied, and calling it is refused with a readable reason
a tool was addedwithheld entirely — the model never knew it, so there is nothing to explain
a tool was removedlogged and ignored; the capability is simply gone

A changed tool is quarantined rather than removed so the model can tell the user why a capability it previously had is unavailable, instead of watching it vanish. Everything the server controls is dropped on the way through — the drifted description is the attack, so it never reaches the prompt, and neither does a widened schema.

Re-trusting. retrustMcpServer(store, serverName) clears the recorded baseline, so the next connect re-establishes it from what the server currently publishes:

import { retrustMcpServer } from '@kuralle-agents/mcp';
await retrustMcpServer(store, 'docs'); // → true when a row was found

This is the only sanctioned way back. save() deliberately cannot replace a baseline — both stores drop an incoming one when they already hold it, which is what stops a compromised catalogue from becoming the trusted one by being written again.

The whole server is never refused. Refusing it would fail closed on the two harmless cases and turn a vendor's routine deploy into your outage.

This requires storage. With no store there is no baseline, so there is nothing to compare and the guard does not apply — an in-process run with no persistence has no notion of "what we trusted last time". This is not a setting you can turn off; it is what having no history means.

Upgrading. A row written before this shipped has tools but no toolFingerprints. The baseline is then derived from that stored catalogue — the last listing you actually served to a model — rather than from whatever the server answers at upgrade time, so a server that drifted before you started looking is caught on the first projection.

Re-trusting. save() alone cannot replace a baseline; both stores keep the first one recorded. Re-trusting a server is deliberately two steps, remove(id) then save(row). There is no UI for it.

A cached listing dies with the row it belongs to. If the supplied config's type or url no longer matches what was persisted, the whole row is refused with a connection-failure diagnostic — a different endpoint is a different catalogue, whatever the entry is still called.

A Durable Object created before the tools column existed is migrated on the next createSqliteMcpConnectionStore call; no manual step is needed.

Configured headers stay out on purpose, even though they arrive legitimately through mcp.json. A plugin file may carry a plaintext secret, and copying that into durable storage would widen its blast radius from process memory to a database. You supply the config again on wake; the store records only which servers were connected.

createSqliteMcpConnectionStore takes any object matching McpSqlStorage, a structural shape that a real Durable Object ctx.storage.sql satisfies:

interface McpSqlStorage {
exec(query: string, ...bindings: unknown[]): Iterable<Record<string, unknown>>;
}

Only remote transports persist. A stdio server never reaches a Worker, so it never produces a row.