UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

133 lines • 6.09 kB
/** * The bounded result store for the LOSSY surface. * * MCP is the lossy surface (docs/agent-response-design.md): a tool result is * text in an agent's context window, so a listing that would be fine as JSON * over HTTP is a context bomb here. The rule that follows is "truncate the * VIEW, never the DATA": * * 1. the FULL result is persisted under an opaque `ref`, * 2. the tool returns a bounded window plus `shown` / `total` — so the agent * is TOLD it is looking at a slice, never left to infer it, and * 3. `expand_result` is the affordance that fetches the rest. * * A tool that renders `items.slice(0, 20)` and says nothing is the failure this * module exists to prevent: the agent reads twenty rows, concludes there are * twenty, and acts on a number that is wrong. * * BOUNDS. The store is in-process (it dies with the server, which is correct — * a ref is a handle on THIS session's answer, not a cache) and doubly bounded: * at most {@link RESULT_STORE_MAX_ENTRIES} results are retained, oldest evicted * first, and every entry expires {@link RESULT_STORE_TTL_MS} after it was * stored. A long-lived agent host cannot grow this without limit. TTL runs from * the STORE time and is not refreshed by reads, so a ref cannot be kept alive * forever by polling it. * * SECRET-BEARING RESULTS ARE NEVER STORED. `secret: true` returns the bounded * view with `ref: null` and writes nothing — enforced here, in code, not by * convention at the call sites. Per docs/agent-response-design.md a * secret-bearing response is never cached, never persisted into an * agent-surface result store, and never logged; a one-shot credential (a * maintenance lease's `holder_token`, a freshly minted key) that landed in a * retained buffer would be readable for the next half hour by anything that * could name its ref. */ import { randomBytes } from "node:crypto"; /** How many results are retained at once. Oldest is evicted first. */ export const RESULT_STORE_MAX_ENTRIES = 32; /** How long a stored result stays addressable, measured from the store time. */ export const RESULT_STORE_TTL_MS = 30 * 60 * 1000; /** The default window a tool shows inline before pointing at `expand_result`. */ export const RESULT_STORE_DEFAULT_SHOWN = 20; /** `expand_result`'s default page size when the caller does not ask for one. */ export const RESULT_STORE_DEFAULT_EXPAND_LIMIT = 100; /** The ceiling on one `expand_result` window — the surface stays bounded even when expanded. */ export const RESULT_STORE_MAX_EXPAND_LIMIT = 1000; /** Insertion-ordered, so the first key is always the oldest entry. */ const entries = new Map(); let now = () => Date.now(); /** * Persist the full result and return a bounded view of it. * * @param kind What produced this, e.g. `vault_heads`. Echoed by `expand_result`. * @param items The COMPLETE list. Never pre-truncate before calling this. * @param opts.shown Window size. Defaults to {@link RESULT_STORE_DEFAULT_SHOWN}. * @param opts.secret `true` ⇒ store NOTHING and return `ref: null`. See the module header. */ export function storeResult(kind, items, opts = {}) { const shown = clampShown(opts.shown); const view = items.slice(0, shown); // The hard rule, enforced before anything is written: a secret-bearing result // is never retained, so it never has a ref that could fetch it back. if (opts.secret === true) { return { ref: null, shown: view.length, total: items.length, items: view }; } pruneExpired(); while (entries.size >= RESULT_STORE_MAX_ENTRIES) { const oldest = entries.keys().next(); if (oldest.done) break; entries.delete(oldest.value); } const ref = newRef(); entries.set(ref, { kind, items: [...items], stored_at: now() }); return { ref, shown: view.length, total: items.length, items: view }; } /** * Read a window of a stored result. * * Returns `null` when the ref is unknown, already evicted, or expired — the * three are deliberately indistinguishable, because the caller's recovery is * the same in all three cases: re-run the tool that produced it. */ export function expandResult(ref, opts = {}) { pruneExpired(); const entry = entries.get(ref); if (!entry) return null; const offset = clampOffset(opts.offset, entry.items.length); const limit = clampExpandLimit(opts.limit); const items = entry.items.slice(offset, offset + limit); return { ref, kind: entry.kind, offset, shown: items.length, total: entry.items.length, items }; } /** Drop every retained result. Test-only, and the server's own reset if it ever needs one. */ export function _resetResultStore() { entries.clear(); } /** Override the clock so TTL behaviour is testable without sleeping. Test-only; `null` restores it. */ export function _setResultStoreClock(clock) { now = clock ?? (() => Date.now()); } /** How many results are currently retained. Test-only introspection. */ export function _resultStoreSize() { pruneExpired(); return entries.size; } function pruneExpired() { const cutoff = now() - RESULT_STORE_TTL_MS; for (const [ref, entry] of entries) { // Insertion order is store order, so the first non-expired entry ends it. if (entry.stored_at > cutoff) break; entries.delete(ref); } } function newRef() { return `res_${randomBytes(8).toString("hex")}`; } function clampShown(shown) { if (shown === undefined || !Number.isFinite(shown)) return RESULT_STORE_DEFAULT_SHOWN; return Math.max(0, Math.floor(shown)); } function clampOffset(offset, total) { if (offset === undefined || !Number.isFinite(offset)) return 0; return Math.min(Math.max(0, Math.floor(offset)), Math.max(0, total)); } function clampExpandLimit(limit) { if (limit === undefined || !Number.isFinite(limit)) return RESULT_STORE_DEFAULT_EXPAND_LIMIT; return Math.min(Math.max(1, Math.floor(limit)), RESULT_STORE_MAX_EXPAND_LIMIT); } //# sourceMappingURL=result-store.js.map