Connect to an MCP server
mcpClient({ transport }) turns any MCP server's tools into agentfootprint Tool[]. Sign every request with your own fetch, read servers old enough to answer the 2024-10-07 shape, and govern by the server a tool came from.
Someone else's tools, on your agent, in one call. The interesting part is not the connection — the SDK does that. It is the three things that happen at the edges: how you authenticate, what an old server answers with, and how a policy tells two servers apart.
import { Agent } from 'agentfootprint';
import { mcpClient } from 'agentfootprint/providers';
const aws = await mcpClient({
name: 'aws-prod',
transport: { transport: 'http', url: process.env.AWS_MCP_URL! },
});
const agent = Agent.create({ provider, model }).tools(await aws.tools()).build();
// ...
await aws.close();name is not decoration. It is the label on every tool this client produces —
see governing by server below.
Three transports
| transport | when |
|---|---|
stdio | a local server you spawn — development, desktop hosts, single user |
http | a remote server over Streamable HTTP — the common production case |
gateway | Streamable HTTP where the auth header is vended per request by a CredentialProvider, for tokens with an expiry |
mockMcpClient({ tools }) is a fourth option with no protocol at all: the same
McpClient shape, driven by an in-memory tool table, for tests and for building
before the server exists.
Sign every request yourself
Some endpoints do not want a header. They want a signature: SigV4, DPoP, an HMAC over the body, a digest of the bytes about to be sent. None of those can be decided when the connection is built, because they are computed from the request — its method, its URL, its body. A header fixed at construction cannot express them, and every one of them is a different vendor's scheme.
So McpHttpTransport takes a fetch. This library implements none of the
schemes and imports none of the SDKs; it hands you the hook the MCP SDK already
has, and your signer runs on every request — initialize, tools/list,
tools/call and the event stream alike.
import { createHmac } from 'node:crypto';
const client = await mcpClient({
name: 'ledger',
transport: {
transport: 'http',
url: process.env.LEDGER_MCP_URL!,
fetch: async (url, init) => {
const headers = new Headers(init?.headers);
const body = typeof init?.body === 'string' ? init.body : '';
const signature = createHmac('sha256', process.env.SIGNING_KEY!)
.update(`${init?.method ?? 'GET'}\n${String(url)}\n${body}`)
.digest('hex');
headers.set('authorization', `HMAC ${signature}`);
return fetch(url, { ...init, headers });
},
},
});That is a complete per-request signer, and nothing in it is specific to a cloud vendor. Swap the HMAC for a SigV4 signer from your provider's own SDK and the shape does not change — the import lives in your code, not in this library.
It composes with headers, and you win. Static headers are folded into the
init.headers your function receives, so you can read them, keep them, or
replace them; whatever your function puts on the Headers object is what
reaches the wire. A tenant id set in headers travels untouched; an
authorization set in both is yours, because you write last.
Secrecy is yours to keep, and this library helps by doing nothing. It never
reads, stores, logs or records the headers your function produces — the value
exists inside your closure for the duration of one request. The test suite pins
that: a signing client's Authorization never appears in a console channel, in
a serialized transport descriptor, in a tool result, or in an error thrown
downstream of the signature.
Omit fetch and behaviour is byte-identical to a client that never had the
option.
Servers old enough to answer differently
The tools/call result has two shapes, and both are still in the wild. Today's
carries content blocks. The 2024-10-07 shape carries a bare toolResult and
no content at all. McpCallToolResult — the shim type this client speaks —
models both arms, so handling them is a compile-time obligation rather than
something a reader has to remember.
What you get:
| the server answered with | you receive |
|---|---|
content blocks | text blocks concatenated; non-text blocks summarised as [image], [resource] |
isError: true | a thrown tool error naming the tool and the server, carrying the text |
a legacy toolResult | the value as the tool's text — a string verbatim, anything else JSON-stringified |
| neither | a corrective tool error naming the shape that arrived (its type, or its keys) — never the payload |
The legacy conversion is stated rather than inferred, because the alternative
was worse than a crash: the SDK's own result schema defaults content to [],
so a legacy answer used to arrive wearing an empty content it never sent, and
reading that first answered a real result with an empty string. A toolResult
beside an empty content is now read as the legacy answer it is. A
non-empty content always wins: a server that sent blocks meant the blocks.
Which server served this tool
A tool NAME is not an identity. Two MCP servers can each serve a call_aws, and
a policy matching the bare name governs whichever one answers — including the
one it was never written about. That is a governance hole with no error message.
So every tool mcpClient produces carries source: the client's name. It
reaches the decision point as toolSource on the
middleware context.
import { allow, deny } from 'agentfootprint';
const prodNeedsATicket = {
name: 'prod-needs-a-ticket',
onToolCall: (call) =>
call.toolSource === 'aws-prod' ? deny('production AWS calls need a change ticket') : allow(),
};Absence is the other fact. A tool you wrote with defineTool carries no
source, and its middleware context carries no toolSource — not undefined,
absent. "This agent's own" and "served by a server I chose not to name" are
different situations, and only one of them should match a rule about somebody
else's server.
mockMcpClient stamps its own name the same way, so a policy written against
toolSource is testable before the real server exists. defineTool never sets
source, so it cannot be spoofed by accident; a hand-written Tool may set it
deliberately when it is genuinely relaying another source's tool — and
mcpServe's serving-side chain reads the same field, so a
re-served tool keeps its provenance across the boundary.
Bulk-register from MCP: the declarations come too
.tools(await client.tools()) is a bulk register, and until 9.71.0 what it
registered was thin. A tool that arrived over MCP carried a name, a description
and an input schema — so every rail that reads a declaration went quiet for
it. The dangling-reference and unsupported-argument checks never armed; artifact
placement minted a kind no wants could spend; subject-joined checks had no
owner to join on. An MCP server could be your whole tool catalogue and still be
a second-class citizen of every check this library ships.
It is not thin any more. A server that speaks the _meta.agentfootprint bag
(the serving side describes it)
hands over five declarations, and they land on the registered Tool exactly as
if you had written them in defineTool:
| field | what it arms |
|---|---|
argumentsFrom | the dangling-reference and unsupported-argument integrity checks |
resultKind | placement's mint — a placed result a wants argument can spend |
owner | the identity edge subject-joined checks read |
resultClass | the per-class check:semantics rules |
resultCeiling | the author's refusing ceiling on an oversized result |
Nothing that governs execution is on that list — needs, checkIn, the
session hooks. They decide how a tool runs, and the tool runs on the server.
const fleet = await mcpClient({ name: 'fleet-mcp', transport });
const agent = Agent.create({ provider, model })
.tools(await fleet.tools()) // backup_status declares argumentsFrom: ['fleet_report']
.build();
// …and the choice-seam check now fires for it, exactly as for a local tool.Ingest never throws
The bag comes from a server you do not control, so every field is read
defensively — and judged by the same rule defineTool enforces, because it
is literally the same function. A field that fails that rule is:
- warned about once, naming the server, the tool, the field, and the rule it broke;
- dropped, so the
Toolregisters without that one declaration; - left alone otherwise — the rest of the bag still lands, and the other thirty-nine tools in the same bulk register are untouched.
One server's typo must never kill a whole catalogue, and a rail that quietly stopped arming must never look like a rail that ran and agreed. That is why the warning is unconditional rather than a dev-mode nicety: the symptom of a dropped declaration is silence.
A field this library does not recognise is ignored in silence — a newer server talking to an older client is not an error, and warning about it would make every upgrade noisy at the wrong end.
No bag at all is byte-identical to before. No _meta, a _meta holding
only someone else's keys, or a non-object under ours: the registered Tool has
exactly schema, source and execute, and nothing warns.
mockMcpClient takes the same _meta on each scripted tool and reads it
through the same reader, so a rail armed by a remote declaration — and the
unhappy path where a declaration is malformed — can both be tested before the
real server exists.
MCP from a browser
The barrier was never the protocol and never the SDK — the SDK's client and
Streamable HTTP modules bundle for a browser with no Node dependency at all. It
was one line of ours: mcpClient loads the SDK through a Node require loader,
and that loader does not exist in a browser bundle. So the fix is to let you
supply what the library would otherwise have loaded.
Give it the modules. Import the two SDK modules yourself — statically, so
your bundler resolves them — and hand them over as sdk. The library still
builds the transport, so everything it carries keeps working: headers, your
own fetch, gateway vending, throttle retry, _meta ingestion. McpSdk is the
type of that pair.
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
import { mcpClient } from 'agentfootprint/providers';
const sidecar = await mcpClient({
name: 'sidecar',
sdk: { Client, StreamableHTTPClientTransport },
transport: { transport: 'http', url: '/py/mcp' },
});
const tools = await sidecar.tools(); // the same readonly Tool[], _meta and allThe two subpaths are not interchangeable with a root import: the SDK's "."
export does not resolve, and client/stdio.js is the one client module that
imports Node — importing it is what would drag Node back onto your graph.
A relative url now resolves against the page it was loaded from, which is
also how you avoid a CORS preflight entirely: /py/mcp on the same origin. In
Node — no document, no base — a relative url is refused by name, with the
absolute form to pass instead. An absolute url takes the branch it always took.
Or give it the whole connection. Connect the client yourself and pass it as
connection, typed McpConnection — three methods over JSON-RPC, no vendor
named. The library calls listTools, callTool and close on it and nothing
else; it never calls connect(), because you already did. McpConnectionOptions
is that arm's option shape.
const connection = new Client({ name: 'browser', version: '1.0.0' }, { capabilities: {} });
await connection.connect(new StreamableHTTPClientTransport(new URL('/py/mcp', location.href), {
// reachable ONLY here: the SDK's own validator hook, for a page whose CSP
// refuses ajv's code generation
jsonSchemaValidator: myValidator,
}));
const sidecar = await mcpClient({ name: 'sidecar', connection });Reach for connection when the library must not construct anything — a strict
CSP, or a transport this library has never heard of. Reach for sdk otherwise:
it forfeits nothing.
What the connection arm costs you, said out loud. The library builds no
transport there, so options that live inside a transport are refused at
construction rather than accepted and ignored — retryOnThrottle,
clientInfo, transport, sdk, _client — each with a message naming where
the behaviour went. The sharp one is throttle retry, which is ON by default
everywhere else: wrap your own fetch with retryingFetch (a ThrottleFetch in,
one out) and you get the same 429 handling, honouring Retry-After, applied
where you build the transport.
import { retryingFetch } from 'agentfootprint/providers';
new StreamableHTTPClientTransport(new URL('/py/mcp', location.href), {
fetch: retryingFetch(undefined, { maxAttempts: 5 }),
});signal is honoured on both arms — it rides the SDK's request options, not the
transport.
Three things this does not do. stdio spawns a subprocess and is refused in
words rather than made to work. Your server must send CORS headers, and every
MCP request preflights (Mcp-Session-Id needs to be in
Access-Control-Expose-Headers) — mcpServe sends none, so it cannot stand in
for a browser-facing server. And the SDK's client path costs your bundle roughly
260 KB minified, about half of it ajv, whose new Function needs unsafe-eval
the first time a tool with an outputSchema is validated — escapable only
through the SDK's jsonSchemaValidator, which is why the connection arm
exists beside sdk.
What the package exports
From agentfootprint/providers:
| export | what it is |
|---|---|
mcpClient(opts) | Connect. Returns an McpClient — .tools(), .refresh(), .close(). |
mockMcpClient({ tools }) | The same shape, in memory. |
gatewayTransport({ url, credentials, service }) | A transport whose auth header is vended per request. |
retryingFetch(fetch, options) | The 429 retry mcpClient({ transport }) applies for you, so a connection caller can apply it too. |
McpHttpTransport | { transport: 'http', url, headers?, fetch? }. |
McpCallToolResult | The tools/call union: today's content arm, or the legacy toolResult arm. |
McpSdk | The two SDK modules you can supply instead of the Node loader: Client and StreamableHTTPClientTransport. |
McpConnection | A connected client: listTools, callTool, close. Deliberately no connect. |
McpConnectionOptions | The options for mcpClient({ connection }) — name, connection, signal. |
McpSdkClient | McpConnection plus connect — the surface the library uses when IT builds the client, and what _client takes in tests. |
ThrottleFetch | The fetch shape retryingFetch takes and returns. |
MCP_TOOL_EXTRAS_KEY | The _meta key declarations travel under — 'agentfootprint'. |
McpToolExtras | The shape inside that key: argumentsFrom, resultKind, owner, resultClass, resultCeiling, all optional. |
MCP_TOOL_EXTRAS_KEY and McpToolExtras are public so a server this library
did not write can speak the bag: put McpToolExtras-shaped data under that key
in your tool's _meta and an agentfootprint client reads it.
_client is a test hook and is documented as one: it skips the SDK import and
the transport entirely, which also means it skips the fetch you configured.
Nothing about signing can be proven through it, which is why the signing tests
run against a real socket.
Related
- Serve your tools over MCP —
mcpServe(tools), the other direction - Middleware — where
toolSourceis read - Tool providers —
staticTools/gatedToolsover the tools you just connected - Tool discovery — MCP as an async tool source
Tool discovery (async ToolProvider)
Runtime tool catalogs over hubs, MCP registries, and per-tenant indexes. Async ToolProvider.list(ctx) with TTL caching, AbortSignal propagation, and discovery_started/completed/failed events — no library API additions required.
Serve your tools over MCP
mcpServe(tools, opts) exposes agentfootprint Tool[] AS an MCP server — stdio or Streamable HTTP. Schemas map 1:1, the served tool is the same object you passed in, so the governance you wrapped around it still runs.
