GNL
Docs menu
Core · Free@gnldev/mcp

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.

connect and use as an AI SDK tool set
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.

mcpFirewall — allowlist, pinning and a ceiling
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.

createMcpServer — exactly-once on the serving side
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 twice

API#

fnmcpTools

Connects to an MCP server (stdio | http | custom transport) and returns a handle: tools(), describeTools(), close(). Lazy connect, shared discovery, idempotent close.

fndescribeTools

Discovers an already-connected client's tools and summarizes each as { name, description, inputSchema, descriptionHash } — the input the firewall pins against.

fnmcpFirewall

Builds a Guard from { server, journal, tools, allow?, deny?, maxCallsPerRun? }. Allow/deny → pin check → per-run ceiling.

fncomposeGuards

Chains two Guards: the second runs only when the first allows. A denial short-circuits.

fnmcpPinKey

The 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.

fncreateMcpServer

Exposes your tools over MCP; with a journal, every callTool becomes exactly-once via durableTool.

typeMcpToolSummary

{ name, description?, inputSchema, descriptionHash } — descriptionHash is argsHash over name + description + inputSchema.

A changed description asks, it does not silently deny
A rug-pull returns 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.
Where it fails open, on purpose
Two places, both documented in the code. Without 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.