agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
216 lines • 7.65 kB
JavaScript
/**
* mcpClientUse — the door an MCP client pool's use of a client enters the run by.
*
* Pattern: a per-dispatch port (the `requestEvidence.ts` precedent) for a call,
* and a per-context ledger for a listing, behind one allow-list.
* Role: `mcpClientPool` (`mcp/pool/`, layer L12) runs below the agent (L9):
* it cannot reach a scope, and the agent cannot import it. Both reach
* this module, which sits under both:
*
* - a CALL: the dispatch loop puts a port on each call's context
* (`mcpClientUseFor`), stamped with that call's id; a pooled tool files its
* use through it once, as its `execute` settles;
* - a LISTING: the pool files its use against the `ToolDispatchContext`
* object it was handed (`fileListUse`); the tools slot takes the uses filed
* against the context it passed (`takeListUses`) after `list(ctx)`, and
* emits them.
*
* Either way the event is `agentfootprint.tools.mcp_client`, emitted by the
* library stage that called the pool, mid-stage, through `typedEmit` — so the
* run's redaction rule serves it and its meta carries the real stage.
*
* THE ALLOW-LIST is the whole record: the fields of `ToolsMcpClientPayload`,
* the library's own words for each, numbers, and two ids of the pool's own
* making (`keyHash`, `clientId`) under a fixed shape. A filing with anything
* else — a field nobody listed, a word outside its set, a non-plain value — is
* refused whole, never trimmed into something it did not say.
*/
import { isDevMode } from 'footprintjs';
import { typedEmit } from '../recorders/core/typedEmit.js';
/** @internal The context key the dispatcher puts a call's sink under. */
export const MCP_CLIENT_USE = Symbol('agentfootprint.mcp-client-use');
/**
* The port for one dispatch: files `agentfootprint.tools.mcp_client` at
* `'call'`, stamped with the call's id, at most once.
*/
export function mcpClientUseFor(scope, call) {
let filed = false;
const sink = {
file: (use) => {
if (filed)
return;
filed = true;
emitUse(scope, { ...use, at: 'call' }, call.toolCallId);
},
};
return { [MCP_CLIENT_USE]: sink };
}
/** The sink a call's context carries, or `undefined`. Never throws, whatever `ctx` is. */
export function mcpClientUseOf(ctx) {
if (ctx === null || typeof ctx !== 'object')
return undefined;
let port;
try {
port = ctx[MCP_CLIENT_USE];
}
catch {
return undefined;
}
if (port === null || typeof port !== 'object')
return undefined;
return typeof port.file === 'function'
? port
: undefined;
}
/** Listing uses filed against a dispatch context, until the tools slot takes them. */
const LIST_USES = new WeakMap();
/** File a listing's use against the `ToolDispatchContext` it was asked with. Never throws. */
export function fileListUse(ctx, use) {
if (ctx === null || typeof ctx !== 'object')
return;
const uses = LIST_USES.get(ctx) ?? [];
uses.push({ ...use, at: 'list' });
LIST_USES.set(ctx, uses);
}
/** Take (and forget) every listing use filed against `ctx`. */
export function takeListUses(ctx) {
const uses = LIST_USES.get(ctx) ?? [];
LIST_USES.delete(ctx);
return uses;
}
/** Emit the listing uses filed against `ctx`, as the tools slot does after `list(ctx)`. */
export function emitListUses(scope, ctx) {
for (const use of takeListUses(ctx))
emitUse(scope, use, undefined);
}
/** One use, through the allow-list, as the event. Never throws. */
function emitUse(scope, use, toolCallId) {
const served = servedUse(use);
if (served === undefined) {
warnOnce('a filing outside the allow-list was refused');
return;
}
try {
typedEmit(scope, 'agentfootprint.tools.mcp_client', {
...served,
...(toolCallId !== undefined && { toolCallId }),
});
}
catch (err) {
// The record is not the work: a sink that dies must not fail the call.
warnOnce(err instanceof Error ? err.message : String(err));
}
}
const AT = { list: true, call: true };
const CLIENT = {
opened: true,
joined: true,
reused: true,
'not-needed': true,
refused: true,
expired: true,
'handle-lost': true,
};
const PREVIOUS = {
idle: true,
evicted: true,
expired: true,
'handle-lost': true,
terminated: true,
};
const EVIDENCE = {
'server-404': true,
'transport-forgot-handle': true,
};
const REFUSAL = {
'pool-full': true,
'principal-full': true,
'ending-full': true,
'init-timeout': true,
'init-failed': true,
'no-identity': true,
'incomplete-identity': true,
'identity-mismatch': true,
'stale-tools': true,
'binding-changed': true,
'request-unmatched': true,
'lease-expired': true,
'operations-full': true,
closed: true,
};
const DEFINITIONS = {
shared: true,
listed: true,
};
const REVALIDATED = {
kept: true,
refused: true,
};
/** A digest of `hashSessionKey`'s shape: hex (SHA-256) or the FNV fallback. */
const KEY_HASH = /^[0-9a-f]{8,16}$/;
/** The pool's own client id: a random hex string. */
const CLIENT_ID = /^[0-9a-f]{12,32}$/;
/** A server name the app declared: a short plain label. */
const SERVER = /^[\x21-\x7e][\x20-\x7e]{0,127}$/;
/** The use as the record may carry it, or `undefined` when it is outside the allow-list. */
export function servedUse(use) {
if (use === null || typeof use !== 'object' || Array.isArray(use))
return undefined;
const allowed = new Set([
'server',
'at',
'keyHash',
'clientId',
'generation',
'client',
'previous',
'evidence',
'refusal',
'initMs',
'definitions',
'definitionsAgeMs',
'revalidated',
]);
for (const key of Object.keys(use))
if (!allowed.has(key))
return undefined;
const ok = typeof use.server === 'string' &&
SERVER.test(use.server) &&
word(AT, use.at) &&
word(CLIENT, use.client) &&
optional(use.keyHash, (v) => typeof v === 'string' && KEY_HASH.test(v)) &&
optional(use.clientId, (v) => typeof v === 'string' && CLIENT_ID.test(v)) &&
optional(use.generation, wholeNumber) &&
optional(use.previous, (v) => word(PREVIOUS, v)) &&
optional(use.evidence, (v) => word(EVIDENCE, v)) &&
optional(use.refusal, (v) => word(REFUSAL, v)) &&
optional(use.initMs, nonNegative) &&
optional(use.definitions, (v) => word(DEFINITIONS, v)) &&
optional(use.definitionsAgeMs, nonNegative) &&
optional(use.revalidated, (v) => word(REVALIDATED, v));
if (!ok)
return undefined;
return { ...use };
}
function word(words, value) {
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(words, value);
}
function optional(value, check) {
return value === undefined || check(value);
}
function wholeNumber(value) {
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
}
function nonNegative(value) {
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
}
/** Dev mode, once per reason: a filing the record could not take. */
const warned = new Set();
function warnOnce(reason) {
if (!isDevMode() || warned.has(reason))
return;
warned.add(reason);
// eslint-disable-next-line no-console
console.warn(`[agentfootprint] tools.mcp_client: ${reason}.`);
}
//# sourceMappingURL=mcpClientUse.js.map