Build

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

transportwhen
stdioa local server you spawn — development, desktop hosts, single user
httpa remote server over Streamable HTTP — the common production case
gatewayStreamable 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 withyou receive
content blockstext blocks concatenated; non-text blocks summarised as [image], [resource]
isError: truea thrown tool error naming the tool and the server, carrying the text
a legacy toolResultthe value as the tool's text — a string verbatim, anything else JSON-stringified
neithera 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:

fieldwhat it arms
argumentsFromthe dangling-reference and unsupported-argument integrity checks
resultKindplacement's mint — a placed result a wants argument can spend
ownerthe identity edge subject-joined checks read
resultClassthe per-class check:semantics rules
resultCeilingthe 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:

  1. warned about once, naming the server, the tool, the field, and the rule it broke;
  2. dropped, so the Tool registers without that one declaration;
  3. 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 all

The 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:

exportwhat 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? }.
McpCallToolResultThe tools/call union: today's content arm, or the legacy toolResult arm.
McpSdkThe two SDK modules you can supply instead of the Node loader: Client and StreamableHTTPClientTransport.
McpConnectionA connected client: listTools, callTool, close. Deliberately no connect.
McpConnectionOptionsThe options for mcpClient({ connection })name, connection, signal.
McpSdkClientMcpConnection plus connect — the surface the library uses when IT builds the client, and what _client takes in tests.
ThrottleFetchThe fetch shape retryingFetch takes and returns.
MCP_TOOL_EXTRAS_KEYThe _meta key declarations travel under — 'agentfootprint'.
McpToolExtrasThe 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.

On this page