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:
| Member | What it is |
|---|---|
name | The 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.
transport | Where the server is | Auth decided | Choose it when |
|---|---|---|---|
'stdio' | A local subprocess, over its stdin/stdout | n/a — no HTTP | Development, single-user, a locally-installed MCP server |
'http' | Remote, over Streamable HTTP | At connect time — headers fixed for the life of the connection | A static API key, or a server that needs no auth |
'gateway' | Remote, over Streamable HTTP | Per request — a CredentialProvider vends inside every fetch | Anything 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
httptransport 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
fetchrather 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:
| Combinator | What it does | Choose 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 iteration | Some tools should not be offered in this context |
skillScopedTools(skillId, tools) | Expose a subset only while that skill is the one most recently loaded | Tools 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
| Adapter | Door | Peer dep | What it really is |
|---|---|---|---|
localCodeRunner | agentfootprint/providers | none | A child process on your machine. Isolation, not a sandbox. |
agentCoreCodeRunner | agentfootprint/providers | @aws-sdk/client-bedrock-agentcore | AWS 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
| Piece | Door | Peer dep | Status |
|---|---|---|---|
mcpClient / McpClient | agentfootprint/providers | @modelcontextprotocol/sdk (optional) | Shipped |
stdio / http transports | agentfootprint/providers | as above | Shipped |
gatewayTransport | agentfootprint/providers | as above | Shipped |
Custom fetch seam | agentfootprint/providers | — | Shipped |
mockMcpClient | agentfootprint/providers | none | Shipped — offline tests |
mcpServe | agentfootprint/providers | @modelcontextprotocol/sdk (optional) | Shipped |
staticTools / gatedTools / skillScopedTools | agentfootprint/providers | — | Shipped |
CodeRunner / codeRunnerTool | agentfootprint (main) | — | Shipped 9.7.0 |
localCodeRunner | agentfootprint/providers | none | Shipped 9.7.0 — isolation, not a sandbox |
agentCoreCodeRunner | agentfootprint/providers | @aws-sdk/client-bedrock-agentcore (optional) | Shipped 9.7.0 |
maxToolResultChars + isTruncatedToolResult | agentfootprint (main) | — | Shipped 9.11.0 — opt-in, no default |
Tool.capabilities declaration | agentfootprint (main) | — | Shipped 9.11.0 — enforced when a checker governs it |
Next
- Connect to an MCP server — the three transports, worked
- Governance & policy — who is allowed to say no
- Identity & credentials — what the gateway vends
- AWS & Bedrock AgentCore — a Gateway as one worked provider
- Tool sessions — the key grammar, the teardown contract, and who says a session ended
Hosting & runtime
The ports between an agent and the place it runs — AgentHost, ConversationHost and SessionLifecycle, none of which names a cloud. Plus httpHost/HttpWire for a second HTTP adapter, three reply terminals, three durability modes with stated crash semantics, the human-in-the-loop resume loop, and the three ways to serve many people at once.
Observability sinks
The ObservabilityStrategy port — one required hot-path method that must be sync and must not throw. Compose several sinks into one, choose when delivery happens, and know exactly what a recording keeps and what it summarizes away.
