Infrastructure

Tools & gateways

The McpClient port — four members, three transports. Where a tool comes from is a transport decision (stdio, fixed HTTP headers, per-request vending, or your own signing fetch); what a tool is allowed to do is a governance decision, and they compose without knowing about each other.

A tool the agent defines in-process needs no infrastructure at all — defineTool is a function and a schema. The moment tools live somewhere else, three questions appear that defineTool never had to answer: who serves them, how each request proves who is asking, and who is allowed to say no.

This page is the first two. The third is Governance & policy.

The port

McpClient (src/lib/mcp/types.ts) is the whole boundary — one property and three methods:

MemberWhat it is
nameThe logical name from options (default 'mcp'). Every tool this client serves is attributed to it.
tools()Snapshot the server's tool list as agentfootprint Tool[].
refresh()Re-list, for a server whose tools change while you are connected.
close()Close the transport. After close() the client is unusable.

Connect once; call .tools() to snapshot the tool list, .refresh() to re-list after the server's tools change, .close() when done.

That is deliberately small. An MCP server's tools become ordinary Tool objects, so everything downstream — the permission gate, the middleware chain, the ToolProvider combinators, the recording — treats a remote tool exactly like a local one.

import { mcpClient, staticTools } from 'agentfootprint/providers';
import { Agent } from 'agentfootprint';

const server = await mcpClient({
  name: 'files',
  transport: { transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '/data'] },
});

const agent = Agent.create({ provider }).toolProvider(staticTools(await server.tools())).build();

Axis 1 — the transport: when are the auth headers decided?

McpTransport is a three-arm union, and the arms differ in one thing that matters operationally more than the protocol does: the moment the credentials are chosen.

transportWhere the server isAuth decidedChoose it when
'stdio'A local subprocess, over its stdin/stdoutn/a — no HTTPDevelopment, single-user, a locally-installed MCP server
'http'Remote, over Streamable HTTPAt connect timeheaders fixed for the life of the connectionA static API key, or a server that needs no auth
'gateway'Remote, over Streamable HTTPPer request — a CredentialProvider vends inside every fetchAnything with an expiry: a managed gateway, an OAuth-brokered endpoint, a standing agent

The http and gateway arms ride the same wire. The library's own note on why they are still two things:

The http transport takes headers once and reuses them for the life of the connection. That is right for a static API key and wrong for anything with an expiry: a standing agent outlives its bearer token, and the failure mode is a burst of 401s an hour into a session that worked perfectly when you tested it.

gatewayTransport — vending, and why the vend happens late

import { agentCoreIdentity } from 'agentfootprint/security';
import { gatewayTransport, mcpClient } from 'agentfootprint/providers';

const gateway = await mcpClient({
  name: 'gateway',
  transport: gatewayTransport({
    url: process.env.GATEWAY_MCP_URL!,
    credentials: agentCoreIdentity({ region: 'us-west-2' }),
    service: 'gateway',            // defaults to 'gateway'
  }),
});

GatewayTransportOptions is { url, credentials, service?, scopes?, mode?, headers? }. url and credentials are required and refused by name when missing.

Two laws worth knowing before you build on it:

A vended token is used once and dropped. It is not cached between requests, not stored on the transport object, not put in an event payload, not written to a log, and not included in any error this module throws — including the errors it throws WHILE holding one.

That is why the vend happens inside fetch rather than at construction. A token resolved at construction has to live somewhere for the connection to use it later, and "somewhere" is what leaks.

Static headers are applied first and the vended header last, so a stale static header can never quietly shadow the live credential. A 3-legged provider that answers authorization-required surfaces as GatewayAuthorizationRequiredError (code: 'ERR_GATEWAY_AUTHORIZATION_REQUIRED'), carrying authorizationUrl — see Identity & credentials.

Nothing in gatewayTransport is vendor-specific: it is the CredentialProvider port on one side and Streamable HTTP on the other. AWS AgentCore's Gateway is one consumer of it, not its definition.

Field-validated for bearer gateways — and refuted for Google's

An independent field trial, 2026-08, served a real agentfootprint tool over Streamable HTTP MCP, reached it through this transport, discovered and called the tool, and confirmed the vending law directly: five requests, five different freshly vended credentials, none retained on the transport.

The same trial answered the open question about Google's Agent Gateway the other way. Its agent identities use mTLS and DPoP — a client certificate and a per-request proof — and GatewayTransportOptions could vend headers only. This transport is not support for Google's identity-enforced path, and 9.32 does not change that: what it adds is a seam so you can build one yourself without giving up rotation. See below, and Google Cloud & Gemini.

fetch — bring your own signer, and keep the rotation (9.32.0)

Until 9.32 a caller who needed mTLS or DPoP had exactly one route: abandon gatewayTransport for the generic http transport, which has a fetch seam and fixes its headers at connect time. That trade is backwards — you give up token rotation to get a client certificate — and it was the shape the trial above reported. So gatewayTransport takes a fetch too:

transport: gatewayTransport({
  url, credentials, service: 'gateway',
  // mTLS through an agent you own …
  fetch: (input, init) => fetch(input, { ...init, dispatcher: mtlsAgent }),
}),
transport: gatewayTransport({
  url, credentials,
  // … or a proof computed FROM the request
  fetch: async (input, init) => {
    const headers = new Headers(init?.headers);
    headers.set('dpop', await sign(init?.method ?? 'POST', String(input)));
    return fetch(input, { ...init, headers });
  },
}),

The order is the feature. The credential is vended and applied first, then your function is called with that request — so a signer sees the final headers and has the last word over the bytes, exactly as it does on the http transport, while the vend still happens on every request. Rotation is not lost.

Zero vendor code lands here. This library ships no signer, no certificate loader and no DPoP implementation, and it never will: a scheme this repo has never heard of works on the day you write it. This is bring-your-own, offered as a seam — never as support for anybody's identity path.

One honest note about secrecy. Your function sees the request it is asked to send, headers included, because that is what makes signing possible. Everything this module controls is unchanged: the vended value is never cached, never stored on the transport, and never enters an event, an error or a log — pinned by the same hostile-observer test that covers the rest of this transport. A fetch you inject that logs its own headers publishes your credential in your own code, which is a decision this library cannot make for you.

Omit it and behaviour is byte-identical to every release before it existed.

The fourth option: sign it yourself

Some endpoints do not want a header, they want a signature — SigV4, DPoP, an HMAC over the body. None of those can be decided when the connection is built, because they are computed from the request. So the library implements none of them and hands you the hook instead: McpHttpTransport.fetch.

const signed = await mcpClient({
  transport: {
    transport: 'http',
    url: process.env.MCP_URL!,
    fetch: async (input, init) => fetch(input, await signSigV4(input, init)),
  },
});

Whatever you set here is between you and the server: this library never reads, stores, logs or records the headers your function produces.

headers and fetch compose — the SDK merges the static headers into the init your function receives, so you see them and have the last word. Worked example: Connect to an MCP server.

Throttling is handled, and only throttling

retryOnThrottle is on by default: retry up to 3 times, never more than 10 seconds of waiting in total across one call, 429 only. A 429 is a pre-execution rejection — the tool did not run — which is the one status where a retry cannot double an effect. Ignored for stdio, which has no HTTP status to read.

Axis 2 — the provider: what the model is shown

ToolProvider decides visibility per iteration; it is a different question from whether a call is permitted. Three combinators chain decorator-style, all on agentfootprint/providers:

CombinatorWhat it doesChoose it when
staticTools(tools)Wrap a fixed Tool[]. The identity provider.You have a list and it does not change
gatedTools(inner, predicate)Filter inner by tool name, per tool per iterationSome tools should not be offered in this context
skillScopedTools(skillId, tools)Expose a subset only while that skill is the one most recently loadedTools that only make sense inside one skill
import { gatedTools, staticTools } from 'agentfootprint/providers';

const tools = gatedTools(staticTools(await gateway.tools()), (name) => allowed.has(name));

The gate decides what the model is shown; the permission checker decides what actually runs. They compose without knowing about each other. Deep dive: Tool providers.

Axis 3 — the governance verdict

Two vocabularies at two layers, and conflating them is the common mistake.

At the permission gate (PermissionChecker.check) the verdict is 'allow' | 'deny' | 'halt' | 'gate_open'. A denial is final for that call — the tool does not execute, and the model reads a bracketed refusal it can adapt to. There is no ask here.

In the middleware chain (ToolOutcome) the verdict is allow | deny | ask, and an ask suspends the run and puts the exact operation in front of a person: the transformed args ride the checkpoint, so a human approves what the chain produced rather than what the model originally proposed. Approve and the real tool runs; decline and it becomes a denial the model reads.

The full table, with the exact strings the model sees, is on Governance & policy.

Axis 4 — the ceiling on ONE result (9.11.0)

maxToolResultChars on Agent.create({ … }) is the last-resort net under everything below: a hard character ceiling on a single tool result.

Agent.create({ provider, model, maxToolResultChars: 20_000 })

Over the cap, the result is replaced by a marker that names the tool, the size, the cap, and the one move that helps — carrying the first characters of the real answer verbatim:

{
  "truncated": true,
  "reason": "orders_export returned 812431 chars, over the 20000-char cap. Narrow the request and call again.",
  "head": "id,customer,total\n1001,…"
}

The marker IS the result. It is what the model reads on the role: 'tool' message and what agentfootprint.stream.tool_end carries — so a run that capped an 800KB result does not then ship that same 800KB to a log sink, and the trace shows the truncation instead of hiding it. head gets whatever the cap has left after the sentence explaining it, so a bigger cap buys a proportionally bigger head. The shape is TruncatedToolResult, and isTruncatedToolResult(value) is the exported guard that narrows to it.

Opt-in, with no default — and that is the design

A default here would silently modify tool results. A tool returning 200KB of rows is doing what somebody wrote it to do, and a framework that quietly replaced that the first time it ran would be lying to the app about its own tool. Omit the option and results are never measured and never replaced, byte-identical to every earlier release. 0 is not "off": it is refused at construction, because a cap that cannot cap anything is a configuration mistake, not a switch.

It composes with what already runs, and replaces none of it: a tool's own paging keeps working, CodeResult.truncated still means what it means, and an onToolResult middleware that summarizes runs FIRST — the cap measures what the chain produced. Every dispatch path is covered: the ordinary loop, a resumed ask, a check-in decision, a credential-consent resume, and a pauseHere answer a person typed.

And when big tool DATA is the normal case rather than the accident, the cap is the wrong tool for it — the next section is.

CodeRunner — compute data outside the window

Every port above answers "where does a tool come from". This one answers a different question: where does the DATA go.

A tool whose honest answer is 40,000 rows has not given the model data. It has spent the context window. The motivating failure is measured, not hypothetical — a production request of 879,073 tokens, almost all of it one tool result pasted into the prompt. Since 9.6.0 that shape at least fails by name (ContextWindowExceededError) instead of as an opaque vendor 400. CodeRunner is the other half of the answer: not failing better, but not needing to.

Summarize prose, compute data. Prose is what a summary is FOR. Data is not: the model should write the aggregation, the runner should hold the rows, and what comes back should be the finding.

The port

interface CodeRunner {
  readonly id: string;
  start(req: { key: string; language?: string; signal?: AbortSignal }): Promise<CodeSession>;
}
interface CodeSession {
  readonly id: string;
  execute(req: { code: string; language?: string; timeoutMs?: number }): Promise<CodeResult>;
  stop(): Promise<void>;
}

CodeResult is { ok, stdout, stderr, exitCode?, artifacts?, truncated? }. truncated is the load-bearing field: an unstated slice is a silent success. A runner that quietly cuts its own output to fit is the same context-window bug wearing a different hat, and the model would reason over a fragment of a table believing it had the table.

start({ key }) takes the ISOLATION key the caller derived. An adapter may use it to name the remote session; it must never widen it.

The adapters

AdapterDoorPeer depWhat it really is
localCodeRunneragentfootprint/providersnoneA child process on your machine. Isolation, not a sandbox.
agentCoreCodeRunneragentfootprint/providers@aws-sdk/client-bedrock-agentcoreAWS Bedrock AgentCore Code Interpreter — a real managed sandbox.

localCodeRunner is named honestly, not modestly. A node:child_process subprocess gives you a separate process and heap, kill-on-timeout, no inherited stdin, and an environment allowlist (process.env is not inherited — only PATH, so the OS can find the interpreter, and you can override even that). It does not give you a filesystem jail, a network jail, or CPU/memory limits.

So: a dev loop, a trusted-input pipeline, a machine you would be relaxed about a shell script running on. Not arbitrary model-written code from untrusted users. For that, put a real sandbox behind the same port and keep the tool identical.

In-process eval / node:vm is refused outright. Node documents vm as not a security mechanism, so shipping it as one would be theater: the same code reaching the same globals, wearing a word that makes a reader stop checking. The library teaches refusals for things that are wrong and honest names for things that are merely limited.

A CodeSession is what start() hands back — execute() and stop(), where stop() must tolerate a session the far side already reaped, because an idle timeout is the reality on every managed backend. Each adapter's options bag is LocalCodeRunnerOptions and AgentCoreCodeRunnerOptions respectively; the AWS one also exports AgentCoreCodeClientLike (the operation-semantic seam you can inject a whole client through, via _client), AgentCoreInvokeAnswer (one invocation already drained of its event stream) and BedrockAgentCoreCodeSdkModule (the _sdk test seam every AWS adapter here carries).

agentCoreCodeRunner dispatches StartCodeInterpreterSessionCommand, InvokeCodeInterpreterCommand and StopCodeInterpreterSessionCommand through client.send(new Command(...)) — never a method on the client, which is the 9.4.0 law. Two shapes worth knowing because guessing them is how this class of bug ships: Invoke answers with an event stream (response.stream), and seven of its nine union members are modelled exceptions rather than results — folding one in as empty output would report a clean run that "printed nothing" at the exact moment you needed the word AccessDenied. All three names are pinned in test/adapters/aws/awsCommandPin.ts and verified against a real install.

The tool

import { Agent, codeRunnerTool } from 'agentfootprint';
import { localCodeRunner } from 'agentfootprint/providers';

const agent = Agent.create({ provider })
  .tool(codeRunnerTool({ runner: localCodeRunner(), language: 'javascript' }))
  .build();

scope decides how long one interpreter lives — 'call' (fresh each time), 'run' (one per turn, the default), or 'session' (one per hosted conversation, so variables and files persist between turns). It holds one session per isolation key, reuses it across calls, and registers its own teardown; see Tool sessions for the key grammar and the firing matrix.

Ask for a scope the door cannot honour and it refuses by name. It never quietly narrows or widens: widening hands one sandbox to two people, and narrowing multiplies start-up cost with nothing to show for it.

Worked end to end, with the payoff, the isolation and the events printed: examples/features/52-run-code.ts.

Why it is a plain tool — and what the chart form buys you

codeRunnerTool is deliberately a plain Tool, not a chart. It is one operation, and — more decisively — the session it holds outlives any single invocation. A per-invocation chart could not hold it: the chart ends when the call ends, and the whole point is that the interpreter does not.

Its evidence therefore lives at the tool boundary, where a plain tool's evidence belongs. inspect_tool_call reaches the code that was sent, the stdout and stderr that came back, and whether anything was truncated; the four tools.session_* events put the session lifecycle on the record beside it — which key, how many calls shared it, when and why it closed.

The chart form is composition, not a different tool. When the thing you are building is a multi-step procedure — fetch → generate code → execute → validate — build it as a footprintjs chart with the runner used inside a stage, and wrap that chart with flowchartAsTool({ keepRecord: true }):

const analysis = flowChart<AnalysisState>('analysis')
  .start('fetch', (s) => { s.rows = await warehouse.query(s.sql); })
  .addFunction('generate', (s) => { s.code = await writeAggregation(s.rows); })
  .addFunction('execute', (s) => { s.out = (await session.execute({ code: s.code })).stdout; })
  .addFunction('validate', (s) => { s.ok = looksLikeANumber(s.out); })
  .build();

const analyze = flowchartAsTool({ chart: analysis, name: 'analyze', keepRecord: true });

Now the 8.17.0 descent applies in full: inspect_tool_run walks through the tool boundary to the inner stage that ran the code, so "why is this number wrong?" resolves to a stage rather than to a tool call. Same runner, same port — the difference is whether the work has steps worth naming.

Serving tools, not only consuming them

mcpServe turns an agentfootprint agent's governed tools into an MCP server, so another client gets the same gate you do. One thing does not cross that boundary: ask — MCP is request/response and there is no pause to carry the question, so a middleware that asks answers the client with a tool error naming it, rather than executing ungoverned. See Serve tools over MCP.

Status

PieceDoorPeer depStatus
mcpClient / McpClientagentfootprint/providers@modelcontextprotocol/sdk (optional)Shipped
stdio / http transportsagentfootprint/providersas aboveShipped
gatewayTransportagentfootprint/providersas aboveShipped
Custom fetch seamagentfootprint/providersShipped
mockMcpClientagentfootprint/providersnoneShipped — offline tests
mcpServeagentfootprint/providers@modelcontextprotocol/sdk (optional)Shipped
staticTools / gatedTools / skillScopedToolsagentfootprint/providersShipped
CodeRunner / codeRunnerToolagentfootprint (main)Shipped 9.7.0
localCodeRunneragentfootprint/providersnoneShipped 9.7.0 — isolation, not a sandbox
agentCoreCodeRunneragentfootprint/providers@aws-sdk/client-bedrock-agentcore (optional)Shipped 9.7.0
maxToolResultChars + isTruncatedToolResultagentfootprint (main)Shipped 9.11.0 — opt-in, no default
Tool.capabilities declarationagentfootprint (main)Shipped 9.11.0 — enforced when a checker governs it

Next

On this page