agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
266 lines • 14.8 kB
JavaScript
;
/**
* toolExtras — agentfootprint's own tool DECLARATIONS, carried over MCP.
*
* A `Tool` is two different things at once. Half of it is EXECUTION — the
* handler, the credential it needs, the human it wants to check in with. The
* other half is DECLARATION — flat, inert facts about the tool that no code
* runs and every consumer-side rail READS: where its arguments come from, what
* kind of artifact its result is, who owns it, what class of answer it gives,
* how big a result is too big.
*
* Over MCP only the execution half used to cross. A tool served by `mcpServe`
* arrived at a client as 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, placement minted
* `tool-result/<name>` that no `wants` could spend, and the identity joins had
* no subject. An MCP server could be somebody's whole tool catalogue and still
* be a second-class citizen of every check this library ships.
*
* ── The carrier ─────────────────────────────────────────────────────────
* MCP's own extensibility bag: `Tool._meta`, under ONE namespaced key
* ({@link MCP_TOOL_EXTRAS_KEY}). `_meta` is declared on the spec's `Tool`
* object (`@modelcontextprotocol/sdk` types it as
* `z.record(z.string(), z.unknown()).optional()` on `ToolSchema`), so it
* survives the SDK's own `tools/list` validation in both directions and needs
* no new protocol, no side channel and no version negotiation. A server that
* has never heard of agentfootprint sends no bag; a client that has never
* heard of it ignores the key.
*
* ── What may travel, and the bar ────────────────────────────────────────
* **A declaration a consumer-side check or rail reads; nothing that governs
* execution.** Every field below passes it. `needs` (credentials), `checkIn`
* (human consent) and the session hooks do NOT and never will: they govern how
* a tool RUNS, and the tool runs on the server. Sending them would tell a
* client to hold a gate that the only executor of the tool has already held,
* or — worse — that nobody is holding at all. `mcpServe` already refuses to
* serve a `checkIn` tool for exactly that reason; this list is the same law,
* stated on the way out.
*
* ── The asymmetry that matters ──────────────────────────────────────────
* SERVING validates nothing new: the tool was built by `defineTool`, which
* already refused every malformed declaration at definition time.
*
* INGEST validates everything and THROWS NOTHING. A bag comes from a server
* this process does not control, and one server's typo must not take down a
* bulk register of forty tools. So each field is judged by the SAME rule
* `defineTool` enforces (literally the same exported assert), and a field that
* fails is warned about once — naming the server, the tool, the field and the
* rule — and DROPPED. The tool still registers, without that one declaration.
* That is the honest outcome: the rail it would have armed stays unarmed, and
* the developer is told which rail and why.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports._resetToolExtrasWarnings = exports.readToolExtras = exports.toolExtrasOf = exports.callTimeOf = exports.callMetaOf = exports.MCP_TOOL_EXTRAS_KEY = void 0;
const tools_js_1 = require("../tools/tools.js");
const declare_js_1 = require("../tools/declare.js");
const script_js_1 = require("../tools/script.js");
const declaration_js_1 = require("../semantics/coverage/search/declaration.js");
const wire_js_1 = require("../time/wire.js");
/**
* The single `_meta` key every agentfootprint declaration travels under.
*
* Namespaced so it can never collide with another vendor's `_meta` entry, and
* a CONSTANT so there is exactly one spelling in the codebase — the
* `branchSegment.ts` precedent upstream: one owner of the grammar, and nobody
* else may write the string.
*/
exports.MCP_TOOL_EXTRAS_KEY = 'agentfootprint';
// ─── A call's own metadata: the time layer's `ctx.time` ──────────────
/**
* The `_meta` bag a `tools/call` request carries for a call handed
* `ctx.time` (the time layer, `time/wire.ts` · `TimeContext`): the
* same object, under the one namespaced key — so a host tool in another
* process can declare the `period.queried` its read covered without parsing
* its own argument.
*/
function callMetaOf(time) {
return { [exports.MCP_TOOL_EXTRAS_KEY]: { [wire_js_1.TIME_CONTEXT_META_KEY]: time } };
}
exports.callMetaOf = callMetaOf;
/**
* The call's time a `tools/call` request carried in its `_meta` bag, read —
* or `undefined` when it carried none, or one this runtime cannot read (a
* different version, a malformed field): never repaired, never guessed. What
* `mcpServe` hands a served tool as `ctx.time`.
*/
function callTimeOf(meta) {
const bag = bagOf(meta);
return bag === undefined ? undefined : (0, wire_js_1.readTimeContext)(bag[wire_js_1.TIME_CONTEXT_META_KEY]);
}
exports.callTimeOf = callTimeOf;
/**
* The declarations on `tool`, ready to be written into an MCP `_meta` bag —
* or `undefined` when the tool declares none, so `mcpServe` can leave `_meta`
* off the listing entirely rather than sending an empty object.
*
* Copied verbatim, never inferred or defaulted. This is the SERVE side, and
* the values were already vetted by `defineTool`.
*/
function toolExtrasOf(tool) {
const extras = {
...(tool.argumentsFrom !== undefined && { argumentsFrom: tool.argumentsFrom }),
...(tool.resultKind !== undefined && { resultKind: tool.resultKind }),
...(tool.resultColumns !== undefined && { resultColumns: tool.resultColumns }),
...(tool.owner !== undefined && { owner: tool.owner }),
...(tool.resultClass !== undefined && { resultClass: tool.resultClass }),
...(tool.resultCeiling !== undefined && { resultCeiling: tool.resultCeiling }),
...(tool.composedOf !== undefined && { composedOf: tool.composedOf }),
...(tool.gates !== undefined && { gates: tool.gates }),
...(tool.askOrAssume !== undefined && { askOrAssume: tool.askOrAssume }),
...(tool.period !== undefined && { period: tool.period }),
...(tool.search !== undefined && { search: tool.search }),
...(tool.script !== undefined && { script: tool.script }),
};
return Object.keys(extras).length > 0 ? extras : undefined;
}
exports.toolExtrasOf = toolExtrasOf;
/**
* Read an MCP `Tool._meta` bag into declarations a `Tool` can carry.
*
* **Never throws.** Everything here is somebody else's data:
*
* - no `_meta`, no `agentfootprint` key, or a non-object under it → `{}`,
* and the ingest is byte-identical to every release before this existed;
* - a field the receiving side does not know → 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);
* - a field this library DOES know but cannot honour → warned once and
* dropped, and the rest of the bag still lands.
*
* The rule each field is judged by is the exported assert `defineTool` itself
* calls, so a declaration that would have been refused at definition time is
* refused here too, with the same sentence.
*/
function readToolExtras(meta, origin) {
const bag = bagOf(meta);
if (bag === undefined)
return {};
const kept = (field, assert) => keepField(bag, field, origin, assert);
// One line per field, in the declaration order of `McpToolExtras` — a reader
// comparing the two lists should not have to sort. The casts inside each
// rule are the point of the exercise: the value is untyped until its own
// rule has agreed it is the shape the field claims.
const argumentsFrom = kept('argumentsFrom', (v) => (0, tools_js_1.assertArgumentsFrom)(origin.tool, v));
const resultKind = kept('resultKind', (v) => (0, tools_js_1.assertResultKind)(origin.tool, v));
const resultColumns = kept('resultColumns', (v) => (0, tools_js_1.assertResultColumns)(origin.tool, v));
const owner = kept('owner', (v) => (0, tools_js_1.assertToolOwner)(origin.tool, v));
const resultClass = kept('resultClass', (v) => (0, tools_js_1.assertResultClass)(origin.tool, v));
const resultCeiling = kept('resultCeiling', (v) => (0, tools_js_1.assertResultCeiling)(origin.tool, v));
const composedOf = kept('composedOf', (v) => (0, tools_js_1.assertComposedOf)(origin.tool, v));
const gates = kept('gates', (v) => (0, tools_js_1.assertGates)(origin.tool, v));
// The inputs layer's two, judged TOGETHER in order: the rules against the
// listed schema, then the period against the rules that were KEPT — a period
// naming an argument whose rule was dropped is dropped too (a period never
// arms anything alone).
const schema = origin.inputSchema ?? { type: 'object', properties: {} };
const askOrAssume = kept('askOrAssume', (v) => (0, declare_js_1.assertAskOrAssume)(origin.tool, v, undefined, schema));
const period = kept('period', (v) => (0, declare_js_1.assertAskOrAssume)(origin.tool, askOrAssume, v, schema));
// The search, judged against the listed schema and the period that was KEPT.
const periodArguments = (0, declaration_js_1.periodArgumentsOf)(period);
const search = kept('search', (v) => (0, declaration_js_1.assertToolSearch)(origin.tool, v, {
inputSchema: schema,
...(periodArguments !== undefined && { periodArguments }),
}));
// The program's argument, judged against the listed schema — the same rule
// `defineTool` runs (`tools/script.ts`), stored as a frozen copy.
const script = kept('script', (v) => (0, script_js_1.assertToolScript)(origin.tool, v, schema));
return {
...(argumentsFrom !== undefined && { argumentsFrom }),
...(resultKind !== undefined && { resultKind }),
...(resultColumns !== undefined && { resultColumns }),
...(owner !== undefined && { owner }),
...(resultClass !== undefined && { resultClass }),
...(resultCeiling !== undefined && { resultCeiling }),
...(composedOf !== undefined && { composedOf }),
...(gates !== undefined && { gates }),
...(askOrAssume !== undefined && { askOrAssume }),
...(period !== undefined && { period }),
// Stored frozen, its value words a null-prototype map — a remote server's
// words never index anything through an inherited key.
...(search !== undefined && { search: (0, declaration_js_1.freezeSearch)(search) }),
...(script !== undefined && { script: (0, script_js_1.copyToolScript)(script) }),
};
}
exports.readToolExtras = readToolExtras;
// ─── The reading, field by field ───────────────────────────────────
/** The bag under our key, or `undefined` when there is nothing to read. */
function bagOf(meta) {
if (typeof meta !== 'object' || meta === null)
return undefined;
const bag = meta[exports.MCP_TOOL_EXTRAS_KEY];
if (typeof bag !== 'object' || bag === null || Array.isArray(bag))
return undefined;
return bag;
}
/**
* Judge ONE field and answer with the value to keep, or `undefined` to drop.
*
* The rule is passed IN rather than looked up here, so this function owns the
* never-throw discipline and nothing else: every rule lives in `tools/tools.ts`
* where `defineTool` reads it, and there is no second copy of any of them in
* this file.
*/
function keepField(bag, field, origin, assert) {
// `in` rather than a truthiness test: a server that sent the key holding a
// `null` said something wrong and should hear about it, while a server that
// never sent the key said nothing and should not.
if (!(field in bag))
return undefined;
const value = bag[field];
if (value === undefined)
return undefined;
try {
assert(value);
}
catch (error) {
warnOnce(origin, field, error instanceof Error ? error.message : String(error));
return undefined;
}
return value;
}
// ─── The warning ───────────────────────────────────────────────────
/**
* Warn-once ledger, keyed by server + tool + field.
*
* ONCE because `.refresh()` re-lists the whole catalogue and an agent that
* refreshes every minute would otherwise print the same complaint every
* minute. Bounded because the key contains a remote server's tool names, and
* an unbounded Set fed by a remote party is a memory leak with a hostname
* attached — past the cap the warning simply repeats, which is the harmless
* failure mode.
*/
const warned = new Set();
const MAX_WARNED = 500;
/**
* NOT gated on `isDevMode()`, unlike the tool-NAME warning next door.
*
* A dropped declaration is not a style note: it is a check that will not arm,
* a placed result that will not be spendable, an identity join with no
* subject — and the symptom is silence, which looks exactly like a rail that
* ran and agreed. The precedent is `toolArgsValidation`'s uncompilable
* `pattern`: an ingested declaration this library cannot honour is reported
* unconditionally, once, and then ignored.
*/
function warnOnce(origin, field, rule) {
const key = `${origin.server}