UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

683 lines • 35.2 kB
/** * mcpClient — connect to an MCP server, expose its tools to your Agent. * * const slack = await mcpClient({ * name: 'slack', * transport: { transport: 'stdio', command: 'npx', args: ['@example/slack-mcp'] }, * }); * * const tools = await slack.tools(); // → readonly Tool[] * const agent = Agent.create({ ... }).tools(tools).build(); * * // ... * * await slack.close(); * * Pattern: Adapter (GoF) — translates MCP `listTools()` / `callTool()` * into agentfootprint's `Tool` interface (schema + execute). * Each MCP tool becomes ONE agentfootprint Tool. The agent's * existing tool-call handler invokes `client.callTool()` * inside the wrapped `execute`. * * Role: Layer-3 integration. Sits next to `defineTool` — same * shape, different source. Once tools land on the agent, * the rest of the library doesn't know they came from MCP. * Since 9.71.0 that is true of DECLARATIONS too: a server's * `_meta` bag becomes `argumentsFrom` / `resultKind` / `owner` * / `resultClass` / `resultCeiling` on the registered `Tool`, * so the integrity checks, the placement mint and the identity * joins arm for a remote tool exactly as for a local one. The * reading NEVER throws — see `toolExtras.ts`. * * Emits: N/A — wrapped tools emit the standard * `agentfootprint.stream.tool_start` / `tool_end` events * when the agent calls them. Add `name: '<mcp-server>'` to * `McpClientOptions` so observability surfaces can group * tool calls by server. On a transport this module builds, * a call the agent dispatched also gets its request's retry * series as `agentfootprint.tools.request_attempts`, filed by * the series through the call's own port (`requestLedger.ts`). * * Lazy-require pattern: the `@modelcontextprotocol/sdk` peer-dep * loads only when a consumer actually constructs a client. Tests * inject `_client` and skip the import path entirely. */ import { isDevMode } from 'footprintjs'; import { callMetaOf, readToolExtras } from './toolExtras.js'; import { isToolError, readToolResult, resultModeOf } from './toolResult.js'; import { listEveryPage, McpToolListIncompleteError } from './toolList.js'; import { listingsOf } from './listingGenerations.js'; import { callCheckKit, checkOutput, checksFor, McpOutputUncheckedError, outputUnchecked, refuseRequiredTask, validatorOption, } from './callChecks.js'; import { createVendingFetch } from './gatewayTransport.js'; import { ledgeredFetch, retryingFetch, seriesWithoutThrottle, } from './throttleRetry.js'; import { RequestLedger } from './requestLedger.js'; import { openCall } from './callLifetime.js'; import { McpReplayUnavailableError, unavailableBeforeSending } from './replayUnavailable.js'; import { needsSeries, replayDeclarationOf, } from './replay.js'; import { refuseConflictingOptions } from './connectionRefusals.js'; import { sdkLoadFailure } from './sdkLoadFailure.js'; import { transportUrl } from './transportUrl.js'; import { lazyRequire } from '../lib/lazyRequire.js'; import { rememberHalfOpen, } from './clientPort.js'; import { DeleteWatch, endLocally, endSession, } from './terminate.js'; // Version-less identity. The MCP `clientInfo` field is informational // (server logs it); a hardcoded number drifts every release. Consumers // who care about wire-level identity pass `clientInfo` explicitly. const DEFAULT_CLIENT_INFO = { name: 'agentfootprint', version: '0.0.0', }; /** * Connect to an MCP server. Returns an `McpClient` that exposes the * server's tools as agentfootprint `Tool[]` and a `close()` to tear * down the transport. * * Three ways to get a connection, and they are three because a browser can * only take the last two: * * - `{ transport }` — the library loads the SDK through its Node loader and * builds everything. The default, and what every Node consumer already * does. * - `{ transport, sdk }` — you supply the two SDK modules with static * imports; the library still builds the transport, so gateway vending, * `retryOnThrottle`, `headers` and your own `fetch` all keep working. * - `{ connection }` — you built and connected the client yourself; the * library only adapts its tools. * * @throws when the two arms are mixed, when a `connection` is not one, or when * `@modelcontextprotocol/sdk` cannot be loaded (the message says which of * "not installed" and "no Node loader here" actually happened), or when the * transport fails to connect. */ export async function mcpClient(opts) { return (await connectClient(opts)).client; } /** * @internal The MCP client pool's way to open a client (`mcp/pool/`): an * ordinary `mcpClient` — the same discovery, checks, ledger and retry series — * plus what only a pool asks for (`clientPort.ts`), and the port back. */ export async function openPooledClient(opts, seam) { return connectClient(opts, seam); } /** The one construction path behind `mcpClient` and the pool's opener. */ async function connectClient(opts, seam) { const name = opts.name ?? 'mcp'; const resultMode = resultModeOf(opts.resultMode, name); // Before anything connects: an option that names a behaviour which cannot // happen on the chosen arm is a defect, not a preference. refuseConflictingOptions(opts, name); // The app's replay declaration — who may send a tool call again — read and // checked once, here, before anything connects (`replay.ts`). const replay = replayDeclarationOf(opts.replay, `mcpClient[${name}]`); const strict = replay.policy === 'app-authorized'; // The validator a tool's own output checks use (`callChecks.ts`), refused by // name now when it is not one. const validator = validatorOption(opts.jsonSchemaValidator, `mcpClient[${name}]`); const { connection, ledger, kit, ending } = await openConnection(opts, replay, name, validator, seam); const unlisted = new Set(); // Tool cache so consumers calling `.tools()` more than once don't // hammer the server. `.refresh()` invalidates it. let cache = null; let closed = false; // The one ending this client gets (`terminate.ts`): `terminate()` asks for it // once, and every later call answers the same outcome. let ended; const ensureOpen = (op) => { if (closed) { throw new Error(`mcpClient[${name}].${op}() called after close(). Construct a new client to reconnect.`); } }; const listWith = async (signal) => { // Every page of the server's list (`toolList.ts`). `signal` rides each // page in the SDK's trailing options argument, never in the params — see // `wrapMcpTool`. // Each page request is counted in this connection's listings, whatever it // settles to (`listingGenerations.ts`). const listing = await listEveryPage(connection, signal, kit.listings); // A list that did not end cannot say which entries name nothing. if (listing.incomplete === undefined) noteUnlisted(replay, listing.tools, name, unlisted); // Every tool carries the checks of the list it came from (`callChecks.ts`): // the SDK's `Client` holds them only while a one-page list's answer is its // latest, and only then are they left to it. const tools = listing.tools.map((t) => wrapMcpTool(name, connection, t, opts.signal, resultMode, ledger, strict, checksFor(t, kit, listing.generation))); // Never a list cut short: what was listed travels on the refusal instead. if (listing.incomplete !== undefined) { throw new McpToolListIncompleteError(listing.incomplete, name, listing.pages, tools); } return { tools, listed: listing.tools }; }; const buildTools = async () => (await listWith(opts.signal)).tools; const closeLocally = async () => { try { await connection.close(); } finally { // Nothing this connection sent is awaited any more, however it closed. ledger?.close(); } }; // The remote session ended, then the local side — at most once. const end = () => { if (ended !== undefined) return ended; closed = true; cache = null; ended = ending(closeLocally); return ended; }; const client = { name, async tools() { ensureOpen('tools'); if (!cache) cache = await buildTools(); return cache; }, async refresh() { ensureOpen('refresh'); cache = await buildTools(); return cache; }, async close() { if (closed) return; closed = true; cache = null; await closeLocally(); }, async terminate() { if (ended !== undefined) return ended.outcome; ensureOpen('terminate'); return end().outcome; }, }; const port = { async listing(signal) { ensureOpen('tools'); const listing = await listWith(signal); cache = listing.tools; return listing; }, bind: (listed) => // No listing generation: this connection's SDK holds no checks for a // definition it did not list, so the tool always runs its own. wrapMcpTool(name, connection, listed, opts.signal, resultMode, ledger, strict, checksFor(listed, kit, undefined)), end, }; return { client, port }; } /** * An ending that sends nothing: the local close, and the outcome that says why * — answered by the teardown deadline even when the close hangs, the close * itself reported apart (`terminate.ts` · `endLocally`). */ function endingWithout(remote) { return (close) => endLocally(remote, close); } /** * The one funnel every arm passes through, in precedence order: a connection * you handed over, the internal test seam, then the library building one. * Only the last has a ledger: a connection built elsewhere has no retry series * of ours to file a call's attempts. * * A call with neither `connection` nor `sdk` reaches `resolveClient` with two * `undefined` arguments and takes the identical path it always took. */ async function openConnection(opts, replay, name, validator, seam) { // A client built elsewhere: the library loads nothing, so only the caller's // validator can check a tool's output itself, and errors take the SDK's shape. // Its list requests are counted per client: every mcpClient over it shares one // count, as they share the SDK's one set of checks (`listingGenerations.ts`). // The library did not build its transport, so it cannot end the session. if (opts.connection !== undefined) { const kit = callCheckKit(name, listingsOf(opts.connection), validator); return { connection: opts.connection, kit, ending: endingWithout('unavailable') }; } if (opts._client !== undefined) { return { connection: opts._client, kit: callCheckKit(name, listingsOf(opts._client), validator), ending: endingWithout('unavailable'), }; } // `refuseConflictingOptions` has already refused the case where this is // absent, so the assertion documents that guarantee rather than assuming it. const transport = opts.transport; return resolveClient(transport, opts.clientInfo, opts.signal, opts.retryOnThrottle, opts.sdk, replay, name, validator, seam); } // ─── SDK construction (lazy require, unless the caller supplied it) ─ async function resolveClient(transport, clientInfo, signal, retryOnThrottle, sdk, replay, name = 'mcp', validator, seam) { if (sdk !== undefined) assertSdk(sdk, transport.transport); const mod = sdk ?? loadClientModule(); // A caller's validator is the client's too, so one validator checks every // page; without one, the options are exactly what they always were. A pooled // client hears the server's tools-changed notice with the SDK's automatic // refresh OFF: the notice only makes the pool list again, through this // client's own discovery, which counts every listing (`listingGenerations.ts`). const options = validator === undefined ? { capabilities: {} } : { capabilities: {}, jsonSchemaValidator: validator }; if (seam !== undefined) { options.listChanged = { tools: { autoRefresh: false, debounceMs: 0, onChanged: () => seam.onToolsChanged() }, }; } const client = new mod.Client(clientInfo ?? DEFAULT_CLIENT_INFO, options); const built = await buildTransport(transport, retryOnThrottle, sdk, replay, name, seam); // With the SDK loaded here, so is what a tool's own checks run with: its // default validator and its own error class. With `sdk`, the loader is never // touched (`callChecks.ts`). const kit = sdk === undefined ? callCheckKit(name, listingsOf(client), validator, loadCheckModules(validator)) : callCheckKit(name, listingsOf(client), validator); // Same rule as callTool: options ride beside the transport, never inside it. // A pooled client connects under the pool's own signal and open deadline; // its lifetime signal (`signal`) rides every later request. const connectSignal = seam?.connectSignal ?? signal; try { if (connectSignal) await client.connect(built.transport, { signal: connectSignal }); else await client.connect(built.transport); } catch (error) { // A pooled open that failed may still have opened a session on the server: // the pool can end it once the open's work has settled (`clientPort.ts`). if (seam !== undefined) { const transport = built.transport; rememberHalfOpen(error, { hasSession: () => transport?.sessionId !== undefined, end: () => built.ending(async () => { try { await client.close(); } finally { built.ledger?.close(); } }), }); } throw error; } return { connection: client, kit, ...(built.ledger !== undefined && { ledger: built.ledger }), ending: built.ending, }; } /** * What the SDK's `Client` checks a tool's call with, loaded with the SDK on the * Node loader path (the bundling law: when the client is built, never at a * later tool call): its own `McpError`, and — unless the caller gave a * validator — its default validator, `AjvJsonSchemaValidator`. Both modules are * the ones `client/index.js` already loaded. An SDK without one of them (older * than its public validator subpath) leaves that slot empty: errors then take * the SDK's shape, and an output no validator here can check is never returned. */ function loadCheckModules(validator) { const McpError = loadOptional(() => lazyRequire('@modelcontextprotocol/sdk/types.js'))?.McpError; const Validator = validator === undefined ? loadOptional(() => lazyRequire('@modelcontextprotocol/sdk/validation/ajv'))?.AjvJsonSchemaValidator : undefined; return { McpError, Validator }; } /** A module this SDK may not have: `undefined` when it cannot be loaded. */ function loadOptional(load) { try { return load(); } catch { return undefined; } } /** * An injected `sdk` is checked before anything is built, so a wrong import (a * namespace object, one module of the two) is refused by name here rather * than surfacing later as "x is not a constructor". */ function assertSdk(sdk, transport) { const needed = transport === 'stdio' ? ['Client'] : ['Client', 'StreamableHTTPClientTransport']; const given = sdk; const missing = needed.filter((key) => typeof given !== 'object' || given === null || typeof given[key] !== 'function'); if (missing.length === 0) return; const sources = { Client: "`import { Client } from '@modelcontextprotocol/sdk/client/index.js'`", StreamableHTTPClientTransport: '`import { StreamableHTTPClientTransport } from ' + "'@modelcontextprotocol/sdk/client/streamableHttp.js'`", }; throw new TypeError(`mcpClient: \`sdk\` has no ${missing.map((key) => `\`${key}\``).join(' or ')} constructor. ` + `Pass ${needed.length > 1 ? 'the classes themselves' : 'the class itself'}: ` + `${needed.map((key) => sources[key]).join(', ')}, ` + `then \`sdk: { ${needed.join(', ')} }\`.`); } /** What every not-installed message adds: the bundler case, and the hook past it. */ const BUNDLED_HINT = ' Installed, and your app is bundled (esbuild, webpack)? The bundler cannot follow\n' + ' this lazy load: import the SDK yourself and pass it as `sdk`\n' + ' (sdk: { Client, StreamableHTTPClientTransport }), or pass `connection`.'; /** The Node loader path, unchanged — reached only when no `sdk` was supplied. */ function loadClientModule() { try { return lazyRequire('@modelcontextprotocol/sdk/client/index.js'); } catch (err) { throw new Error(sdkLoadFailure(err, { notInstalled: 'mcpClient requires @modelcontextprotocol/sdk.\n' + ' Install: npm install @modelcontextprotocol/sdk\n' + BUNDLED_HINT, caller: 'mcpClient', specifier: '@modelcontextprotocol/sdk/client/index.js', instead: 'Pass `sdk` (the two SDK modules, imported statically) or `connection` ' + '(a client you connected yourself).', })); } } async function buildTransport(t, retryOnThrottle, sdk, replay, name = 'mcp', seam) { if (t.transport === 'stdio') { // Deliberately NOT served by `sdk`: stdio spawns a subprocess, so it can // never run in a browser, and `client/stdio.js` is the SDK's one client // module that imports `node:process`/`node:stream`. Keeping it behind the // loader is what keeps it off every browser module graph. let stdioMod; try { stdioMod = lazyRequire('@modelcontextprotocol/sdk/client/stdio.js'); } catch (err) { throw new Error(sdkLoadFailure(err, { notInstalled: 'mcpClient(stdio) requires @modelcontextprotocol/sdk/client/stdio.js — ' + 'check that @modelcontextprotocol/sdk is installed at the latest version.', caller: 'mcpClient(stdio)', specifier: '@modelcontextprotocol/sdk/client/stdio.js', instead: 'stdio spawns a subprocess, so it cannot run in a browser at all — ' + "reach the server over `transport: { transport: 'http', url }` instead.", })); } // No fetch, so no retry series: a stdio call has no attempts to file. // stdio has no session id to end: its ending is the local close. const stdio = new stdioMod.StdioClientTransport({ command: t.command, args: t.args ? [...t.args] : [], ...(t.env && { env: { ...t.env } }), ...(t.cwd !== undefined && { cwd: t.cwd }), }); if (seam === undefined) return { transport: stdio, ending: endingWithout('no-session') }; // A pooled client's sends are watched, so the pool's gate is asked at // `send()` — on stdio, which writes without waiting, the last boundary. const ledger = new RequestLedger({ server: name, strict: replay?.policy === 'app-authorized', guardFor: seam.guardFor, }); return watchedForPool(stdio, ledger, endingWithout('no-session'), name); } // http + gateway transports both ride Streamable HTTP. They differ only in // WHEN the auth headers are decided: `http` fixes them at construction, // `gateway` vends them inside every request (see gatewayTransport.ts). const httpMod = sdk ?? loadHttpModule(t.transport); // Throttle retry wraps the OUTERMOST fetch, so every attempt is re-signed // and re-vended: signatures expire, and a token that would have died during // the wait is simply never the one reused. `retryingFetch` returns its input // untouched when retry is off — including `undefined`, so a plain `http` // transport with no custom fetch still passes none and behaves exactly as it // did before 8.11.0. A transport fetch that already runs a retry series of // its own (AgentCore Runtime's) is never wrapped: `retryingFetch` joins the // 429 rule to that series, so one call is one series. // // The ledger joins the same series, so each request a dispatched tool call // sends files its attempts into that call's run; the transport's `send` is // watched to bind each request to its call (`requestLedger.ts`). The app's // replay declaration joins with it: the series asks it before sending a tool // call again (`replay.ts`). `retryOnThrottle: false` turns off the 429 rule // and nothing else: when the declaration still needs a series — a tool // declared repeatable on 'network', or the 'app-authorized' policy — the // series stays, with that rule switched off (`seriesWithoutThrottle`). // Otherwise a fetch that runs no series gets neither ledger nor declaration: // there is nothing to file and nothing is sent again. const retried = (inner) => retryOnThrottle === false && replay !== undefined && needsSeries(replay) ? seriesWithoutThrottle(inner) : retryingFetch(inner, retryOnThrottle); // Under 'app-authorized' the ledger refuses a request it cannot match to its // call, and awaits each matched request's answer (`requestLedger.ts`). const ledger = new RequestLedger({ server: name, strict: replay?.policy === 'app-authorized', ...(seam !== undefined && { guardFor: seam.guardFor }), }); // The pass-through fetch every transport the library builds carries: it // changes no request, and reads a `DELETE`'s answer for `terminate()` // (`terminate.ts`), because the SDK treats a 405 as success. A pooled client's // requests also cross the pool's gate here, before anything else of ours. const watch = new DeleteWatch(); const outermost = (inner) => watch.fetch(seam === undefined ? inner : seam.wrapFetch(inner ?? platformFetch, (body) => guardOfCall(ledger, body))); if (t.transport === 'gateway') { // The consumer's own `fetch` (9.32.0) goes UNDERNEATH the vending, not // beside it: `createVendingFetch` resolves the credential, applies it to // this one request, and then calls the base fetch — so an mTLS agent or a // DPoP signer sees the final headers and has the last word, while the // credential is still vended per request. Passing `undefined` is the // default global `fetch`, which is byte-identical to every release before // the seam existed. const gatewayFetch = retried(createVendingFetch(t, t.fetch)); const ledgered = gatewayFetch && ledgeredFetch(gatewayFetch, ledger, replay); const sdkTransport = new httpMod.StreamableHTTPClientTransport(transportUrl(t.url, 'mcpClient'), { fetch: outermost(ledgered ?? gatewayFetch) }); if (seam !== undefined) { return watchedForPool(sdkTransport, ledger, sessionEnding(sdkTransport, watch), name); } return watched(sdkTransport, ledgered !== undefined ? ledger : undefined, sessionEnding(sdkTransport, watch)); } // Both options ride the SAME SDK transport, and both are forwarded when both // are given. The SDK folds `requestInit.headers` into the `init.headers` it // hands the custom fetch, so a signer sees the static headers and has the // final word over the bytes — see `McpHttpTransport.fetch`. const httpFetch = retried(t.fetch); const ledgered = httpFetch && ledgeredFetch(httpFetch, ledger, replay); const sdkTransport = new httpMod.StreamableHTTPClientTransport(transportUrl(t.url, 'mcpClient'), { ...(t.headers && { requestInit: { headers: { ...t.headers } } }), fetch: outermost(ledgered ?? httpFetch), }); if (seam !== undefined) { return watchedForPool(sdkTransport, ledger, sessionEnding(sdkTransport, watch), name); } return watched(sdkTransport, ledgered !== undefined ? ledger : undefined, sessionEnding(sdkTransport, watch)); } /** The guard of the pooled tool call `body` belongs to, by its ledger claim. */ function guardOfCall(ledger, body) { return ledger.claim(body)?.guard; } /** How an SDK Streamable HTTP transport's session ends: its own `terminateSession()`. */ function sessionEnding(transport, watch) { const terminable = transport; if (typeof terminable?.terminateSession !== 'function') return endingWithout('unavailable'); return (close) => endSession(terminable, watch, close); } /** * `transport` with its sends watched by `ledger` — the SAME object, its own * `send` installed in place. As it is, with no ledger, when there is none or it * cannot be watched: then no call expects its attempts filed. */ function watched(transport, ledger, ending) { if (ledger === undefined || transport === null || typeof transport !== 'object') { return { transport, ending }; } return ledger.watch(transport) === undefined ? { transport, ending } : { transport, ledger, ending }; } /** * A pooled client's transport, its sends watched — REQUIRED. The pool checks * every request against its binding at the watched `send` (on stdio the last * boundary there is) and ties each tool call to its guard there, so a * transport whose `send` cannot be watched is refused before anything is * built on it or sent: the pool's guarantee needs the watch, whatever the * replay policy. A standalone client never comes here (`watched`). * * @throws when the transport has no `send` to watch or refuses the property */ function watchedForPool(transport, ledger, ending, name) { const built = watched(transport, ledger, ending); if (built.ledger === undefined) { throw new Error(`mcpClient[${name}]: the MCP client pool checks every request at its transport's \`send\` ` + "before it is sent, and this transport's `send` cannot be watched (it is missing, or a " + 'property that cannot be redefined), so the pooled client is not opened. Nothing was sent.'); } return built; } /** The platform's fetch, looked up at call time so a later polyfill still wins. */ const platformFetch = (input, init) => globalThis.fetch(input, init); /** The Node loader path for Streamable HTTP — reached only with no `sdk`. */ function loadHttpModule(transport) { try { return lazyRequire('@modelcontextprotocol/sdk/client/streamableHttp.js'); } catch (err) { throw new Error(sdkLoadFailure(err, { notInstalled: `mcpClient(${transport}) requires @modelcontextprotocol/sdk/client/streamableHttp.js — ` + 'check that @modelcontextprotocol/sdk is installed at the latest version.\n' + BUNDLED_HINT, caller: `mcpClient(${transport})`, specifier: '@modelcontextprotocol/sdk/client/streamableHttp.js', instead: 'Pass `sdk` (the two SDK modules, imported statically) or `connection` ' + '(a client you connected yourself).', })); } } /** * Dev mode, once per client and name: a replay entry for a tool the server does * not list applies to nothing — a misspelled name would leave a tool that makes * changes outside the side-effect guard, so it is named. */ function noteUnlisted(replay, listed, server, noted) { if (replay.tools.size === 0 || !isDevMode()) return; const names = new Set(listed.map((tool) => tool.name)); for (const tool of replay.tools.keys()) { if (names.has(tool) || noted.has(tool)) continue; noted.add(tool); // eslint-disable-next-line no-console console.warn(`[agentfootprint] mcpClient[${server}]: \`replay.tools\` names ${JSON.stringify(tool)}, ` + "which the server does not list, so the entry applies to nothing. Check the name against the server's tool list."); } } // ─── Tool wrapping ───────────────────────────────────────────────── /** * @internal What a listed MCP tool looks like as a `Tool`, less its `execute`: * its schema, its provenance and the declarations its `_meta` carries. The ONE * shape both an `mcpClient` tool and an MCP client pool's bound tool take. */ export function mcpToolShape(serverName, mcp) { return { schema: { name: mcp.name, description: mcp.description ?? `MCP tool: ${mcp.name}`, inputSchema: mcp.inputSchema, }, // Provenance, carried to the decision point. Governance that matches on a // bare tool name cannot tell two servers' identically-named tools apart; // this is the fact that lets it. See `Tool.source`. source: serverName, // The DECLARATIONS the server sent in MCP's `_meta` bag (9.71.0) — read // defensively, per field, by the same rules `defineTool` enforces, and // NEVER throwing: a malformed field is warned about once and dropped, and // this tool still registers. That is what keeps one bad tool from killing // a forty-tool bulk register. A server that sent no bag adds no keys here, // so the Tool is byte-identical to one built before this existed. ...readToolExtras(mcp._meta, { server: serverName, tool: mcp.name, inputSchema: mcp.inputSchema, }), }; } function wrapMcpTool(serverName, connection, mcp, signal, resultMode, ledger, strict = false, checks) { const tool = { ...mcpToolShape(serverName, mcp), execute: async (args, ctx) => { // The agent passes args as `unknown` per Tool contract. MCP // expects a JSON object — non-object inputs become `{}` rather // than failing the SDK call. const argsObj = args !== null && typeof args === 'object' && !Array.isArray(args) ? args : {}; // The abort signal is NOT a request param — it belongs to the SDK's // third argument (`RequestOptions`). Put it in the params object and // it gets serialized onto the wire as an empty `{}` and the call is // uncancellable: a silent no-op that only a real transport reveals. // `resultSchema` (2nd arg) is left to the SDK's own default. // The call's time (`ctx.time`, the time layer) crosses in the request's own // `_meta` bag, under the one namespaced key; a call without it sends no `_meta`. const params = { name: mcp.name, arguments: argsObj, ...(ctx?.time !== undefined && { _meta: callMetaOf(ctx.time) }), }; // The call's lifetime (`callLifetime.ts`): its context's signal and the // client's cancel the SDK request while it runs; the call puts a private // marker in its params' `_meta` (`call.params`), which ties the request // the SDK sends to this call — through any copy the SDK makes — so the // retry series that sends it files its attempts into the call's run; and // however the call settles, a series still sending is cut, inside the // call. The transport takes the marker out: the server receives the // request it always did. // // How it settles is its verdict: `completed` when the server answered and // not with an error (a result it then cannot decode still completed). // Under 'app-authorized', a call that genuinely lacked an answer somewhere // and does not complete fails as "may have run"; a received refusal or // error is delivered as it came (`callLifetime.ts`). That contract needs // each request matched to its call and each answer seen: a connection // that cannot establish both refuses the call here, before anything is // sent (`replayUnavailable.ts`). const unavailable = strict ? unavailableBeforeSending(ledger) : undefined; if (unavailable !== undefined) { throw new McpReplayUnavailableError(unavailable, serverName, mcp.name); } // Whose checks this call runs under, decided before anything is sent // (`callChecks.ts`): the SDK's `Client` holds them while this tool's // one-page list is still its latest answer and no list request is out; // otherwise the tool's own checks run. An output they cannot check is // refused here, unsent. const own = checks !== undefined && !checks.heldBySdk(); if (own && outputUnchecked(checks)) { throw new McpOutputUncheckedError(serverName, mcp.name, false); } const call = openCall(params, ctx, signal, ledger, serverName); let verdict = 'failed'; try { const send = () => call.signal ? connection.callTool(call.params, undefined, { signal: call.signal }) : connection.callTool(call.params); // The tool's own checks run where the SDK's `callTool` runs them, so a // call fails as it would with the SDK holding them: the task refusal // before sending, the output checks once the answer is in. if (own) refuseRequiredTask(checks); const result = await send(); // A list request that went out while the call was out may have replaced // the SDK's checks before it read the answer, so the tool's own checks // read it too. if (checks !== undefined && (own || !checks.heldBySdk())) checkOutput(checks, result); verdict = isToolError(result) ? 'tool-error' : 'completed'; return readToolResult(result, mcp.name, serverName, resultMode); } catch (error) { throw call.failed(error, verdict); } finally { call.close(verdict); } }, }; return tool; } //# sourceMappingURL=mcpClient.js.map