agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
223 lines • 10 kB
JavaScript
;
/**
* boundTool — a tool bound to its conversation, the list it came from and that
* list's generation, re-checked after every wait and at the send gate.
*
* Pattern: the shape every MCP tool takes (`mcpClient.ts` · `mcpToolShape`) +
* one `execute` that runs the five steps of § 6.3.
* Role: A bound tool carries its BINDING: the key it was listed for; the
* context it was listed in (per client, the configuration; shared, the
* entry's context); the list it came from (per client, the client
* incarnation; shared, the entry); and that list's generation. Its
* `execute`:
*
* 1. checks that the calling run's caller identity encodes to its key —
* otherwise `identity-mismatch`, nothing sent;
* 2. checks its binding is current — otherwise `stale-tools`, nothing sent;
* 3. is admitted against `maxOperations` and takes a lease on its key's
* client, opening one from a plan resolved now when the key has none. A
* client whose plan's context differs from the binding's refuses with
* `binding-changed` (the client stays for the next list). A client that is
* not the incarnation the tool was listed from is checked against its OWN
* fresh list — the call goes on only if that list holds the same
* definition, compared as canonical JSON; otherwise `stale-tools`. Under
* the app's sharing contract, a replacement whose context matches uses the
* shared entry as it stands;
* 4. after every wait, checks step 2 again — and asks the app's `share`
* again, so a changed authorization name refuses it (`binding-changed`);
* 5. sends through the send gate (`mcp/requestGuard.ts`), asked at every
* boundary the request crosses — before every attempt, after a credential
* arrives, after signing — classifies the outcome (a server's 404 for the
* session is `SessionExpiredError`; a handle this process lost is
* `LocalSessionHandleLostError`; neither is repeated), files its use, and
* releases its lease once the request's own work has settled.
*
* Never another conversation's connection: the binding is to the KEY, and a
* client of any other key never serves it.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.noteRefusal = exports.boundTool = void 0;
const mcpClientUse_js_1 = require("../../tools/mcpClientUse.js");
const toolSessions_js_1 = require("../../tools/toolSessions.js");
const sessionEnded_js_1 = require("./sessionEnded.js");
const mcpClient_js_1 = require("../mcpClient.js");
const requestGuard_js_1 = require("../requestGuard.js");
const errors_js_1 = require("./errors.js");
const identityKey_js_1 = require("./identityKey.js");
const record_js_1 = require("./record.js");
/** A callable tool for `binding`, shaped as every MCP tool is. */
function boundTool(pool, binding) {
return {
...(0, mcpClient_js_1.mcpToolShape)(pool.settings.name, binding.definition.listed),
execute: (args, ctx) => executeBound(pool, binding, args, ctx),
};
}
exports.boundTool = boundTool;
/** The five steps. */
async function executeBound(pool, binding, args, ctx) {
const facts = new record_js_1.UseFacts(pool.settings.name, 'call');
facts.keyHash = binding.key.keyHash;
let operation;
try {
// 1 — the calling run's caller identity, encoded the one way.
const caller = (0, identityKey_js_1.poolKeyOf)(ctx?.identity, { requirePrincipal: false, requireTenant: false });
if (typeof caller === 'string' || caller.key !== binding.key.key) {
throw pool.refused('identity-mismatch');
}
pool.interact();
// 2 — the binding, before anything is admitted.
if (beforeLease(pool, binding) === 'stale')
throw pool.refused('stale-tools');
// 3 — admitted, leased, opened when the key has none; the app's names asked
// again, so a changed authorization refuses what was listed under the old.
operation = pool.admit('call', 'call', ctx?.signal);
const client = await pool.leaseClient(binding.key, operation, facts);
if (pool.refreshContext(client).context !== binding.context) {
throw pool.refused('binding-changed');
}
const { inner, effective } = await toolOn(pool, binding, client, operation, facts);
// 4 — after every wait, the binding again.
operation.check();
checkHolds(pool, client, effective);
// 5 — the send gate, asked at every boundary the request crosses.
operation.guardWith(() => checkHolds(pool, client, effective));
operation.phase = 'call';
const callCtx = (0, requestGuard_js_1.withCallGuard)({ ...(ctx ?? {}), signal: operation.signal }, operation);
try {
return await inner.execute(args, callCtx);
}
catch (error) {
throw classified(pool, client, operation, facts, error);
}
}
catch (error) {
noteRefusal(facts, error);
throw error;
}
finally {
operation?.finish();
(0, mcpClientUse_js_1.mcpClientUseOf)(ctx)?.file(facts.toUse());
}
}
/** The tool to run on `client`, and the binding the send gate checks it against. */
async function toolOn(pool, binding, client, operation, facts) {
const name = binding.definition.listed.name;
const port = client.port;
if (port === undefined)
throw pool.refused('init-failed');
if (binding.shared) {
// The shared entry as it stands: a definition this client may never have
// listed, so the tool carries its own checks (`ClientPort.bind`).
return { inner: port.bind(binding.definition.listed), effective: binding };
}
if (client.clientId === binding.clientId) {
const own = client.list.tool(name);
if (own === undefined)
throw pool.refused('stale-tools');
return { inner: own, effective: binding };
}
// A replacement: its own fresh list, under the context of now, then the
// same definition or nothing.
const listed = await pool.ensureListed(client, operation);
if (listed.context !== binding.context)
throw pool.refused('binding-changed');
const fresh = listed.list.definition(name);
const own = listed.list.tool(name);
if (fresh === undefined ||
own === undefined ||
fresh.canonical !== binding.definition.canonical) {
facts.revalidated = 'refused';
throw pool.refused('stale-tools');
}
facts.revalidated = 'kept';
return {
inner: own,
effective: {
...binding,
clientId: client.clientId,
listId: listed.list.id,
generation: listed.list.generation,
},
};
}
/**
* Step 2, before a lease: a shared binding against its entry; a per-client
* binding against its incarnation when that incarnation is still the key's
* current client. An incarnation that is gone is not stale yet — step 3 checks
* the replacement's own list.
*/
function beforeLease(pool, binding) {
if (binding.shared)
return sharedHolds(pool, binding) ? 'ok' : 'stale';
const current = pool.clients.currentFor(binding.key.key);
if (current === undefined || current.clientId !== binding.clientId)
return 'ok';
return listHolds(current, binding) ? 'ok' : 'stale';
}
/**
* The binding holds on `client` — the one the call runs on: the app's names
* still give its context, and its list has not moved. Otherwise the refusal:
* `binding-changed` for a changed context, `stale-tools` for a moved list.
*/
function checkHolds(pool, client, binding) {
if (!pool.contextHolds(client, binding.context))
throw pool.refused('binding-changed');
const holds = binding.shared ? sharedHolds(pool, binding) : listHolds(client, binding);
if (!holds)
throw pool.refused('stale-tools');
}
function sharedHolds(pool, binding) {
const entry = pool.shared.peek(binding.context);
return (entry !== undefined &&
entry.list.id === binding.listId &&
entry.list.generation === binding.generation);
}
function listHolds(client, binding) {
return client.list.id === binding.listId && client.list.generation === binding.generation;
}
/**
* What the call failed with, as the pool read it: a server's 404 for the
* session it issued is `SessionExpiredError`; a handle this process lost is
* `LocalSessionHandleLostError` (the transport's own object). Either way the
* client leaves its key, and the call is not repeated. Anything else is the
* error as it came.
*/
function classified(pool, client, operation, facts, error) {
const lost = pool.classifyFailure(client, operation, error);
if (lost === 'expired') {
facts.client = 'expired';
facts.evidence = 'server-404';
return new sessionEnded_js_1.ServerEndedSessionError(pool.settings.name, client.clientId, error);
}
if (lost === 'handle-lost') {
facts.client = 'handle-lost';
facts.evidence = 'transport-forgot-handle';
return error instanceof toolSessions_js_1.LocalSessionHandleLostError
? error
: new toolSessions_js_1.LocalSessionHandleLostError({ cause: error });
}
return error;
}
/** A refusal's word on the record. */
function noteRefusal(facts, error) {
if (error instanceof errors_js_1.McpPoolRefusedError) {
facts.refusal = error.reason;
}
else if (error instanceof errors_js_1.PoolFullError) {
facts.refusal = LIMIT_WORDS[error.limit];
}
else {
return;
}
// No client was touched: the pool refused to give one.
if (facts.clientId === undefined)
facts.client = 'refused';
}
exports.noteRefusal = noteRefusal;
const LIMIT_WORDS = {
pool: 'pool-full',
principal: 'principal-full',
ending: 'ending-full',
operations: 'operations-full',
};
//# sourceMappingURL=boundTool.js.map