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.
364 lines • 15.8 kB
JavaScript
/**
* Request kernel — the one place in the SDK that touches `fetch`.
*
* Isomorphic: uses only platform globals, no Node-only APIs. The kernel is
* safe to execute inside a V8 isolate where `fs`, `child_process`, and
* `process` are absent.
*
* Failure translation: maps HTTP status codes and network errors to the
* appropriate {@link Run402Error} subclass. Callers never see `undefined`
* or a response-shaped error value — they either get the parsed body as T
* or an exception.
*/
import { ApiError, NetworkError, NotAuthorizedError, WriteApprovalRequiredError, PaymentRequired, SecretRequiresCliError, StepUpRequiredError, TransferFreezeError, Unauthorized, isRun402Error, } from "./errors.js";
/** Gateway codes that mean "a passkey write approval is needed for this (capability, target)". */
const WRITE_APPROVAL_CODES = new Set([
"WRITE_APPROVAL_REQUIRED",
"WRITE_APPROVAL_SCOPE_MISMATCH",
"WRITE_APPROVAL_BINDING_MISMATCH",
"WRITE_APPROVAL_SESSION_INVALID",
]);
/** Capabilities a client has when the caller names none. */
export const DEFAULT_CLIENT_CAPABILITIES = Object.freeze({ returnSecrets: true });
/** Resolve a partial capability set against the defaults. */
export function resolveClientCapabilities(partial) {
return {
returnSecrets: typeof partial?.returnSecrets === "boolean" ? partial.returnSecrets : DEFAULT_CLIENT_CAPABILITIES.returnSecrets,
};
}
function emptyClientStats() {
return { round_trips: 0, wire_ms: 0, bytes_up: 0, bytes_down: 0 };
}
function recordClientStats(stats, delta) {
if (!stats)
return;
stats.round_trips += 1;
stats.wire_ms += Math.round(delta.ms);
stats.bytes_up += delta.up;
stats.bytes_down += delta.down;
}
/** Monotonic clock, isomorphic-safe (`performance.now()` when present, else `Date.now()`). */
function nowMs() {
try {
if (typeof performance !== "undefined" && typeof performance.now === "function")
return performance.now();
}
catch {
// fall through to Date.now()
}
return Date.now();
}
/** Exact byte length of a request/response body payload. */
function byteLength(value) {
if (value === undefined)
return 0;
if (typeof value === "string") {
try {
return new TextEncoder().encode(value).length;
}
catch {
return value.length;
}
}
return value.byteLength;
}
/**
* `RUN402_TRACE`: any non-empty value enables one stderr line per request.
* Feature-detects `process`/`process.env` so this isomorphic file never
* throws in a runtime (browser, V8 isolate) that lacks either.
*/
function traceEnabled() {
try {
return typeof process !== "undefined" && typeof process.env === "object" && process.env !== null &&
typeof process.env.RUN402_TRACE === "string" && process.env.RUN402_TRACE.length > 0;
}
catch {
return false;
}
}
/**
* `r402 <METHOD> <path> -> <status> <ms>ms attempt=<n>` on stderr — path
* WITHOUT its query string, never headers, never bodies, never tokens (same
* redaction posture as the Node payment-attempt journal). `status` is `ERR`
* for a request that never got a response (network failure).
*/
function traceLine(method, path, status, ms, attempt) {
if (!traceEnabled())
return;
const pathOnly = path.split("?")[0];
const line = `r402 ${method} ${pathOnly} -> ${status} ${Math.round(ms)}ms attempt=${attempt}\n`;
try {
if (typeof process !== "undefined" && process.stderr && typeof process.stderr.write === "function") {
process.stderr.write(line);
return;
}
}
catch {
// fall through to console.error
}
try {
if (typeof console !== "undefined" && typeof console.error === "function")
console.error(line.trimEnd());
}
catch {
// tracing must never break a request
}
}
/**
* Decode the `PAYMENT-RESPONSE` receipt if the response carries one.
*
* Deliberately total: a malformed or unexpected receipt yields `null` rather
* than throwing. A caller asking "what did I just pay?" must never have its
* SUCCESSFUL response turned into an error by a reporting concern.
*
* Strict receipt VALIDATION — matching the receipt against the challenge the
* buyer accepted — belongs to the paid-fetch buyer path, which does it. This is
* disclosure of what the seller reported, and is labelled as such.
*/
export function decodeSettlementReceipt(res) {
const header = res.headers.get("PAYMENT-RESPONSE") ?? res.headers.get("X-PAYMENT-RESPONSE");
if (!header)
return null;
try {
const json = typeof atob === "function" ? atob(header) : Buffer.from(header, "base64").toString("utf8");
const raw = JSON.parse(json);
if (typeof raw.network !== "string" || typeof raw.transaction !== "string")
return null;
return {
success: raw.success === true,
network: raw.network,
transaction: raw.transaction,
payer: typeof raw.payer === "string" ? raw.payer : null,
};
}
catch {
return null;
}
}
export async function request(kernel, path, opts) {
return (await requestWithResponse(kernel, path, opts)).body;
}
/**
* Auth header families. If a request explicitly sets ANY of these, it owns its
* credentials and the kernel will not merge a provider auth header alongside —
* preventing duplicate/contradictory credentials once dual-header auth exists.
*/
const AUTH_HEADER_NAMES = ["authorization", "sign-in-with-x", "x-run402-write-approval"];
/** Case-insensitive header presence check. */
function hasHeader(headers, name) {
const lower = name.toLowerCase();
for (const k of Object.keys(headers)) {
if (k.toLowerCase() === lower)
return true;
}
return false;
}
export async function requestWithResponse(kernel, path, opts) {
const { apiBase, fetch, credentials } = kernel;
const { method = "GET", headers = {}, body, rawBody, withAuth = true, context, attempt = 1 } = opts;
const url = `${apiBase}${path}`;
const fetchHeaders = { ...headers };
for (const [k, v] of Object.entries(clientMetadataHeaders(kernel.clientMetadata))) {
if (!hasHeader(fetchHeaders, k))
fetchHeaders[k] = v;
}
if (withAuth) {
const auth = await credentials.getAuth(path, opts.authMeta);
if (auth) {
// Credential-family atomicity: if the request already set any auth header
// (any casing), it owns its credentials — never merge provider auth over
// or beside it. Other provider headers still merge (case-insensitively).
const requestOwnsAuth = AUTH_HEADER_NAMES.some((h) => hasHeader(fetchHeaders, h));
for (const [k, v] of Object.entries(auth)) {
if (hasHeader(fetchHeaders, k))
continue;
if (requestOwnsAuth && AUTH_HEADER_NAMES.includes(k.toLowerCase()))
continue;
fetchHeaders[k] = v;
}
}
}
let fetchBody;
if (rawBody !== undefined) {
fetchBody = rawBody;
}
else if (body !== undefined) {
if (!("Content-Type" in fetchHeaders) && !("content-type" in fetchHeaders)) {
fetchHeaders["Content-Type"] = "application/json";
}
fetchBody = JSON.stringify(body);
}
const upBytes = byteLength(fetchBody);
const startedAt = nowMs();
let res;
try {
res = await fetch(url, {
method,
headers: fetchHeaders,
body: fetchBody,
});
}
catch (err) {
const elapsed = nowMs() - startedAt;
recordClientStats(kernel.stats, { ms: elapsed, up: upBytes, down: 0 });
traceLine(method, path, "ERR", elapsed, attempt);
// The kernel's `fetch` is injectable, and the paid clients inject an x402
// payment fetch. That fetch throws DOMAIN errors, not just transport ones —
// a confirmed balance miss (`X402_INSUFFICIENT_FUNDS`) surfaces here exactly
// like a dead socket would. Those errors arrive fully classified
// (`category: "payment_required"`, `retryable: false`, a `fund_wallet`
// next_action, the actual balances and requirements in `details`).
//
// Blanket-wrapping them as NetworkError destroyed all of it: a buyer whose
// wallet ran dry was told `NETWORK_ERROR` / `retryable: true` and given no
// remedy, so a retrying agent would loop forever on a condition that only
// funding can clear.
//
// Anything already carrying the Run402 brand is a classified SDK error and
// is strictly more informative than the wrapper — pass it through untouched.
// Genuine transport faults are unbranded and still become NetworkError.
if (isRun402Error(err))
throw err;
throw new NetworkError(`Network error while ${context}: ${err.message}`, err, context);
}
const ct = res.headers.get("content-type") ?? "";
let resBody;
if (ct.includes("application/json")) {
resBody = await res.json().catch(() => null);
}
else {
resBody = await res.text();
}
const elapsed = nowMs() - startedAt;
const downBytes = measureDownBytes(res, resBody, ct);
recordClientStats(kernel.stats, { ms: elapsed, up: upBytes, down: downBytes });
traceLine(method, path, res.status, elapsed, attempt);
if (res.ok) {
// OMIT the key entirely when no payment settled, rather than setting null.
// `restResponse` hands this envelope straight to CLI/MCP shims, and callers
// compare it by exact shape — always adding the key silently widened a
// public surface for every consumer to serve a field only paid responses
// use. Caught by an existing deepEqual test, which was right to fail.
const settlement = decodeSettlementReceipt(res);
return settlement
? { status: res.status, body: resBody, settlement }
: { status: res.status, body: resBody };
}
if (res.status === 402) {
throw new PaymentRequired(`${displayMessage(resBody, "Payment required")} while ${context}`, 402, resBody, context);
}
if (res.status === 403 && envelopeCode(resBody) === "STEP_UP_REQUIRED") {
throw new StepUpRequiredError(`${displayMessage(resBody, "Step-up authentication required")} while ${context}`, res.status, resBody, context);
}
if (res.status === 403 && envelopeCode(resBody) === "NOT_AUTHORIZED") {
// Org-owned control-plane denial (gateway v1.77+): authenticated but lacks
// the required org membership/role or per-project grant. Distinct from a
// generic 401/403 so callers can prompt for access, not re-authentication.
throw new NotAuthorizedError(`${displayMessage(resBody, "Not authorized")} while ${context} (HTTP ${res.status})`, res.status, resBody, context);
}
if ((res.status === 401 || res.status === 403) && WRITE_APPROVAL_CODES.has(envelopeCode(resBody) ?? "")) {
throw new WriteApprovalRequiredError(`${displayMessage(resBody, "Write approval required")} while ${context}`, res.status, resBody, context, { capability: opts.authMeta?.capability ?? null, target: opts.authMeta?.target ?? null });
}
if (res.status === 401 || res.status === 403) {
throw new Unauthorized(`${displayMessage(resBody, "Unauthorized")} while ${context} (HTTP ${res.status})`, res.status, resBody, context);
}
if (res.status === 409 && envelopeCode(resBody) === "PROJECT_HAS_PENDING_TRANSFER") {
throw new TransferFreezeError(`${displayMessage(resBody, "Project has a pending transfer")} while ${context}`, res.status, resBody, context);
}
throw new ApiError(`${displayMessage(resBody, "API error")} while ${context} (HTTP ${res.status})`, res.status, resBody, context);
}
function displayMessage(body, fallback) {
if (body && typeof body === "object" && !Array.isArray(body)) {
const obj = body;
if (typeof obj.message === "string" && obj.message.length > 0)
return obj.message;
if (typeof obj.error === "string" && obj.error.length > 0)
return obj.error;
}
return fallback;
}
/** Response body bytes: `Content-Length` when present and well-formed, else measured from the parsed body. */
function measureDownBytes(res, resBody, ct) {
const headerLen = res.headers.get("content-length");
if (headerLen !== null) {
const n = Number(headerLen);
if (Number.isFinite(n) && n >= 0)
return n;
}
try {
if (typeof resBody === "string")
return byteLength(resBody);
if (ct.includes("application/json") && resBody !== null && resBody !== undefined) {
return byteLength(JSON.stringify(resBody));
}
}
catch {
// best-effort measurement only
}
return 0;
}
function envelopeCode(body) {
if (!body || typeof body !== "object" || Array.isArray(body))
return null;
const code = body.code;
return typeof code === "string" ? code : null;
}
export function buildClient(kernel) {
// One stats accumulator per client: every request this `Run402` instance
// makes (directly or through a namespace) mutates the SAME object, because
// every closure below captures `kernelWithStats`, not the caller's `kernel`.
const stats = kernel.stats ?? emptyClientStats();
const kernelWithStats = kernel.stats === stats ? kernel : { ...kernel, stats };
const getProjectCredentials = (id) => kernel.credentials.getProjectCredentials
? kernel.credentials.getProjectCredentials(id)
// eslint-disable-next-line @typescript-eslint/no-deprecated -- older custom providers implement only getProject
: kernel.credentials.getProject?.(id) ?? Promise.resolve(null);
const capabilities = Object.freeze(resolveClientCapabilities(kernel.capabilities));
return {
apiBase: kernel.apiBase,
capabilities,
assertSecretReturn: ({ command, why }) => {
if (!capabilities.returnSecrets)
throw new SecretRequiresCliError(command, why);
},
request: (path, opts) => request(kernelWithStats, path, opts),
requestWithResponse: (path, opts) => requestWithResponse(kernelWithStats, path, opts),
getProjectCredentials,
getProject: getProjectCredentials,
credentials: kernel.credentials,
fetch: kernel.fetch,
stats: () => ({ ...stats }),
};
}
export function clientMetadataHeaders(metadata) {
if (!metadata || typeof metadata !== "object")
return {};
const parts = [];
const surface = sanitizeMetadataToken(metadata.surface, 40);
const version = sanitizeMetadataToken(metadata.version, 64);
const sdk = sanitizeMetadataToken(metadata.sdkVersion, 64);
if (surface)
parts.push(`surface=${quoteStructuredValue(surface)}`);
if (version)
parts.push(`version=${quoteStructuredValue(version)}`);
if (sdk)
parts.push(`sdk=${quoteStructuredValue(sdk)}`);
const value = parts.join(", ");
if (!value || value.length > 200)
return {};
return { "Run402-Client": value };
}
function sanitizeMetadataToken(value, maxLength) {
if (typeof value !== "string")
return null;
const trimmed = value.trim();
if (!trimmed || trimmed.length > maxLength)
return null;
if (!/^[A-Za-z0-9_.+-]+$/.test(trimmed))
return null;
return trimmed;
}
function quoteStructuredValue(value) {
return `"${value.replace(/["\\]/g, "\\$&")}"`;
}
//# sourceMappingURL=kernel.js.map