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.
434 lines • 22.7 kB
JavaScript
/** Shared error formatting for MCP tool handlers. */
import { Run402Error, NetworkError, ProjectCredentialNotFound as SdkProjectCredentialNotFound, ProjectNotFound as SdkProjectNotFound, } from "../sdk/dist/index.js";
import { toolErrorFrom } from "./structured.js";
/**
* Format an API error response into an agent-friendly MCP tool result.
*
* Always includes: HTTP status, API error message, and actionable next-step guidance.
* Extracts optional fields: hint, retry_after, renew_url, usage, expires_at,
* and lifecycle signals (lifecycle_state, entered_state_at, next_transition_at,
* scheduled_purge_at) when the gateway returns them on a grace-state denial.
* Grace-state (frozen/dormant) control-plane denials are HTTP 403 — 402 is
* reserved for genuine payment challenges — but both statuses are handled
* here so the richer "soft-delete grace window" guidance renders either way.
*
* @param res A response-like object with `status` and `body`.
* @param context Short verb phrase: "running SQL", "deploying function", etc.
*/
export function formatApiError(res, context, error) {
const body = res.body && typeof res.body === "object"
? res.body
: null;
// Primary message — try canonical/human message, then legacy string error.
const primary = body
? stringField(body, "message") ?? stringField(body, "error") ?? "Unknown error"
: typeof res.body === "string"
? res.body
: "Unknown error";
const lines = [
`Error ${context}: ${primary} (HTTP ${res.status})`,
];
lines.push(...formatCanonicalErrorContext(body));
const inGrace = Boolean(body && body.lifecycle_state);
// Supplementary fields from the API response
if (body) {
if (body.hint)
lines.push(`Hint: ${body.hint}`);
if (body.retry_after)
lines.push(`Retry after: ${body.retry_after} seconds`);
if (body.expires_at)
lines.push(`Expires: ${body.expires_at}`);
if (body.renew_url)
lines.push(`Renew URL: ${body.renew_url}`);
if (body.usage) {
const u = body.usage;
const parts = [];
if (u.api_calls !== undefined)
parts.push(`API calls: ${u.api_calls}/${u.limit || "?"}`);
if (u.storage_bytes !== undefined)
parts.push(`Storage: ${u.storage_bytes}/${u.storage_limit || "?"} bytes`);
if (parts.length > 0)
lines.push(`Usage: ${parts.join(", ")}`);
}
if (body.lifecycle_state) {
const lc = [`state=${body.lifecycle_state}`];
if (body.entered_state_at)
lc.push(`entered=${body.entered_state_at}`);
if (body.next_transition_at)
lc.push(`next=${body.next_transition_at}`);
if (body.scheduled_purge_at)
lc.push(`purge_at=${body.scheduled_purge_at}`);
lines.push(`Lifecycle: ${lc.join(", ")}`);
}
}
const code = stringField(body, "code");
const usedCodeGuidance = addCodeGuidance(lines, code, body);
// Actionable guidance based on HTTP status when no canonical code-specific
// guidance applied, or when legacy lifecycle fields need their old text.
if (!usedCodeGuidance) {
switch (res.status) {
case 401:
lines.push(`\nNext step: Create a project with \`up\` (or \`run402 projects provision\`), or check that your service key is correct.`);
break;
case 402:
if (inGrace) {
lines.push(`\nNext step: Project is in the soft-delete grace window — control-plane mutations are blocked. Renew or upgrade with a \`run\` snippet \`await r.tier.set("<tier>")\` (or \`run402 tier set <tier>\`) to reactivate the project in one transaction.`);
}
break;
case 403:
if (body && body.admin_required) {
lines.push(`\nThis command requires admin access.`);
}
else if (inGrace) {
// Lifecycle (frozen/dormant) denials are 403, not 402 — style.md
// reserves 402 for genuine payment challenges. Give the same
// richer grace-window guidance as the 402 branch above.
lines.push(`\nNext step: Project is in the soft-delete grace window — control-plane mutations are blocked. Renew or upgrade with a \`run\` snippet \`await r.tier.set("<tier>")\` (or \`run402 tier set <tier>\`) to reactivate the project in one transaction.`);
}
else {
lines.push(`\nNext step: The project lease may have expired. Check with a \`run\` snippet \`await r.projects.getUsage("<project_id>")\`, or renew with a \`run\` snippet \`await r.tier.set("<tier>")\` (or \`run402 tier set <tier>\`).`);
}
break;
case 404:
lines.push(`\nNext step: Check that the resource name and project ID are correct.`);
break;
case 409:
lines.push(`\nNext step: The requested name or resource is already in use or reserved (e.g., held for an original owner during a grace window). Wait for the reservation to lapse or try a different name.`);
break;
case 429:
lines.push(`\nNext step: Rate limit hit. Wait and retry.`);
break;
default:
if (res.status >= 500) {
lines.push(`\nNext step: Server error. Try again in a moment.`);
}
}
}
else if ((res.status === 402 || res.status === 403) && inGrace) {
lines.push(`\nNext step: Project is in the soft-delete grace window — control-plane mutations are blocked. Renew or upgrade with a \`run\` snippet \`await r.tier.set("<tier>")\` (or \`run402 tier set <tier>\`) to reactivate the project in one transaction.`);
}
return {
content: [{ type: "text", text: lines.join("\n") }],
structuredContent: { status: "error", error: error ?? toolErrorFromBody(res, primary) },
isError: true,
};
}
/** The error object for a bare `{ status, body }` response that did not arrive as a `Run402Error`. */
function toolErrorFromBody(res, message) {
const body = res.body && typeof res.body === "object" && !Array.isArray(res.body) ? res.body : null;
const out = {
code: stringField(body, "code") ?? `HTTP_${res.status}`,
message,
next_actions: Array.isArray(body?.next_actions)
? body.next_actions.filter((a) => Boolean(a) && typeof a === "object" && !Array.isArray(a))
: [],
http_status: res.status,
};
const category = stringField(body, "category");
if (category)
out.category = category;
if (typeof body?.retryable === "boolean")
out.retryable = body.retryable;
if (typeof body?.safe_to_retry === "boolean")
out.safe_to_retry = body.safe_to_retry;
const mutation = stringField(body, "mutation_state");
if (mutation)
out.mutation_state = mutation;
const trace = stringField(body, "trace_id");
if (trace)
out.trace_id = trace;
if (body?.details !== undefined && body.details !== null)
out.details = body.details;
return out;
}
export function formatCanonicalErrorContext(source, opts = {}) {
const lines = [];
const code = canonicalString(source, "code");
const category = canonicalString(source, "category");
const mutationState = canonicalString(source, "mutation_state", "mutationState");
const traceId = canonicalString(source, "trace_id", "traceId");
const retryable = canonicalBoolean(source, "retryable", "retryable");
const safeToRetry = canonicalBoolean(source, "safe_to_retry", "safeToRetry");
const attempts = canonicalNumber(source, "attempts", "attempts");
const maxRetries = canonicalNumber(source, "max_retries", "maxRetries");
const lastRetryCode = canonicalString(source, "last_retry_code", "lastRetryCode");
const details = canonicalValue(source, "details", "details");
const nextActions = canonicalValue(source, "next_actions", "nextActions");
if (code)
lines.push(`Code: \`${code}\``);
if (category)
lines.push(`Category: ${category}`);
if (retryable !== undefined)
lines.push(`Retryable: ${retryable}`);
if (safeToRetry !== undefined)
lines.push(`Safe to retry: ${safeToRetry}`);
if (attempts !== undefined)
lines.push(`Attempts: ${attempts}`);
if (maxRetries !== undefined)
lines.push(`Max retries: ${maxRetries}`);
if (lastRetryCode)
lines.push(`Last retry code: \`${lastRetryCode}\``);
if (mutationState)
lines.push(`Mutation state: ${mutationState}`);
if (traceId)
lines.push(`Trace: ${traceId}`);
if (opts.includeDetails && isNonEmptyRecord(details)) {
lines.push(`Details:`);
lines.push("```json", JSON.stringify(details, null, 2), "```");
}
if (Array.isArray(nextActions) && nextActions.length > 0) {
lines.push(...formatNextActions(nextActions));
}
return lines;
}
function addCodeGuidance(lines, code, body) {
switch (code) {
case "AUTHENTICATION_REQUIRED":
case "UNAUTHORIZED":
lines.push(`\nNext step: Authenticate again or check the project key used for this request.`);
return true;
case "NOT_AUTHORIZED": {
// Org-owned control plane: the wallet authenticated, but the resolved
// principal lacks the org membership/role or per-project grant the
// action needs. This is NOT a payment, lease, or re-authentication
// problem — surface it distinctly from the generic 403.
const details = body && typeof body.details === "object" && body.details !== null && !Array.isArray(body.details)
? body.details
: null;
const requiredRole = stringField(details, "required_role");
const requiredCapability = stringField(details, "required_capability");
const reason = stringField(details, "reason");
const req = [];
if (requiredRole)
req.push(`required role \`${requiredRole}\``);
if (requiredCapability)
req.push(`required capability \`${requiredCapability}\``);
if (reason)
req.push(`reason \`${reason}\``);
const reqStr = req.length > 0 ? ` (${req.join(", ")})` : "";
lines.push(`\nNext step: Authorization denied${reqStr} — not a payment or lease issue. A wallet authenticates; ownership is the org (organization), and what you may do is decided by your org membership role (\`owner > admin > developer > billing > viewer\`) or a per-project grant. Obtain a covering membership/role or grant; high-stakes actions (delete, transfer, membership changes) require an active \`owner\` membership. Note: control-plane denials return 403 even when the project does not exist, so this can also mean the project id is wrong.`);
return true;
}
case "FORBIDDEN":
lines.push(`\nNext step: This operation is not permitted. Some SQL — CREATE ROLE, CREATE SCHEMA, CREATE EXTENSION, GRANT — needs cluster privileges that run402 projects don't grant; rework it within your project's schema. Otherwise verify the key is authorized for this action.`);
return true;
case "ADMIN_REQUIRED":
lines.push(`\nNext step: Use the admin path for this operation — the project's service REST endpoint (\`/projects/v1/:project_id/rest/*\` with the service key) or a \`run\` snippet \`await r.project(id).sql(query)\` for SQL.`);
return true;
case "LAST_OWNER":
lines.push(`\nNext step: An org must keep at least one active \`owner\`. This change would remove or demote the last one. Promote another member to \`owner\` first (\`run402 orgs members role --principal <principal_id> --role owner\`), then retry.`);
return true;
case "PAYMENT_REQUIRED":
case "INSUFFICIENT_FUNDS":
lines.push(`\nNext step: Submit payment (top up the organization's allowance or fund the wallet), then retry the request.`);
return true;
case "PAYMENT_INTENT_PENDING":
lines.push(`\nNext step: Repeat the identical request with the same payer and Idempotency-Key after Retry-After. Do not authorize a fresh key while settlement is unresolved.`);
return true;
case "PAYMENT_RECOVERY_PENDING":
lines.push(`\nNext step: The payment settled and the gateway is still fulfilling it. Repeat the identical request with the same Idempotency-Key after Retry-After; it lands on the same intent and never pays again. After three such cycles the charge is credited to the organization instead (PAYMENT_CREDITED).`);
return true;
case "PAYMENT_DESTINATION_DRAINING":
lines.push(`\nNext step: Wait for the destination mutation to finish, then repeat the identical request. Do not mint a replacement payment identity.`);
return true;
case "PAYMENT_INTENT_DESTINATION_CHANGED":
case "PAYMENT_INTENT_FENCE_EXPIRED":
case "IDEMPOTENCY_KEY_REUSED":
lines.push(`\nNext step: Follow the gateway-authored next_actions exactly; the old key remains fenced and must not fall through to a fresh charge.`);
return true;
case "PAYMENT_AUTHORIZATION_LIFETIME_EXCEEDED":
lines.push(`\nNext step: Request a standard Run402 challenge and sign an authorization whose maxTimeoutSeconds does not exceed 300.`);
return true;
case "PAYMENT_CALLER_IDENTITY_NOT_ACTIVE":
lines.push(`\nNext step: Caller-key payment identity is not enabled for new purchases on this deployment. Keep the same key and retry after rollout activation; do not remove the key to force a charge.`);
return true;
case "PROJECT_FROZEN":
case "PROJECT_DORMANT":
case "PROJECT_PAST_DUE":
lines.push(`\nNext step: Inspect the lifecycle state with a \`run\` snippet \`await r.projects.getUsage("<project_id>")\`, or renew with a \`run\` snippet \`await r.tier.set("<tier>")\` (or \`run402 tier set <tier>\`).`);
return true;
case "RATE_LIMITED":
lines.push(`\nNext step: Rate limit hit. Wait and retry.`);
return true;
case "MIGRATE_GATE_ACTIVE":
lines.push(`\nNext step: A deploy migration gate is active. Wait for the retry window or inspect the deploy operation before retrying.`);
return true;
case "MIGRATION_FAILED":
case "MIGRATION_CHECKSUM_MISMATCH":
lines.push(`\nNext step: Inspect and edit the migration, then submit a corrected deploy request.`);
return true;
case "PLAN_NOT_FOUND":
case "OPERATION_NOT_FOUND":
lines.push(`\nNext step: Check the deploy plan/operation id, or list recent deploy operations for the project.`);
return true;
case "CI_ROUTE_SCOPE_DENIED":
lines.push(`\nNext step: Re-link the GitHub Actions binding with covering \`--route-scope\` patterns, or run the route-changing deploy locally with wallet-backed authority.`);
return true;
case "PROJECT_HAS_PENDING_TRANSFER":
lines.push(`\nNext step: The project has a pending transfer; owner-side mutations are blocked until it is accepted, cancelled, or expires (72h). View it with \`run402 transfer preview <transfer_id>\` or cancel it with \`run402 transfer cancel <transfer_id>\` (from MCP, a \`run\` snippet over \`r.admin.transfers\`). The transfer id is in \`details.transfer_id\`.`);
return true;
case "CANNOT_REBUILD_UNLOCKED_DEPS":
lines.push(`\nNext step: This function was deployed before dependency locking and can't be rebuilt deterministically. Redeploy it from source with \`deploy\` (its \`functions\` slice) to refresh its runtime.`);
return true;
default:
return false;
}
}
function formatNextActions(actions) {
const lines = ["Next actions:"];
for (const action of actions) {
if (action && typeof action === "object" && !Array.isArray(action)) {
const obj = action;
const name = stringField(obj, "type") ??
stringField(obj, "name") ??
stringField(obj, "kind");
const description = stringField(obj, "label") ??
stringField(obj, "description") ??
stringField(obj, "message") ??
stringField(obj, "hint") ??
stringField(obj, "why");
// A client-resolvable action names its literal command (e.g. the
// `approve_write` a WriteApprovalRequiredError synthesizes, or a gateway
// `register_contact` / `contact_staff` / `hand_to_member` that carries one).
const command = stringField(obj, "command");
if (name) {
lines.push(`- ${name}${description ? `: ${description}` : ""}${command ? ` (\`${command}\`)` : ""}`);
continue;
}
}
lines.push(`- ${JSON.stringify(action)}`);
}
return lines;
}
function canonicalString(source, snake, camel = snake) {
const value = canonicalValue(source, snake, camel);
return typeof value === "string" ? value : undefined;
}
function canonicalBoolean(source, snake, camel = snake) {
const value = canonicalValue(source, snake, camel);
return typeof value === "boolean" ? value : undefined;
}
function canonicalNumber(source, snake, camel = snake) {
const value = canonicalValue(source, snake, camel);
return typeof value === "number" ? value : undefined;
}
function canonicalValue(source, snake, camel = snake) {
const sourceObj = source && typeof source === "object" ? source : null;
const body = source instanceof Run402Error && source.body && typeof source.body === "object"
? source.body
: sourceObj;
if (body && body[snake] !== undefined)
return body[snake];
if (sourceObj && sourceObj[camel] !== undefined)
return sourceObj[camel];
if (sourceObj && sourceObj[snake] !== undefined)
return sourceObj[snake];
return undefined;
}
function stringField(obj, key) {
return typeof obj?.[key] === "string" ? obj[key] : undefined;
}
function isNonEmptyRecord(value) {
return Boolean(value &&
typeof value === "object" &&
!Array.isArray(value) &&
Object.keys(value).length > 0);
}
/**
* Consistent "project not found in key store" error.
*/
export function projectNotFound(projectId) {
return {
content: [
{
type: "text",
text: `Error: Project \`${projectId}\` not found in key store. ` +
`Create a project first with \`up\` (or \`run402 projects provision\`).`,
},
],
structuredContent: {
status: "error",
error: {
code: "PROJECT_NOT_FOUND",
message: `Project ${projectId} not found in key store.`,
next_actions: [{ type: "create_project", why: "Create a project first with `up` (or `run402 projects provision`)." }],
},
},
isError: true,
};
}
/**
* Consistent local project credential-cache miss error.
*/
export function projectCredentialNotFound(err) {
const lines = [
`Error: No local project credentials cached for \`${err.projectId}\` (not found in key store).`,
`Code: \`PROJECT_CREDENTIAL_NOT_FOUND\``,
`This is a local credential-cache miss, not proof that the project does not exist.`,
];
lines.push(...formatCanonicalErrorContext(err, { includeDetails: true }));
return {
content: [{ type: "text", text: lines.join("\n") }],
structuredContent: { status: "error", error: toolErrorFrom(err) },
isError: true,
};
}
/**
* Translate an SDK error into the MCP tool-result shape.
*
* Routing:
* - `ProjectNotFound` → {@link projectNotFound} (preserves existing text)
* - HTTP-backed errors (status !== null) → {@link formatApiError}
* - `NetworkError` / other `Run402Error` with no status → plain isError text
* - Unknown throwables → plain isError text
*
* Call this in the catch branch of a thin MCP shim that otherwise does
* nothing but delegate to an SDK method and format the successful result.
*/
export function mapSdkError(err, context) {
if (err instanceof SdkProjectCredentialNotFound) {
return projectCredentialNotFound(err);
}
if (err instanceof SdkProjectNotFound) {
return projectNotFound(err.projectId);
}
const error = toolErrorFrom(err, context);
const structuredContent = { status: "error", error };
if (err instanceof Run402Error) {
if (err.code === "REST_PERMISSION_DENIED") {
return { isError: true, structuredContent, content: [{ type: "text", text: [
`Error ${context}: ${err.message} (HTTP ${err.status})`,
...formatCanonicalErrorContext({ code: err.code, details: err.details, retryable: err.retryable }, { includeDetails: true }),
"Upstream body:", JSON.stringify(err.body),
].join("\n") }] };
}
if (err.status !== null) {
return formatApiError({ status: err.status, body: err.body }, context, error);
}
if (err instanceof NetworkError) {
return {
content: [{ type: "text", text: `Error ${context}: ${err.message}` }],
structuredContent,
isError: true,
};
}
const lines = [`Error ${context}: ${err.message}`];
lines.push(...formatCanonicalErrorContext(err, { includeDetails: true }));
return {
content: [{ type: "text", text: lines.join("\n") }],
structuredContent,
isError: true,
};
}
return {
content: [
{
type: "text",
text: `Error ${context}: ${err?.message ?? String(err)}`,
},
],
structuredContent,
isError: true,
};
}
//# sourceMappingURL=errors.js.map