UNPKG

agentfootprint

Version:

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

410 lines • 18.7 kB
"use strict"; /** * requestLedger — which dispatched tool call an MCP request belongs to, so the * retry series that sends it files its attempts into THAT call's run; when the * MCP SDK cancels a request, so the series sending it stops; and whether the * request's JSON-RPC answer arrived. * * Pattern: a per-connection ledger + the SDK transport's `send`, watched in place. * Role: The middle of the evidence seam (`tools/requestEvidence.ts` is its * door). The retry series runs inside the transport's `fetch`, which * the MCP SDK calls with a serialized body and nothing else — no tool * call, no run. Three hops carry the call down to it: * * 1. call → marker `issue`: the MCP tool gets a library-private marker and * puts it in its request's `_meta` (`callMarker.ts`). It * survives an SDK that copies the params. * 2. marker → id the watched `send` takes the marker out of the message * — the server receives the bytes it always did — and * keys the call by the JSON-RPC `id` the SDK assigned. * 3. id → series `claim`: the series reads the `id` back out of the body * it is about to send. * * CANCELLATION DOES NOT WAIT ON ATTRIBUTION. Every `tools/call` in flight gets * an entry, matched or not, and the SDK says when it gives a request up: it * sends `notifications/cancelled` naming the request's id — on its own * timeout, or when the call's signal fires — through this same `send`. The * ledger aborts that request's series there and then, so a request nobody waits * for is never asked again, even when it could not be matched to a call. * * Under `'app-authorized'` (`strict`), two more things hold: * - A `tools/call` that reaches `send` without a marker this ledger issued * and has not used is REFUSED there (`replayUnavailable.ts`, * `'request-unmatched'`): its answer could not be tied to any call, so it * is never sent rather than guessed at. * - Each matched request's answer is AWAITED, by its id: the connection's * incoming messages are observed for the JSON-RPC response to it. A 202, or * a stream, is not that answer. The watch ends when the answer arrives, * when the SDK cancels the request, when the call settles (found by its * marker, never by its params object) or when the connection closes — * never when only the HTTP send finished, since a 202 or a stream can still * carry the answer later. * * Whether the answers are observed at all is established once, at * initialization: the `initialize` answer must pass the observer * (`answersObserved`). An SDK whose `connect` replaces the transport's * `onmessage` instead of keeping it leaves the observer out, and a call under * `'app-authorized'` is then refused before it is sent (`replayUnavailable.ts`). * * Ids are the client's, one ledger per connection, so concurrent calls — and * concurrent runs sharing one client — can never claim each other's request. A * request that is not a `tools/call` (a `tools/list`, a notification, a * response) claims nothing. * * `send` is installed as an OWN property of the transport instance — the same * object, its own `send` bound to it, so a transport class with `#private` * fields works as it always did. Assigning `send` again afterwards THROWS: a * send that bypassed the ledger would quietly lose every call's attempts. * * Each matched `tools/call` also gets ONE `RequestReplay` (`replay.ts`), the * facts its series, this ledger and its call share: the series' claim hands it * over by the request's id, the call reads it from the entry it was issued * (`IssuedCall`), which outlives the connection's close. The * series writes what HTTP showed; this ledger writes whether the JSON-RPC * answer arrived; the call writes how it settled. * * What it does NOT do: retry, wait, or decide anything. The series is the one * retry owner; this only tells it whose attempts it is making, and when the SDK * has stopped waiting for them. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.RequestLedger = void 0; const callMarker_js_1 = require("./callMarker.js"); const replay_js_1 = require("./replay.js"); const replayUnavailable_js_1 = require("./replayUnavailable.js"); /** The method whose requests a tool call issues markers for. */ const TOOLS_CALL = 'tools/call'; /** The notification the SDK sends when it gives up one of its own requests. */ const CANCELLED = 'notifications/cancelled'; /** The request whose answer proves the observer sees this connection's answers. */ const INITIALIZE = 'initialize'; const LENIENT = { server: 'mcp', strict: false }; /** Per connection: the calls issued and not yet settled, the requests being sent, the answers awaited. */ class RequestLedger { options; /** Every call issued and not yet settled — keyed by its marker. */ issued = new Map(); /** Every `tools/call` sent and not yet settled — keyed by its JSON-RPC id. */ sending = new Map(); /** Matched requests whose JSON-RPC answer is awaited (`strict` only) — by id. */ awaiting = new Map(); /** * A pooled call's guard by its request's id, until the call settles: the SDK's * `notifications/cancelled` for that request is work started for the call, and * is counted under it (`requestGuard.ts`). */ guards = new Map(); initializeId; initializeAnswered = false; markers = 0; calls = 0; unboundNamed = false; constructor(options = LENIENT) { this.options = options; } /** * A call is about to send its request: its entry, whose `marker` the call * puts in the request's `_meta` (`callMarker.ts` · `markParams`). `ticket` * is where its attempts are filed, when the call carries the run's evidence * port. */ issue(ticket, guard) { this.markers += 1; const call = { marker: `call-${this.markers}`, ...(ticket !== undefined && { ticket }), ...(guard !== undefined && { guard }), }; this.issued.set(call.marker, call); return call; } /** `call` settled: forget it, and stop awaiting its request's answer. */ settle(call) { if (this.issued.get(call.marker) === call) this.issued.delete(call.marker); const request = call.request; if (request !== undefined && this.guards.get(request.id) === call.guard) { this.guards.delete(request.id); } if (request !== undefined && this.awaiting.get(request.id) === request.replay) { this.awaiting.delete(request.id); } } /** The connection closed: nothing it sent is awaited, and no call is pending, any more. */ close() { this.issued.clear(); this.sending.clear(); this.awaiting.clear(); this.guards.clear(); } /** * Whether this connection's answers pass the observer: the `initialize` * answer did. Read once the connection is initialized. */ get answersObserved() { return this.initializeAnswered; } /** How many `tools/call` requests the watched transport has sent — matched to a call or not. */ get toolCallsSent() { return this.calls; } /** * @internal How many entries the ledger holds: calls issued and not settled, * requests being sent, answers awaited. Zero once every call settled. */ get retained() { return this.issued.size + this.sending.size + this.awaiting.size; } /** `true` the first time it is asked, then never again: an unmatched request is named once per connection. */ firstUnbound() { if (this.unboundNamed) return false; this.unboundNamed = true; return true; } /** * Watch `transport`'s `send`: install a wrapped `send` as its OWN property, the * original bound to the instance itself. Returns the SAME object — or * `undefined` when it has no `send` to watch or refuses the property, and * then nothing is watched and no call should expect its attempts filed. * * Its incoming messages are watched too: an `onmessage` set before the * client connects, which the current MCP SDK's `Protocol.connect` keeps and * calls before its own dispatch. Whether it was kept is known once the * `initialize` answer has, or has not, passed it (`answersObserved`). */ watch(transport) { const send = Reflect.get(transport, 'send'); if (typeof send !== 'function') return undefined; const original = send.bind(transport); const watched = (message, options) => this.send(original, message, options); try { Object.defineProperty(transport, 'send', { configurable: false, enumerable: false, get: () => watched, set: () => { throw new TypeError("mcpClient: this transport's `send` is watched by its request ledger, which " + 'matches each tools/call request to the call that sent it. Assigning `send` ' + 'again would send requests the ledger never sees, so it is refused.'); }, }); } catch { return undefined; } this.watchMessages(transport); return transport; } /** Observe incoming messages before whatever handled them already — never instead of it. */ watchMessages(transport) { try { const before = Reflect.get(transport, 'onmessage'); const handled = typeof before === 'function' ? before : undefined; const observing = (message, extra) => { this.observe(message); handled?.(message, extra); }; Reflect.set(transport, 'onmessage', observing); } catch { // A transport that refuses it is never observed: `answersObserved` stays false. } } /** One incoming message: the `initialize` answer, or the answer to an awaited request. */ observe(message) { const id = responseIdOf(message); if (id === undefined) return; if (id === this.initializeId) this.initializeAnswered = true; const replay = this.awaiting.get(id); if (replay === undefined) return; this.awaiting.delete(id); replay.answered(); } /** * The `tools/call` request `body` carries, while its send runs — its ticket * when a call issued one, and the signal its cancellation fires. `undefined` * for any other body. Parses only while some request is being sent. */ claim(body) { if (this.sending.size === 0 || typeof body !== 'string') return undefined; let message; try { message = JSON.parse(body); } catch { return undefined; } const id = requestIdOf(message); const entry = id === undefined ? undefined : this.sending.get(id); if (entry === undefined) return undefined; return { ...(entry.ticket !== undefined && { ticket: entry.ticket }), cancelled: entry.cut.signal, replay: entry.replay, ...(entry.guard !== undefined && { guard: entry.guard }), }; } /** One message through the watched transport. */ send(original, message, options) { this.cancelIfAsked(message); this.noteInitialize(message); const id = requestIdOf(message); if (id === undefined) return this.sendOther(original, message, options); // The marker comes out before anything else: the server never sees it. const taken = (0, callMarker_js_1.takeMarker)(message); const call = taken.marker === undefined ? undefined : this.issued.get(taken.marker); // One request per marker: a second message carrying it is not this call's. const matched = call !== undefined && call.request === undefined ? call : undefined; // A pooled call's send gate, asked here — the last boundary on a transport // whose `send` writes without waiting (stdio). Refused, nothing is sent and // the marker stays unused. On a pooled connection a tool call without a // guarded call of its own is the pool's to answer (`guardFor`), whatever // the replay policy. const guard = matched?.guard ?? this.options.guardFor?.(message); try { guard?.check(); } catch (refusal) { return Promise.reject(refusal); } if (matched === undefined && this.options.strict) { return Promise.reject(new replayUnavailable_js_1.McpReplayUnavailableError('request-unmatched', this.options.server, toolNameOf(message))); } this.calls += 1; const replay = new replay_js_1.RequestReplay(id, this.initializeAnswered); if (matched !== undefined) { matched.request = { id, replay }; if (this.options.strict) this.awaiting.set(id, replay); } const entry = { ...(matched?.ticket !== undefined && { ticket: matched.ticket }), cut: new AbortController(), replay, ...(guard !== undefined && { guard }), }; if (guard !== undefined) this.guards.set(id, guard); this.sending.set(id, entry); const release = () => { if (this.sending.get(id) === entry) this.sending.delete(id); }; try { const sent = Promise.resolve(original(taken.message, options)).finally(release); guard?.track(sent); return sent; } catch (err) { release(); throw err; } } /** * Any message but a `tools/call`: sent as it always was — unless a pool * guards it (`guardFor`), and then a REQUEST is checked first and refused * unsent, and every message's send is counted under the guard. * Notifications are never refused: a `notifications/cancelled` is the * courtesy that tells the server to stop. */ sendOther(original, message, options) { // A cancellation is counted under the call it cancels; anything else under // the pool's guard for the client's current work. const guard = this.cancelledGuard(message) ?? this.options.guardFor?.(message); if (guard === undefined) return original(message, options); if (isRequest(message)) { try { guard.check(); } catch (refusal) { return Promise.reject(refusal); } } const sent = Promise.resolve(original(message, options)); guard.track(sent); return sent; } /** The guard of the pooled call a `notifications/cancelled` names, if any. */ cancelledGuard(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return undefined; const { method, params } = message; if (method !== CANCELLED || params === null || typeof params !== 'object') return undefined; const requestId = params.requestId; if (typeof requestId !== 'string' && typeof requestId !== 'number') return undefined; return this.guards.get(requestId); } /** The `initialize` request: its answer is the one that proves the observer is in place. */ noteInitialize(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return; const { method, id } = message; if (method !== INITIALIZE || (typeof id !== 'string' && typeof id !== 'number')) return; this.initializeId = id; this.initializeAnswered = false; } /** The SDK gave a request up: stop the series still sending it, and stop awaiting its answer. */ cancelIfAsked(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return; const { method, params } = message; if (method !== CANCELLED || params === null || typeof params !== 'object') return; const requestId = params.requestId; if (typeof requestId !== 'string' && typeof requestId !== 'number') return; this.sending.get(requestId)?.cut.abort(sdkCancelled()); this.awaiting.delete(requestId); } } exports.RequestLedger = RequestLedger; /** The reason a series stops when the SDK cancelled its request — a fixed sentence. */ function sdkCancelled() { return Object.assign(new Error('The MCP SDK cancelled this request.'), { name: 'AbortError' }); } /** The tool a `tools/call` names — what a refusal names. Never its arguments. */ function toolNameOf(message) { const params = message.params; const name = params !== null && typeof params === 'object' ? params.name : undefined; return typeof name === 'string' ? name : 'tools/call'; } /** The id of a JSON-RPC RESPONSE — a result or an error, never a request or a notification. */ function responseIdOf(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return undefined; const fields = message; if (fields.method !== undefined || !('result' in fields || 'error' in fields)) return undefined; return typeof fields.id === 'string' || typeof fields.id === 'number' ? fields.id : undefined; } /** A JSON-RPC request — a method and an id. A notification or a response is not one. */ function isRequest(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return false; const { method, id } = message; return typeof method === 'string' && (typeof id === 'string' || typeof id === 'number'); } /** The id of a `tools/call` REQUEST — a response or a notification has none to claim. */ function requestIdOf(message) { if (message === null || typeof message !== 'object' || Array.isArray(message)) return undefined; const { method, id } = message; if (method !== TOOLS_CALL) return undefined; return typeof id === 'string' || typeof id === 'number' ? id : undefined; } //# sourceMappingURL=requestLedger.js.map