agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
406 lines • 18.5 kB
JavaScript
/**
* 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.
*/
import { takeMarker } from './callMarker.js';
import { RequestReplay } from './replay.js';
import { McpReplayUnavailableError } from './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. */
export 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 = 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 McpReplayUnavailableError('request-unmatched', this.options.server, toolNameOf(message)));
}
this.calls += 1;
const replay = new 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);
}
}
/** 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