Skip to content

MCP

mcp() in @rulvar/core imports a Model Context Protocol server as a ToolSource on the tool bus, wrapping @modelcontextprotocol/sdk (pinned at ^1.29). Every imported tool becomes an ordinary ToolDef: the Agent Runtime dispatches it through the same permission chain, records its result in the same canonical history, and hashes its contract into the same toolsetHash as a native tool. There is no MCP-specific dispatch channel and nothing for policy to miss.

The bus is consume-only: Rulvar connects to MCP servers as a client. It does not serve its own tools or agents over MCP.

Transports

Three transports are supported:

TransportWhen to use
stdioLocal MCP servers spawned as a child process.
streamable-httpRemote MCP servers reachable over HTTP or HTTPS.
inprocessAn in-memory server instance living in your own process, for tests and embedded servers.
ts
import { mcp } from '@rulvar/core';

const filesystem = mcp({
  transport: 'stdio',
  command: 'mcp-server-filesystem',
  args: ['--root', './workspace'],
});

const search = mcp({
  transport: 'streamable-http',
  url: 'https://mcp.example.com/v1',
});

const embedded = mcp({
  transport: 'inprocess',
  server: myInMemoryServer, // an in-memory server instance
});

Exactly the config keys matching the chosen transport must be set: command/args for stdio, url for streamable-http, server for inprocess. Anything else is a typed ConfigError, raised early rather than at first call.

Importing tools

The full configuration surface of mcp():

ts
import { mcp } from '@rulvar/core';

const github = mcp({
  transport: 'stdio',
  command: 'mcp-server-github',
  allow: ['get_issue', 'list_issues', 'create_comment'],
  prefix: 'gh',
  approval: { create_comment: true },
  risk: {
    get_issue: 'read',
    list_issues: 'read',
    create_comment: 'write',
  },
});
OptionWhat it does
allow / denyTool-name filters on the original (pre-prefix) names; omitted allow means all, and deny wins over allow.
prefixNamespaces imported names as ${prefix}_${name}, so create_comment above surfaces as gh_create_comment.
approvaltrue sets needsApproval: true on every imported tool; the record form sets it per tool name. An approval-flagged tool asks at the permission chain's terminal default.
riskHost-supplied ToolRisk labels (read, write, network, execute, destructive) so permission presets can govern imported tools.

Two naming rules are enforced for you. Every final tool name (after prefixing) must match ^[a-zA-Z0-9_-]{1,64}$, else ConfigError. And a name collision between two sources in one toolset without a disambiguating prefix is a ConfigError at spawn time, never a silent shadowing.

MCP servers declare no risk metadata of their own, and Rulvar deliberately does not trust a server's self-description for policy. The risk map is your trust decision: unlabeled imported tools fall under the undeclared-risk row of every preset, which asks under strict and standard.

One bus for every tool

ToolSource is the seam that makes native tools, in-process MCP servers, and stdio or streamable-http MCP servers indistinguishable to the runtime:

ts
interface ToolSource {
  id: string;
  tools(session: ToolSourceSession): Promise<ToolDef[]>;
}

Anywhere the engine accepts tools (ToolsOption), you can mix plain ToolDef values, tool sources, and registered toolset names side by side: a string entry names a toolset registered under engine defaults.toolsets and means the same thing in direct calls, profiles, and the sandbox dialect, while an unknown name is a typed ConfigError before any provider call (see Tools). The dynamic orchestrator's toolsetRef spawn parameter draws from the same registry. At spawn time the engine expands every source, validates names and duplicates across the whole toolset, and freezes the snapshot:

ts
import { createEngine, defineWorkflow } from '@rulvar/core';
import { anthropic } from '@rulvar/anthropic';

const engine = createEngine({
  adapters: [anthropic()],
  defaults: {
    profiles: {
      triager: {
        description: 'Triages GitHub issues and drafts responses.',
        model: 'anthropic:claude-sonnet-5',
        tools: [github], // the mcp() source from above, mixed freely with ToolDefs
        permissions: { preset: 'standard' },
      },
    },
  },
});

const triage = defineWorkflow(
  { name: 'triage-issue' },
  async (ctx, args: { issue: number }) => {
    return ctx.agent(`Triage issue #${args.issue} and draft a response.`, {
      agentType: 'triager',
    });
  },
);

The MCP client connects lazily on the first tools() call. tools/list is fetched with cursor pagination until exhaustion (an absent or empty nextCursor both end the walk, so a server echoing an empty cursor cannot spin the import) and cached per MCP session, so repeated spawns against the same server do not re-list; concurrent cold snapshots share one in-flight fetch instead of each sweeping the list.

The permission chain

Every dispatch of an imported tool runs the same layered chain as a native tool, in fixed order, first decisive verdict wins:

text
hooks -> deny rules -> ask rules -> canUseTool -> terminal default

Rules match by tool name (the final, prefixed name the model sees) or by declared risk class. Combined with the risk map on mcp(), presets give you a one-line policy over an entire server:

ts
const engine = createEngine({
  adapters: [anthropic()],
  defaults: {
    permissions: {
      deny: [{ risk: 'destructive' }],
      ask: [{ tool: 'gh_create_comment' }, { risk: 'undeclared' }],
    },
  },
});

The three shipped presets compile into the deny and ask layers (never a bypass channel; "allow" just means no rule is emitted):

Declared riskstrictstandardopen
readallowallowallow
writeaskallowallow
networkaskaskallow
executeaskaskallow
destructivedenyaskallow
(undeclared)askaskallow

Domain rules are advisory for MCP tools

Network domain rules ({ tool, domains }) are advisory for every tool in the current release, MCP tools included: they never change a verdict, and matches surface in the audit fields on tool:end events. Rulvar ships no fetch tool today, and there is no enforcement mechanism inside a server you do not control. Do not treat domain rules as containment.

Every chain evaluation emits audit telemetry on the tool:end event: the verdict, the deciding layer, the matched rule, and advisory matches. See observability.

Approvals suspend durably

A deny is surfaced to the model as an error tool result carrying the policy reason; the turn continues and nothing throws past policy. An ask is stronger: the verdict is journaled as a suspended approval entry together with the turn-boundary checkpoint, and the run suspends. Resolution arrives later through the resolution-entry family (a resolveExternal call, an operator action, or a journaled deadlineAt timeout with a default decision), first-closing-wins. On resume the agent continues from the same turn: no model turn is re-paid and no already-executed tool runs again. That is the never-pay-twice invariant applied to human-in-the-loop approval; see durability.

Schema handling

An imported tool's inputSchema becomes its parameters in bare JSON Schema form, so the inferred input type is unknown and runtime validation runs through the engine's vendored eval-free validator (a draft 2020-12 subset: no $dynamicRef, no remote $ref). A schema outside that subset is a typed ConfigError when the tool is admitted into a toolset, not a runtime surprise. Model-produced arguments are validated before any tools/call goes out; a validation failure is surfaced to the model as an error tool result naming the issues, so the model can correct itself.

When the server declares an outputSchema, the structuredContent of each result is validated against it; a failure is again an error tool result, never an exception.

Result mapping

Server resultWhat lands in the canonical history
structuredContent presentThe structured value is the tool result.
content blocks onlyText blocks are concatenated as text; non-text blocks are preserved as typed parts.
isError: trueAn error tool result surfaced to the model; it never throws past policy.

The tool result record is part of the agent's canonical history and is checkpointed at the turn boundary, exactly like a native tool result. See the journal.

Lifecycle and toolset identity

The toolset snapshot for a given agent spawn is captured at spawn time and stays immutable for that agent's lifetime. Its toolsetHash (sha256 over the canonicalized contract tuples, sorted by name) enters the spawn's identity, and MCP tools hash their version as absent since MCP defines no version field.

Two consequences follow:

  • A listChanged notification from the server invalidates the session's tool-list cache, affecting subsequently spawned agents only. A mid-run listChanged never mutates an in-flight agent's toolset. The invalidation also survives racing the list fetch itself: a notification that lands while tools/list is in flight keeps that fetch from being pinned as the cache, so the next snapshot refetches.
  • Server-side drift of a tool's description or inputSchema changes toolsetHash and therefore the content key of new spawns. This is intended: a journal is never replayed against a changed contract. It is also why MCP-heavy workflows should pin their server versions; an upgraded server silently invalidates replay for new spawns of agents that import it. A re-key makes drift visible, not refused: to hold a profile's spawns to a recorded hash and refuse the drift typed at spawn time, pin the profile with a toolset attestation.

Idempotent server tools resume cleanly

Tool execution between a tool's side effect and the turn-boundary checkpoint write is at-least-once on crash and resume. Prefer MCP servers whose mutating tools are idempotent, and gate the rest with approval.

Closing a source

mcp() returns a McpToolSource: the frozen ToolSource seam plus one lifecycle method. The source connects lazily on the first tools() call, and what it creates then (the SDK client, its transport, and for stdio the spawned child process) lives until you release it. The engine never closes a source, because one source may serve many runs; the host owns the lifecycle and calls close() once its runs have settled:

ts
const github = mcp({ transport: 'stdio', command: 'github-mcp-server' });
try {
  const outcome = await engine.run(triage, { repo: 'o-stepper/rulvar' }).result;
  // ...
} finally {
  await github.close(); // releases the client, the transport, and the stdio child
}

For a one shot script the finally is not optional hygiene: a stdio child and its pipes keep the Node.js event loop alive, so a process that skips close() finishes its workflow and then never exits. close() is idempotent, resolves even when the connection never succeeded, and resets the source, so a later tools() call connects afresh. A long lived host keeps one source per server, reuses it across runs, and closes it at shutdown. Closing while a run is in flight fails that run's MCP tool calls, so close after the runs settle, not during them.

Bounds

An MCP server sits on the other side of a trust boundary, and three of its behaviors used to be unbounded on the host side: how many tools the tools/list sweep may stream, how large an imported schema may be, and how long the handshake and each request may take (the SDK's own 60-second default request timeout was the only backstop). All the bounds are opt-in config on mcp(); leaving them out preserves the previous behavior exactly:

ts
const github = mcp({
  transport: 'stdio',
  command: 'github-mcp-server',
  maxTools: 64, // cap the tools/list sweep itself
  maxPages: 16, // cap the sweep's wire call count
  maxSchemaBytes: 16384, // per admitted tool, input plus output schema
  timeouts: { connectMs: 3000, listMs: 5000, callMs: 30000 },
});
  • maxTools bounds the sweep, not the toolset: it is checked against the accumulated wire tools after each page, before allow/deny filtering, because the sweep is the resource being protected. A server that streams past the cap is refused with a typed ConfigError naming the count and the cap; an allow list cannot admit past it.
  • maxPages bounds the sweep's wire call count where maxTools bounds its volume (RV1602). The gap it closes is real: a server answering unique cursors over empty pages grows neither the tool count nor any timeout, because each page answers comfortably inside listMs, so only a page bound stops the loop. Fail closed like maxTools: a server still reporting another page past the cap refuses typed rather than silently importing a subset of its declared surface.
  • The cursor-echo cycle guard needs no configuration (RV1602): a page whose nextCursor equals the cursor it was queried with makes no pagination progress, and refetching it would spin the sweep forever. That is never a legitimate pagination step, so the sweep refuses typed on the spot, on the second page at the latest. The eighteenth comparison benchmark called the missing guard out: the audited answer claimed a cursor bound that did not exist.
  • The visited-cursor guard is the echo guard's general form (RV1808), and needs no configuration either: a nextCursor this sweep has ALREADY queried with re-fetches a page it has already consumed, so an alternating pair (A, then B, then A again) is exactly as much of a loop as the self-echo, however long the cycle's period. The sweep refuses typed naming the revisited cursor.
  • timeouts.discoveryMs is the whole-sweep wall clock (RV1808): per-page listMs cannot bound a crawl of pages that each answer promptly, and maxPages binds only when declared, so a server paginating forever under both radars is stopped by the one bound that watches the sweep as a unit. On expiry the sweep refuses typed naming the deadline and the page count. The deadline binds the page call itself, not just the gap between calls (RV3205): every tools/list request carries the smaller of listMs and the remaining discovery budget as its wire timeout, so a hung or slow current page, the last page included, fails the sweep closed at the deadline instead of being waited out.
  • requireBounds: true is the production posture (RV1808): the source refuses at construction unless maxTools, maxPages, maxSchemaBytes, and timeouts.discoveryMs are all declared, one typed error naming what is missing instead of four silent unboundeds. An unbounded discovery sweep against a remote registry is an availability decision someone should have made on purpose; see the production profiles guide.
  • maxSchemaBytes is measured per admitted tool (the filter runs first, so a denied tool's oversized schema costs nothing): the UTF-8 byte length of the serialized inputSchema plus outputSchema when present. An oversized tool refuses the resolution, naming the tool and its measured bytes; deny the tool or raise the cap.
  • timeouts.connectMs races the transport handshake; on expiry the client, and for stdio its spawned child, is released, and the refusal is a typed ConfigError. listMs and callMs ride the SDK request timeout per tools/list page and per tools/call; a call timeout surfaces as that tool's error result to the model, exactly like a server-reported isError, and never propagates past policy.

Session posture

Two more session-level contracts are the host's to declare: how requests authenticate, and what a server-side tool-list change means.

Per-request auth headers (http, streamable-http only). http.headers takes a header record or a hook returning one, injected into every wire request through a wrapped fetch. The hook form is awaited before each send, which makes it the refresh point: rotate a token inside the hook and the very next request carries it, with no reconnect. There is no library-invented 401 retry; an expired token fails the request exactly like any transport error, the engine's retry policy owns what happens next, and the retried request consults the hook again.

ts
const remote = mcp({
  transport: 'streamable-http',
  url: 'https://mcp.example.com/mcp',
  http: { headers: async () => ({ authorization: `Bearer ${await currentToken()}` }) },
});

The drift policy (drift). A listChanged notification invalidates the session cache either way; the policy names what happens next. 'rekey' is the documented default described above: subsequently spawned agents import the changed list under a new toolsetHash. 'refuse' fails closed instead: the notification poisons the source, every later tools() call refuses with a typed ConfigError, and only close() (a deliberate host reset) clears it, after which a fresh tools() imports the changed list on purpose. In-flight spawn snapshots are untouched either way. The two refusal layers compose: drift: 'refuse' stops a changed list at the source, and a toolset attestation stops it at the spawn; a locked-down profile can use both.

Failure behavior

Configuration problems fail early with a typed ConfigError; runtime problems become error tool results the model can react to. Nothing an MCP server does can throw past policy out of the agent loop.

SituationBehavior
Config keys not matching the chosen transportConfigError.
Final (prefixed) name outside ^[a-zA-Z0-9_-]{1,64}$ConfigError.
Duplicate tool names across sources, no disambiguating prefixConfigError at spawn time.
inputSchema outside the vendored validator subsetConfigError when the tool is admitted into a toolset.
Model arguments fail inputSchema validationError tool result naming the issues; the model retries within the turn budget.
structuredContent fails outputSchema validationError tool result.
Server returns isError: trueError tool result carrying the server's content.
Source closed while a run is in flightError tool results for that run's MCP calls; the turn continues.
Permission chain says denyError tool result carrying the policy reason; the turn continues.
Permission chain says askJournaled suspended approval entry; the run suspends durably.

Next steps

  • Tools covers tool(), SchemaSpec, executors, and the permission chain in full.
  • Agents shows how toolsets attach to profiles and per-spawn options.
  • Journal explains content keys, replay, and why toolset identity matters.
  • API reference for mcp, McpConfig, and ToolSource.