UNPKG

agentfootprint

Version:

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

215 lines • 11.2 kB
/** * 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 type { RequestEvidenceSink } from '../tools/requestEvidence.js'; import type { RequestGuard } from './requestGuard.js'; import { RequestReplay } from './replay.js'; /** One call's claim on the request it sends. */ export interface RequestTicket { /** Where the request's attempts are filed. */ readonly sink: RequestEvidenceSink; /** What cancels the call's series — the call's own signal, the client's, the call settling. */ readonly signal?: AbortSignal; } /** What a series claims for the `tools/call` request it sends. */ export interface ClaimedRequest { /** The ticket a dispatched call issued for this request — absent when none was matched. */ readonly ticket?: RequestTicket; /** Fires when the MCP SDK cancels this request (`notifications/cancelled`). */ readonly cancelled: AbortSignal; /** What this request's series and its call share (`replay.ts` · `RequestReplay`). */ readonly replay: RequestReplay; /** * The send gate of the pooled call this request belongs to (`requestGuard.ts`) * — absent for a call no pool made. */ readonly guard?: RequestGuard; } /** How a connection's ledger is set up. */ export interface RequestLedgerOptions { /** The client's name, which a refusal names. */ readonly server: string; /** The `'app-authorized'` policy is in force: see the module note. */ readonly strict: boolean; /** * A pooled client's guard for every message that does not carry its own — its * open, its listing, its ending (`mcp/pool/`) — and for a `tools/call` that * reached `send` without a call carrying a guard: the pool answers that one * with a guard that refuses it, so a pooled request is never sent unchecked, * under either replay policy. A request it guards is checked before it is * sent, and every message's send is counted under it. Absent for a client no * pool built. */ readonly guardFor?: (message: unknown) => RequestGuard | undefined; } type JsonRpcId = string | number; /** The request a call's marker was matched to. */ export interface MatchedRequest { readonly id: JsonRpcId; readonly replay: RequestReplay; } /** * One call, from `issue` until it settles: the marker it sends, and the * request matched to it once one was. The call holds this entry itself, so * what it reads does not depend on the ledger still holding it. */ export interface IssuedCall { readonly marker: string; readonly ticket?: RequestTicket; /** The pooled call's send gate, when a pool made the call (`requestGuard.ts`). */ readonly guard?: RequestGuard; /** Set once, by the watched `send`: a marker matches ONE request. */ readonly request?: MatchedRequest; } /** Per connection: the calls issued and not yet settled, the requests being sent, the answers awaited. */ export declare class RequestLedger { private readonly options; /** Every call issued and not yet settled — keyed by its marker. */ private readonly issued; /** Every `tools/call` sent and not yet settled — keyed by its JSON-RPC id. */ private readonly sending; /** Matched requests whose JSON-RPC answer is awaited (`strict` only) — by id. */ private readonly awaiting; /** * 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`). */ private readonly guards; private initializeId; private initializeAnswered; private markers; private calls; private unboundNamed; constructor(options?: RequestLedgerOptions); /** * 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?: RequestTicket, guard?: RequestGuard): IssuedCall; /** `call` settled: forget it, and stop awaiting its request's answer. */ settle(call: IssuedCall): void; /** The connection closed: nothing it sent is awaited, and no call is pending, any more. */ close(): void; /** * Whether this connection's answers pass the observer: the `initialize` * answer did. Read once the connection is initialized. */ get answersObserved(): boolean; /** How many `tools/call` requests the watched transport has sent — matched to a call or not. */ get toolCallsSent(): number; /** * @internal How many entries the ledger holds: calls issued and not settled, * requests being sent, answers awaited. Zero once every call settled. */ get retained(): number; /** `true` the first time it is asked, then never again: an unmatched request is named once per connection. */ firstUnbound(): boolean; /** * 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<T extends object>(transport: T): T | undefined; /** Observe incoming messages before whatever handled them already — never instead of it. */ private watchMessages; /** One incoming message: the `initialize` answer, or the answer to an awaited request. */ private observe; /** * 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: unknown): ClaimedRequest | undefined; /** One message through the watched transport. */ private send; /** * 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. */ private sendOther; /** The guard of the pooled call a `notifications/cancelled` names, if any. */ private cancelledGuard; /** The `initialize` request: its answer is the one that proves the observer is in place. */ private noteInitialize; /** The SDK gave a request up: stop the series still sending it, and stop awaiting its answer. */ private cancelIfAsked; } export {}; //# sourceMappingURL=requestLedger.d.ts.map