Docs menu
MCP client, server & firewall
Consume external MCP tools as durable AI SDK tools, expose your own over MCP with server-side exactly-once, and gate both with a firewall that pins tool descriptions against rug-pulls.
What it is for#
MCP is how an agent reaches tools it did not write. That is the point and also the problem: a tool's definition — its description and input schema — is data the server hands you, and the model reads it as an instruction. A server can serve one description while you are evaluating it and a different one later.
This package covers both directions. mcpTools connects to an MCP server over stdio or streamable HTTP and turns each discovered tool into an AI SDK tool; createMcpServer exposes your own tools the other way. And mcpFirewall produces an ordinary Guard that gates the calls.
Durability is inherited, not re-implemented. There is no separate journal logic here: an MCP tool call inside runDurable is journaled like any other tool call.
Client — external tools, durably#
mcpTools connects lazily: the call itself performs no I/O. The transport opens on the first tools() or describeTools(), and that one discovery is shared for the lifetime of the handle. close() is idempotent — a no-op if you never connected.
import { mcpTools } from '@gnldev/mcp';
const github = mcpTools({
transport: { kind: 'http', url: 'https://mcp.example.com/mcp' },
prefix: 'github_', // avoids name collisions with your own tools
});
await runDurable({
runId: 'issue-42',
journal,
model,
tools: { ...(await github.tools()) },
prompt: 'Triage the newest issue',
});
await github.close();Exactly-once crosses the wire. Inside runDurable, the durableTool wrapper hands each call an idempotencyKey of {runId}:{toolCallId}; the client carries it into the MCP request as params._meta.idempotencyKey. So you get client-side exactly-once from the journal, and if the other end runs createMcpServer({ journal }), server-side exactly-once as well. With no key present, _meta is not sent at all and the old behaviour is preserved.
Firewall — three gates on one Guard#
mcpFirewall returns a plain Guard, so it goes straight into runDurable({ guard }) or chains with another guard through composeGuards. It applies three checks in order.
1 · Allowlist / denylist. Without allow it is fail-open: everything runs except what deny matches. With allow it is fail-closed: every tool not on the list is denied. deny is evaluated after allow, so when both match, deny wins. Patterns can be strings or regexes.
2 · Description pinning. The first time a tool is seen, the hash of its description and input schema is written to the journal with claim() under __mcp_pin__:<server>:<tool> — the winning write is the permanent pin. Every later call compares the server's current hash against it. If it changed, the call returns require-approval with both hashes in the reason. Because the pin lives in the journal, resume and replay reach the same decision.
3 · Per-run call ceiling. With maxCallsPerRun, the firewall counts that tool's successful calls in the run and returns require-approval once the ceiling is reached.
import { mcpFirewall, composeGuards } from '@gnldev/mcp';
const guard = mcpFirewall({
server: 'github', // part of the pin key — two servers never collide
journal,
tools: await github.describeTools(),
allow: ['github_list_issues', /^github_read_/], // fail-closed once given
deny: ['github_delete_repo'], // evaluated after allow
maxCallsPerRun: 5,
});
await runDurable({ runId: 'issue-42', journal, model, tools, guard, prompt });
// a changed description ->
// require-approval: "'github_list_issues' tool description changed — poisoning risk
// (pinned: 4b1e…, current: 90ac…)"
// chain it with your policy guard — the firewall runs first
const both = composeGuards(guard, policyGuard(journal, { fallback: 'allow' }));Server — expose your own tools#
createMcpServer publishes your tools over MCP. Pass a journal and every callTool is wrapped with durableTool: a repeated request carrying the same idempotencyKey produces the side effect once. Arguments are validated before execute when the schema is executable (zod/valibot or standard-schema); with a plain JSON Schema and no interpreter, validation is skipped rather than risking a false rejection.
import { createMcpServer } from '@gnldev/mcp';
const server = createMcpServer({
tools: { chargeCard },
journal, // omit it and callTool is a plain call
});
await server.callTool({
name: 'chargeCard',
arguments: { amount: 5000 },
idempotencyKey: 'order-123:call-7',
});
// the same request again -> the recorded result, the card is not charged twiceAPI#
mcpToolsConnects to an MCP server (stdio | http | custom transport) and returns a handle: tools(), describeTools(), close(). Lazy connect, shared discovery, idempotent close.
describeToolsDiscovers an already-connected client's tools and summarizes each as { name, description, inputSchema, descriptionHash } — the input the firewall pins against.
mcpFirewallBuilds a Guard from { server, journal, tools, allow?, deny?, maxCallsPerRun? }. Allow/deny → pin check → per-run ceiling.
composeGuardsChains two Guards: the second runs only when the first allows. A denial short-circuits.
mcpPinKeyThe journal key of the pin record: __mcp_pin__:<server>:<tool>. The server field is what keeps two servers exposing the same tool name from colliding.
createMcpServerExposes your tools over MCP; with a journal, every callTool becomes exactly-once via durableTool.
McpToolSummary{ name, description?, inputSchema, descriptionHash } — descriptionHash is argsHash over name + description + inputSchema.
require-approval, not deny — the run suspends and a human sees both the pinned and the current hash. A legitimate upgrade of the server should not be indistinguishable from an attack, and only a person can tell them apart.allow, the allowlist does not apply at all. And maxCallsPerRun needs readRun on the journal: if the adapter cannot count, the call is not blocked. If your threat model needs a hard boundary, supply allow and use a journal that supports reads.