UNPKG

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
"use strict"; /** * 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}${origin.tool}${field}`; if (warned.has(key)) return; if (warned.size < MAX_WARNED) warned.add(key); // eslint-disable-next-line no-console console.warn(`[agentfootprint] MCP server '${origin.server}' declared \`${field}\` on tool ` + `'${origin.tool}' in its _meta.${exports.MCP_TOOL_EXTRAS_KEY} bag, and this library cannot ` + `honour it — the field was DROPPED and the tool registered without it, so whatever ` + `that declaration would have armed stays unarmed. The rule it broke: ${rule}`); } /** * Forget every warning issued so far. * @internal test seam — the ledger is process-wide and warn-ONCE, so a test * proving the warning fires must be able to start from silence. */ function _resetToolExtrasWarnings() { warned.clear(); } exports._resetToolExtrasWarnings = _resetToolExtrasWarnings; //# sourceMappingURL=toolExtras.js.map