UNPKG

agentfootprint

Version:

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

237 lines • 11.1 kB
/** * callChecks — a listing never makes a callable tool lose its checks. * * Pattern: the SDK's three per-tool call checks, carried by every tool the * library hands out, plus one typed refusal. * Role: The MCP SDK's `Client` runs three checks inside `callTool`, and it * keeps them only for the tools of its most recent `tools/list` * answer (1.32: `cacheToolMetadata` replaces them on every answer): * * 1. a tool with `execution.taskSupport: 'required'` is refused before a * plain `tools/call` is sent; * 2. a tool with an `outputSchema` must answer with `structuredContent`, * unless the answer is an error; * 3. that `structuredContent` must match the schema. * * So a tool that declares any of them carries them itself, as the listing it * came from declared them. The library leaves them to the SDK only for a tool * of a ONE-page list whose answer is still the latest, with no list request * out (`listingGenerations.ts`), both when the call starts and when its answer * comes in: the one-page normal case, which runs exactly as it always did. * Every other call runs the tool's own checks, at the SDK's moments — the first * before sending, the other two once the answer is in, before anything reads it * — and raises the SDK's error: its own `McpError` where this arm loaded the * SDK, otherwise an error with the same name, code and message. Those calls * are to a tool of a paged list, a tool whose list a later answer replaced (a * refresh that failed or succeeded; a reference held across it), and any call * made while a list is being read. * * All of this covers the lists the library reads (`tools()`, `refresh()`); a * listing it does not send — the SDK client's own automatic refresh, a * `listTools` call of the caller's — is not seen (`listingGenerations.ts`). * * The output checks need a JSON Schema validator equal to the client's: the * SDK's default (`AjvJsonSchemaValidator`, loaded with the SDK on the Node * loader arm), or the caller's `jsonSchemaValidator`. Where neither exists — an * `sdk` or `connection` arm given none — an answer under these rules is never * returned unchecked (`McpOutputUncheckedError`): * * - a call under the tool's own checks from the start, to a tool that * declares an `outputSchema`, is refused before it is sent (`sent: false`); * - a call that started under the SDK's checks is refused once its answer is * in, unread, when the library sent a `tools/list` request while the call * was out (`sent: true`): the SDK may have checked it against another list. * * A schema is compiled when a tool's own checks first need it, never before: * the one-page normal case compiles nothing twice. */ /** JSON-RPC's codes for the two errors the SDK raises here. */ const INVALID_REQUEST = -32600; const INVALID_PARAMS = -32602; /** * The kit for one connection, named `server`, whose list requests `listings` * counts (`listingsOf(connection)`, shared by every client over it). `given` is * the caller's `jsonSchemaValidator`; `loaded` holds what the library loaded * with the SDK on the Node loader arm — the SDK's default validator class and * its `McpError` — each `undefined` where it is not a constructor (another arm, * or an SDK without it). */ export function callCheckKit(server, listings, given, loaded = {}) { const Default = typeof loaded.Validator === 'function' ? loaded.Validator : undefined; let created; return { server, validator: () => { if (given !== undefined) return given; if (Default === undefined) return undefined; created ??= new Default(); return created; }, ...(typeof loaded.McpError === 'function' && { McpError: loaded.McpError }), listings, }; } /** * `value` as a validator, or `undefined` when it was not given. Refused by name * when it is something else, before anything connects. */ export function validatorOption(value, at) { if (value === undefined) return undefined; const getValidator = value !== null && (typeof value === 'object' || typeof value === 'function') ? value.getValidator : undefined; if (typeof getValidator === 'function') return value; throw new TypeError(`${at}: \`jsonSchemaValidator\` has no \`getValidator\` method. Pass a JSON Schema ` + "validator as the MCP SDK's `Client` takes one: `new AjvJsonSchemaValidator()` from " + "'@modelcontextprotocol/sdk/validation/ajv', or the one your client uses."); } /** * The checks `listed` carries — `undefined` when it declares none. `generation` * is the one answer its list was read in, when it was read in one * (`toolList.ts` · `ToolListing`); a paged list's tools have none, so the SDK * never holds all of them. */ export function checksFor(listed, kit, generation) { const schema = ownObject(listed, 'outputSchema'); const taskRequired = ownValue(ownObject(listed, 'execution'), 'taskSupport') === 'required'; if (schema === undefined && !taskRequired) return undefined; let compiled; return { server: kit.server, tool: listed.name, taskRequired, outputDeclared: schema !== undefined, heldBySdk: () => generation !== undefined && kit.listings.isLatest(generation), validate: () => { if (schema !== undefined) compiled ??= kit.validator()?.getValidator(schema); return compiled; }, fail: (code, message) => kit.McpError !== undefined ? new kit.McpError(code, message) : new McpErrorShape(code, message), }; } /** * True when this call's output cannot be checked here: it runs under the * tool's own checks, the tool declares an `outputSchema`, no validator is * reachable, and it is not refused as a required task anyway (that refusal is * the SDK's, and comes first). The call is then refused before it is sent. */ export function outputUnchecked(checks) { return checks.outputDeclared && !checks.taskRequired && checks.validate() === undefined; } /** The SDK's task refusal, word for word, before anything is sent. */ export function refuseRequiredTask(checks) { if (!checks.taskRequired) return; throw checks.fail(INVALID_REQUEST, `Tool "${checks.tool}" requires task-based execution. ` + 'Use client.experimental.tasks.callToolStream() instead.'); } /** * The SDK's two output checks, in its order and with its sentences, once the * answer is in and before anything reads it. An answer no validator here can * check is never returned: it is refused, unread, as sent. */ export function checkOutput(checks, result) { if (!checks.outputDeclared) return; const validate = checks.validate(); if (validate === undefined) throw new McpOutputUncheckedError(checks.server, checks.tool, true); const structured = ownValue(result, 'structuredContent'); if (!structured && !ownValue(result, 'isError')) { throw checks.fail(INVALID_REQUEST, `Tool ${checks.tool} has an output schema but did not return structured content`); } if (!structured) return; let mismatch; try { const verdict = validate(structured); if (!verdict.valid) { mismatch = `Structured content does not match the tool's output schema: ${verdict.errorMessage}`; } } catch (error) { throw checks.fail(INVALID_PARAMS, `Failed to validate structured content: ${error instanceof Error ? error.message : String(error)}`); } if (mismatch !== undefined) throw checks.fail(INVALID_PARAMS, mismatch); } /** * The SDK's `McpError`, for an arm that cannot reach the class itself (`sdk`, * `connection`): the same name, `code`, `data` and message, so a caller that * branches on those handles it as it handles the SDK's. */ class McpErrorShape extends Error { code; data; constructor(code, message) { super(`MCP error ${code}: ${message}`); this.code = code; this.data = undefined; this.name = 'McpError'; } } /** What the refusal says happened, refused before sending. */ const NOT_SENT = 'was not sent. The tool declares an outputSchema, the MCP SDK no longer holds the checks of ' + 'the tool list it came from (that list was paged, a later list replaced it, or a list is ' + 'being read)'; /** What the refusal says happened, refused once the answer was in. */ const SENT_UNREAD = 'was sent, and its answer is not returned. The tool declares an outputSchema, a tool list ' + 'was read while the call was out (so the MCP SDK may not have checked the answer against ' + "this tool's schema)"; /** * A tool call whose output this connection cannot check, refused. The tool * declares an `outputSchema`, the MCP SDK no longer holds the checks of the * tool list it came from, and this connection has no JSON Schema validator to * check it with — so its answer is never returned: * * - `sent: false` — refused before it was sent: nothing reached the server. * The tool came from a paged list, from a list a later answer replaced, or * the call was made while a list was being read. * - `sent: true` — the call was sent, and a `tools/list` request went out * before its answer came in: the SDK may have checked that answer against * the other list, so it is refused unread. The tool may have run. * * Pass `jsonSchemaValidator` — the validator your client uses — to `mcpClient`. * Names the server and the tool, never an argument or an answer. */ export class McpOutputUncheckedError extends Error { server; tool; sent; code = 'ERR_MCP_OUTPUT_UNCHECKED'; reason = 'output-unchecked'; constructor(server, tool, /** Whether the request left the client: `false` when the refusal came before it. */ sent) { super(`mcpClient[${server}]: the call to '${tool}' ${sent ? SENT_UNREAD : NOT_SENT}, and this ` + 'connection has no JSON Schema validator to check its output with. Pass ' + '`jsonSchemaValidator` to mcpClient: the validator your client uses.'); this.server = server; this.tool = tool; this.sent = sent; this.name = 'McpOutputUncheckedError'; } } /** An own data property, never an accessor's result; `undefined` otherwise. */ function ownValue(value, key) { if (value === null || typeof value !== 'object') return undefined; const descriptor = Object.getOwnPropertyDescriptor(value, key); return descriptor !== undefined && 'value' in descriptor ? descriptor.value : undefined; } /** An own data property that is a plain object (not an array); `undefined` otherwise. */ function ownObject(value, key) { const found = ownValue(value, key); return found !== null && typeof found === 'object' && !Array.isArray(found) ? found : undefined; } //# sourceMappingURL=callChecks.js.map