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.

434 lines • 22.7 kB
/** 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