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.

284 lines • 13.5 kB
/** * `run` — execute a TypeScript snippet against the SDK in a sandbox. * * The snippet sees one binding, `r` (the Node SDK client, reached through the * chain proxy in `../sandbox-proxy.ts`), plus `console` and the ECMAScript * builtins. It has no filesystem, no `process`, no module loader, no timers, * and no network of its own (`../sandbox.ts`). Its chains replay on the host * against a client built with `surface: "sandbox"`, which refuses every * secret-returning or secret-consuming SDK method with `SECRET_REQUIRES_CLI` * before any request, so nothing secret can reach the result store. * * The result is the value, the logs, and the calls, all bounded and all * honest: the value is stored by items (`../value-paging.ts`: an array's * elements, the rows of an object's largest array, an object's entries, or a * string's lines) under `value_ref`; a value that does not fit inline carries * its leading whole items as `value_window`, with `shown` / `total` counting * items; logs likewise under `logs_ref` (50 inline); `calls[]` lists every * SDK chain the replay ran with its outcome, including on a timeout, because * those side effects happened. Every result names the active wallet. */ import { randomBytes } from "node:crypto"; import { z } from "zod"; import { isRun402Error } from "../../sdk/dist/index.js"; import { getActiveProfile } from "../config.js"; import { readWallet } from "../wallet.js"; import { storeResult } from "../result-store.js"; import { ChainHost, UnknownMember } from "../sandbox-proxy.js"; import { runInSandbox } from "../sandbox.js"; import { jsonBlock } from "../structured.js"; import { fitsInline, pageableItems, pageValue, VALUE_INLINE_LINES } from "../value-paging.js"; import { getSandboxSdk } from "../sdk.js"; /** At most this many runs execute at once on one server; the next is `RUN_BUSY`. */ export const RUN_MAX_CONCURRENT = 4; export const RUN_DEFAULT_TIMEOUT_SECONDS = 60; export const RUN_MAX_TIMEOUT_SECONDS = 300; /** Source size cap, in bytes. */ export const RUN_MAX_SOURCE_BYTES = 64 * 1024; /** A value whose pretty-printed JSON fits in this many lines is inline whole; a larger one is windowed by items within the same budget. */ export const RUN_VALUE_WINDOW_LINES = VALUE_INLINE_LINES; /** Console lines shown inline. */ export const RUN_LOG_WINDOW_LINES = 50; export const runSchema = { code: z .string() .min(1) .refine((s) => Buffer.byteLength(s, "utf8") <= RUN_MAX_SOURCE_BYTES, { message: "code is at most 64 KB" }) .describe("TypeScript or JavaScript, run as the body of an async function: top-level await works, and the value of an explicit return or of the last expression statement is the result. `r` is the Node SDK client (`@run402/sdk/node`): await any chain, e.g. `(await r.projects.list()).map(p => p.project_id)` or `await r.project(\"prj_…\").functions.list()`. Types are stripped, not compiled: no enum, no parameter properties. No filesystem, process, fetch, timers, or imports; `console` is captured."), timeout_seconds: z .number() .int() .min(1) .max(RUN_MAX_TIMEOUT_SECONDS) .optional() .describe(`Deadline for the whole run, ${RUN_DEFAULT_TIMEOUT_SECONDS} s by default and at most ${RUN_MAX_TIMEOUT_SECONDS}. An SDK call in flight at the deadline completes on its own; the result lists every call that completed.`), }; let active = 0; export async function handleRun(args) { const runId = `run_${randomBytes(6).toString("hex")}`; const started = Date.now(); const wallet = activeWallet(); if (active >= RUN_MAX_CONCURRENT) { const result = { ...emptyResult(wallet, started), status: "error", error: { code: "RUN_BUSY", message: `${RUN_MAX_CONCURRENT} runs are already in flight on this server.`, next_actions: [{ type: "retry", why: "Retry when one of the runs in flight finishes." }], }, }; logRun(runId, result); return render(result); } active++; const host = new ChainHost(getSandboxSdk()); try { const timeoutSeconds = Math.min(Math.max(1, Math.floor(args.timeout_seconds ?? RUN_DEFAULT_TIMEOUT_SECONDS)), RUN_MAX_TIMEOUT_SECONDS); const outcome = await runInSandbox({ code: args.code, timeoutMs: timeoutSeconds * 1000, extensions: [host.extension()] }); const result = { ...emptyResult(wallet, started), status: outcome.status, calls: [...host.calls], ...(host.callsDropped > 0 ? { calls_dropped: host.callsDropped } : {}), duration_ms: Date.now() - started, }; if (outcome.logs.length > 0) { const stored = storeResult("run_logs", outcome.logs, { shown: RUN_LOG_WINDOW_LINES }); result.logs = stored.items; result.logs_ref = stored.ref; extras(result).logsTotal = stored.total; } if (outcome.logs_dropped > 0) result.logs_dropped = outcome.logs_dropped; if (outcome.status === "ok") { if (outcome.value_kind === "undefined" || outcome.value_json === undefined) { result.value = null; result.value_kind = "undefined"; } else { const parsed = JSON.parse(outcome.value_json); if (fitsInline(parsed)) { const { items } = pageableItems(parsed); const stored = storeResult("run_value", items, { shown: items.length }); result.value = parsed; result.value_ref = stored.ref; result.shown = stored.total; result.total = stored.total; } else { // Paged by items, never by text lines: the window is whole rows. const paged = pageValue(parsed); const stored = storeResult("run_value", paged.items, { shown: paged.window.length }); result.value_window = { path: paged.path, items: paged.window, ...(paged.rest ? { rest: paged.rest } : {}) }; result.value_ref = stored.ref; result.shown = stored.shown; result.total = stored.total; } } } else if (outcome.error) { result.error = runError(outcome.error, host); } logRun(runId, result); return render(result); } catch (err) { const result = { ...emptyResult(wallet, started), status: "error", calls: [...host.calls], error: { code: "RUN_EXCEPTION", message: `The run could not complete: ${err instanceof Error ? err.message : String(err)}`, next_actions: [{ type: "edit_request", why: "Retry the snippet; if it fails the same way, simplify it." }], }, }; logRun(runId, result); return render(result); } finally { host.dispose(); active--; } } /** What rendering needs beside the envelope: the value's inline lines and the stored log count. */ const renderExtras = new WeakMap(); function extras(result) { let e = renderExtras.get(result); if (!e) { e = {}; renderExtras.set(result, e); } return e; } function emptyResult(wallet, started) { return { status: "ok", value_ref: null, shown: 0, total: 0, logs: [], logs_ref: null, calls: [], duration_ms: Date.now() - started, wallet, }; } /** The active wallet's local label and short address, read locally (no network). */ function activeWallet() { let local_label = "default"; try { local_label = getActiveProfile(); } catch { // An invalid wallet name in the environment; the SDK's own calls report it. } let address = null; try { const w = readWallet(); if (w?.address) address = `${w.address.slice(0, 6)}…${w.address.slice(-4)}`; } catch { // A malformed wallet file; the SDK's own calls report it. } return { local_label, address }; } const LOCAL_NEXT_ACTION = { RUN_SYNTAX_ERROR: "Fix the syntax at the line and column named. Types are stripped, not compiled: no enum, no parameter properties, no namespaces.", RUN_TIMEOUT: `Narrow the snippet or raise timeout_seconds (at most ${RUN_MAX_TIMEOUT_SECONDS}). The calls listed completed; their side effects happened, so do not repeat them blindly.`, RUN_MEMORY_EXCEEDED: "Process less at once: page through the SDK's lists, or keep only the fields you need.", RUN_VALUE_NOT_SERIALIZABLE: "Return JSON data: map handles and class instances to plain fields; a function, a BigInt, or a cycle is not JSON.", RUN_VALUE_TOO_LARGE: "Narrow the selection in the snippet (filter, map to the fields you need, slice) and return less.", RUN_EXCEPTION: "Fix the snippet; the message names the error and, when known, its line.", RUN_ARGUMENT_NOT_CLONEABLE: "Pass data to r, not functions, class instances, or un-awaited r calls; await the call first and pass its result.", RUN_UNKNOWN_MEMBER: "Use one of the members named in did_you_mean, or call docs with the namespace as topic for the full list.", }; function runError(error, host) { const thrown = error.thrown; // An SDK error thrown during a replay: report it exactly as the SDK shaped it. const original = host.errorFor(thrown?.error_ref); if (original && isRun402Error(original)) { const e = original; return { code: e.code ?? "RUN402_ERROR", message: e.message, next_actions: Array.isArray(e.nextActions) ? e.nextActions : [], }; } // A path the SDK does not have: name the closest members. if (original instanceof UnknownMember) { return { code: "RUN_UNKNOWN_MEMBER", message: original.message, next_actions: [{ type: "edit_request", why: LOCAL_NEXT_ACTION.RUN_UNKNOWN_MEMBER, path: original.path, did_you_mean: original.suggestions, }], }; } let code = error.code; if (thrown?.code === "RUN_ARGUMENT_NOT_CLONEABLE" || thrown?.code === "RUN_VALUE_TOO_LARGE") code = thrown.code; const message = code === error.code ? error.message : thrown?.message ?? error.message; const next = { type: "edit_request", why: LOCAL_NEXT_ACTION[code] }; if (error.line !== undefined) next.line = error.line; if (error.column !== undefined) next.column = error.column; return { code, message, next_actions: [next], ...(error.line !== undefined ? { line: error.line } : {}), ...(error.column !== undefined ? { column: error.column } : {}), }; } /** One stderr line per run: id, duration, call count, outcome. Never the source, arguments, or values. */ function logRun(runId, result) { const outcome = result.status === "ok" ? "ok" : result.error?.code ?? "error"; console.error(`run402-mcp run ${runId} duration_ms=${result.duration_ms} calls=${result.calls.length} outcome=${outcome}`); } function render(result) { const lines = []; const wallet = `${result.wallet.local_label}${result.wallet.address ? ` (${result.wallet.address})` : ""}`; const calls = `${result.calls.length} call${result.calls.length === 1 ? "" : "s"}`; if (result.status === "ok") { lines.push(`## run: ok — ${calls}, ${result.duration_ms} ms, wallet ${wallet}`); } else { lines.push(`## run: ${result.error?.code ?? "error"} — ${calls}, ${result.duration_ms} ms, wallet ${wallet}`); lines.push("", result.error?.message ?? ""); } const { logsTotal } = renderExtras.get(result) ?? {}; if (result.value_window) { lines.push("", `value — items 1–${result.shown} of ${result.total} at ${result.value_window.path} are in value_window below. The whole value is stored: expand_result with ref ${result.value_ref} pages the rest by item (offset ${result.shown}).`); } if (logsTotal !== undefined && result.logs.length < logsTotal) { lines.push("", `logs — ${result.logs.length} of ${logsTotal} lines shown below; expand_result with ref ${result.logs_ref} pages the rest (offset ${result.logs.length}).`); } // The fenced JSON is the structured object itself: one value, two views. const structured = inContractOrder(result); lines.push("", jsonBlock(structured)); return { content: [{ type: "text", text: lines.join("\n") }], structuredContent: structured, ...(result.status === "error" ? { isError: true } : {}), }; } /** The envelope's fields in the Frozen Contract's order, absent ones left out. */ function inContractOrder(result) { const order = [ "status", "value", "value_kind", "value_window", "value_ref", "shown", "total", "logs", "logs_ref", "logs_dropped", "calls", "calls_dropped", "duration_ms", "wallet", "error", ]; const out = {}; for (const key of order) if (result[key] !== undefined) out[key] = result[key]; return out; } //# sourceMappingURL=run.js.map