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.
npm install @kuralle-agents/mcp @kuralle-agents/pluginsQuick start
Section titled “Quick start”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.
Runtime matrix
Section titled “Runtime matrix”| Transport | Cloudflare Workers / DO | Node / Bun (root @kuralle-agents/mcp) | Node / Bun (@kuralle-agents/mcp/node) |
|---|---|---|---|
streamable-http | yes | yes | yes |
sse | yes | yes | yes |
stdio | no — no subprocess in workerd | skipped (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:
bun packages/mcp/examples/runtime-matrix.tsmcpTools(servers, opts?)
Section titled “mcpTools(servers, opts?)”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:
type | Fields |
|---|---|
stdio | name, command, optional args, env, cwd, cwdRoot, pluginRoot, pluginDataRoot |
streamable-http | name, url, optional headers |
sse | name, url, optional headers |
McpOptions
Section titled “McpOptions”| Option | Purpose |
|---|---|
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.
Two plugins, one server name
Section titled “Two plugins, one server name”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.
Sessions and credentials
Section titled “Sessions and credentials”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.
Policy is the approval gate
Section titled “Policy is the approval gate”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']),});allowedHosts — SSRF guard
Section titled “allowedHosts — SSRF guard”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.
Disclosure budget
Section titled “Disclosure budget”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 / field | Default | Meaning |
|---|---|---|
DEFAULT_DISCLOSURE_BUDGET_TOKENS | 20_000 | ≈10% of a 200k context window |
disclosure.budget | 20_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.
What a deferred tool keeps
Section titled “What a deferred tool keeps”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 schema | Malformed tool call |
|---|---|
Bare { type: 'object' } | 2 of 5 runs |
Names, types and required | 0 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.
Server instructions are never forwarded
Section titled “Server instructions are never forwarded”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.
Hibernation and reconnect
Section titled “Hibernation and reconnect”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.
A wake makes no tools/list call
Section titled “A wake makes no tools/list call”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.
What persists, and what never does
Section titled “What persists, and what never does”A PersistedServer row holds exactly six fields:
| Field | Example |
|---|---|
id | "docs" |
name | "docs" |
type | "streamable-http" |
url | "https://mcp.example.com/mcp" |
tools | PersistedTool[] — [{ name: "search", description: "…", inputSchema: { … } }] |
toolFingerprints | Record<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:
| Drift | What happens |
|---|---|
description or inputSchema changed | the 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 added | withheld entirely — the model never knew it, so there is nothing to explain |
| a tool was removed | logged 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 foundThis 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.
Related
Section titled “Related”- Agent Plugins —
mcp.jsonlayout and five failure widths - Tool Policy — govern MCP tool execution
- Skills — plugin
skills/use the same progressive disclosure model - Durable Execution — MCP tools replay through the effect log