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.
Turn repeated code into a tool backlog
Every completed run leaves a privacy-safe CodeRunFacts record keyed by its
tool-call ID. codeRunsOf(tool) reads those facts for inspection, and
agentfootprint.tools.code_run emits the same facts to attached observers after
dispatch. Neither path exposes the generated program: codeShape(code) removes
comments, literals and non-callee identifiers while keeping the operations and
their order. The event carries the resulting shapeHash instead of source code.
Group that hash over production runs. When the same shape keeps returning, the agent is repeatedly writing the same missing operation; the most frequent shapes are a measured backlog of tools worth implementing. The remaining facts — language, staged-input count, output size, truncation and success — let you prioritize that backlog without sending the model-written code or the customer data embedded in it to an observability provider.
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.
BrowserRunner — a browser the agent drives, and a person can take
CodeRunner computes outside the window; BrowserRunner (9.68.0) reaches a
real browser. agentCoreBrowser() is the first backend — AWS Bedrock AgentCore
Browser, a managed Chrome — and AWS_SYSTEM_BROWSER is the resource it uses
unless you name a custom one via AgentCoreBrowserOptions.
import { agentCoreBrowser } from 'agentfootprint/providers';
const browser = agentCoreBrowser({ region: 'us-east-1' });
const session = await browser.start({ key: toolSessionKey(ctx, 'run') });A session has two doors, and confusing them costs a day. BrowserSession
exposes automationEndpoint — a CDP WebSocket — and everything page-shaped
(navigate, find an element, fill a form) happens there, driven by Playwright or
another CDP client. This library does not depend on Playwright and does not do
page work; it hands you the endpoint. What the port itself carries is what an
automation library cannot do on its own: click, type, press and
screenshot (returning a BrowserShot), which are operating-system input
above the page.
The InvokeBrowser action union, read off the SDK rather than remembered, is
exactly mouseClick | mouseMove | mouseDrag | mouseScroll | keyType | keyPress | keyShortcut | screenshot. There is no navigate action. That is not a gap in
this adapter — it is which door navigation lives behind.
The takeover
await session.handControlTo('person'); // the automation stream stops
// …they sign in, clear the CAPTCHA, approve the consent screen, watching live
await session.handControlTo('agent'); // and the agent carries onhandControlTo takes a BrowserDriver — 'person' or 'agent' — and is
optional on the port, so feature-detect before offering it. Underneath it sets
the automation stream to DISABLED and back: the live-view user
(liveViewEndpoint) is already connected, and stopping automation is what lets
their input through.
Pair it with a check-in and the agent pauses rather than guesses. The handover, the wait and the resume are ordinary events in the trace, so "why did this run take four minutes?" has an answer that names a person and a login screen.
agentCoreBrowser talks to AWS through send(new Command(...)) — its four
commands are pinned by the same systemic test as every other AWS adapter — and
AgentCoreBrowserClientLike / BedrockAgentCoreBrowserSdkModule are the
injection seams for tests. stop() tolerates a session the backend already
reaped, because an idle timeout is the ordinary case on a managed browser and a
teardown that succeeded must not report failure.
One contradiction left as AWS wrote it: the devguide says a session defaults
to 15 minutes, StartBrowserSession's API reference says 3600 seconds. The
adapter sends no timeout unless you pass sessionTimeoutSeconds, so the service
applies whichever it means rather than this library picking a side.
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
Writing a session store
The ownership rule every SessionLifecycle store must hold, and the shared battery that checks it — runSessionLifecycleConformance, 14 cases, three deliberately distinct non-pass outcomes, and no way to make a case quietly disappear. For anyone implementing the port over Postgres, DynamoDB, SQL Server or anything else.
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.
