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.

609 lines • 27.6 kB
import { z } from "zod"; import { getSdk } from "../sdk.js"; import { formatCanonicalErrorContext, mapSdkError, projectNotFound } from "../errors.js"; import { jsonBlock, toolErrorFrom } from "../structured.js"; import { requireWalletAuth } from "../wallet-auth.js"; import { updateProject } from "../keystore.js"; import { PaymentRequired, ROUTE_HTTP_METHODS, Run402DeployError, } from "../../sdk/dist/index.js"; import { normalizeDeployManifest } from "../../sdk/dist/node/index.js"; /** * MCP `deploy` tool — exposes the unified `p.apply` primitive. * * The Zod schema mirrors `ReleaseSpec` and accepts byte sources in either * shape: a bare UTF-8 string (the natural shape, e.g. `"<h1>hi</h1>"`) or * the `{ data, encoding?, content_type? }` object — the latter is required * for binary payloads via base64 or for an explicit `content_type` override * (wire fields are snake_case; `contentType` is only the in-function JS option). * The SDK's `resolveContent` already accepts both polymorphically; this * schema mirrors that. Agents that want to ship a real app via patch * semantics, multi-resource atomicity, or the resumable operation model * use this tool. */ const fileEntry = z.union([ z.string(), z .object({ data: z.string(), encoding: z.enum(["utf-8", "base64"]).optional(), content_type: z .string() .optional() .describe("MIME type override (snake_case, like every wire field). Auto-detected from the path's extension when omitted."), }) .strict(), ]); const fileMap = z.record(fileEntry); const publicStaticPathSpec = z .object({ asset: z .string() .describe("Release static asset path, such as events.html. This is not a public URL."), cache_class: z .string() .optional() .describe("Optional static cache class, for example html, immutable_versioned, or revalidating_asset."), }) .strict(); const siteEmbedding = z .object({ frame_ancestors: z .array(z.string()) .min(1) .describe("Platform embedding catalog KEYS (never raw origins). `localhost` expands to http://localhost:* and http://127.0.0.1:*; the gateway rejects unknown keys with INVALID_SPEC naming the valid ones."), }) .strict() .nullable() .describe("tenant-site-embedding: who may put the site in an iframe. The gateway then sends `Content-Security-Policy: frame-ancestors <expanded origins>` and drops X-Frame-Options on every response of the host. Omit to carry the previous release's declaration forward; null returns to the default deny."); const sitePublicPaths = z.union([ z .object({ mode: z .literal("implicit") .describe("Restore filename-derived public reachability for static assets."), }) .strict(), z .object({ mode: z .literal("explicit") .describe("Use only the complete replace table as direct public static URLs."), replace: z .record(publicStaticPathSpec) .describe("Complete map from public browser paths such as /events to release static asset paths such as events.html."), }) .strict(), ]); const migrationEntry = z .object({ id: z .string() .optional() .describe("Stable versioned migration id (e.g. '001_init'). Same id+checksum across re-deploys is a registry noop; same id+different checksum is a hard error. Use name instead for generated/idempotent SQL."), name: z .string() .regex(/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/) .optional() .describe("Content-tracked migration name for generated/idempotent SQL. The SDK compiles this to <name>_<sha256(sql)[0:16]>; changed content applies once under a new id and identical re-deploys noop. SQL declared with name MUST be idempotent."), sql: z.string().optional().describe("Inline SQL (UTF-8). Either sql or sql_ref is required."), sql_ref: z .object({ sha256: z.string(), size: z.number(), content_type: z.string().optional(), }) .optional() .describe("Pre-uploaded CAS reference. Mutually exclusive with sql."), checksum: z .string() .optional() .describe("Lowercase hex SHA-256 of the migration SQL. Computed from `sql` if omitted."), transaction: z.enum(["required", "none"]).optional(), }) .strict() .refine((entry) => (entry.id === undefined) !== (entry.name === undefined), { message: "Migration entries must declare exactly one of id or name.", path: ["id"], }); const functionSpec = z .object({ runtime: z.literal("node22").optional(), source: fileEntry .optional() .describe("Single-file function source. The SDK uploads it to CAS and references it in the release."), files: fileMap .optional() .describe("Multi-file function. Provide `entrypoint` to designate the entry path."), entrypoint: z.string().optional(), config: z .object({ timeoutSeconds: z.number().optional(), memoryMb: z.number().optional(), }) .optional(), schedule: z .string() .nullable() .optional() .describe("Cron expression (5-field). Pass null to remove an existing schedule; omit in patch mode to leave it unchanged."), }) .strict(); const functionMap = z.record(functionSpec); const routeMethod = z.enum(ROUTE_HTTP_METHODS); const functionRouteTarget = z .object({ type: z.literal("function"), name: z .string() .describe("Materialized release function name, not a file name or handler export."), }) .strict(); const staticRouteTarget = z .object({ type: z.literal("static"), file: z .string() .describe("Materialized static-site file path, relative to the site root. No leading slash, query, fragment, traversal, or directory shorthand."), }) .strict(); const routeTarget = z.union([functionRouteTarget, staticRouteTarget]); const routeEntry = z .object({ pattern: z .string() .describe("Route pattern such as /api, /api/*, or /login. Prefix wildcards only use a final /*."), methods: z .array(routeMethod) .min(1) .optional() .describe("Optional method allowlist. Omit to allow all supported methods; do not pass an empty array."), target: routeTarget, acknowledge_readonly: z .literal(true) .optional() .describe("Durable acknowledgement for intentional GET/HEAD final-wildcard function routes such as /share/*."), }) .strict(); export const deploySchema = { project_id: z .string() .describe("Project ID to deploy to (from provision)."), base: z .union([ z.object({ release: z.enum(["current", "empty"]) }).strict(), z.object({ release_id: z.string() }).strict(), ]) .optional() .describe("Diff base. Default `{ release: 'current' }`. Use `{ release: 'empty' }` for a fresh deploy that fails if a release already exists."), database: z .object({ migrations: z.array(migrationEntry).optional(), expose: z .record(z.unknown()) .optional() .describe("Declarative authorization manifest (see https://run402.com/schemas/manifest.v1.json)."), zero_downtime: z .boolean() .optional() .describe("Skip the migrate-gate phase. Only safe when migrations are declared backward-compatible."), }) .strict() .optional(), secrets: z .object({ require: z .array(z.string()) .optional() .describe("Secret keys that must already exist. Set values first with `set_secret`; never put secret values in deploy specs."), delete: z.array(z.string()).optional(), }) .strict() .optional(), functions: z .object({ replace: functionMap.optional(), patch: z .object({ set: functionMap.optional(), delete: z.array(z.string()).optional(), }) .strict() .optional(), }) .strict() .optional(), site: z .union([ z .object({ replace: fileMap, public_paths: sitePublicPaths.optional(), embedding: siteEmbedding.optional(), }) .strict(), z .object({ patch: z .object({ put: fileMap.optional(), delete: z.array(z.string()).optional(), }) .strict(), public_paths: sitePublicPaths.optional(), embedding: siteEmbedding.optional(), }) .strict(), z.object({ public_paths: sitePublicPaths, embedding: siteEmbedding.optional() }).strict(), z.object({ embedding: siteEmbedding }).strict(), ]) .optional(), assets: z .object({ put: z .array(z .object({ key: z.string().describe("Asset key (path under the project's asset namespace, no leading slash)."), source: fileEntry.optional().describe("Source bytes (SDK-input form). Mutually exclusive with sha256."), sha256: z.string().optional().describe("Pre-uploaded CAS reference (wire form). Mutually exclusive with source."), size_bytes: z.number().int().optional().describe("Required when using the wire form (sha256)."), content_type: z.string().optional(), visibility: z.enum(["public", "private"]).optional(), immutable: z.boolean().optional(), }) .strict()) .optional() .describe("Per-key asset puts. Pass either { source } for the SDK to hash/upload, or { sha256, size_bytes } when bytes are already in CAS."), delete: z.array(z.string()).optional().describe("Asset keys to remove in the activation transaction."), sync: z .object({ prefix: z.string().describe("Prefix under which sync operates. Required."), prune: z.literal(true).describe("Must be true to declare destructive sync intent."), confirm: z .object({ base_revision: z.string(), delete_set_digest: z.string(), expected_delete_count: z.number().int(), }) .strict() .optional() .describe("Confirmation token echoed back from a prior plan; required to commit a destructive sync."), }) .strict() .optional() .describe("Declarative sync. With prune: true and no confirm, the plan returns an asset_sync block with the values to echo back."), }) .strict() .optional() .describe("v1.48 unified-apply assets slice. Asset writes promote inside the same activation transaction as functions/site/secrets so a release flips atomically."), subdomains: z .object({ set: z.array(z.string()).optional(), add: z.array(z.string()).optional(), remove: z.array(z.string()).optional(), }) .strict() .optional() .describe("At most one subdomain per project — multi-element `set` is rejected with SUBDOMAIN_MULTI_NOT_SUPPORTED."), routes: z .union([ z.null(), z.object({ replace: z.array(routeEntry) }).strict(), ]) .optional() .describe("Apply-v1 web routes. Omit or pass null to carry forward base routes; pass { replace: [] } to clear routes; pass { replace: [{ pattern, methods?, target: { type: 'function', name } }] } for functions or exact GET/HEAD { target: { type: 'static', file } } entries for method-aware static route aliases. Prefer site.public_paths for ordinary clean static URLs."), i18n: z .union([ z.null(), z .object({ defaultLocale: z .string() .regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/) .describe("Default locale tag. MUST be byte-identical to one entry in locales[]. The platform does NOT silently canonicalize."), locales: z .array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/)) .min(1) .max(50) .describe("Supported locale tags. Non-empty, max 50 entries. Tags are opaque — only the safety regex is enforced. Negotiation returns canonical casing from this array, not the request's casing."), detect: z .array(z .string() .regex(/^(accept-language|cookie:[!#$%&'*+\-.^_`|~0-9A-Za-z]+)$/) .describe("Detect source: 'accept-language' or 'cookie:<name>' (RFC 6265 cookie-name grammar).")) .max(10) .optional() .describe("Walked in order; first match wins. Defaults to ['accept-language'] when omitted, max 10 entries; [] is allowed and means 'always default'."), }) .strict(), ]) .optional() .describe("Routed-locale-context release slice. Omit to carry forward from base release; pass null to clear the slice; pass { defaultLocale, locales, detect? } to replace. Drives the negotiated locale that the gateway surfaces to routed HTTP function invocations via x-run402-locale and x-run402-default-locale request headers (omitted entirely when the active release has no i18n slice). Static-route hits do NOT receive locale negotiation."), idempotency_key: z .string() .optional() .describe("Optional client idempotency key. Combined with the project id and gateway-computed manifest digest to deduplicate retries."), allow_warnings: z .boolean() .optional() .describe("Continue past plan warnings that require confirmation. Default false: the tool stops before upload/commit so an agent can set missing secrets or inspect warnings."), allow_warning_codes: z .array(z.string()) .optional() .describe("Continue past specific reviewed plan warning codes. Prefer this to allow_warnings when only one known warning class is intentional."), }; export async function handleDeploy(args) { if (!args.project_id) return projectNotFound("(none — project_id is required)"); const auth = requireWalletAuth("/apply/v1/plans"); if ("error" in auth) return auth.error; const events = []; const onEvent = (e) => { events.push(e); }; let spec; let idempotencyKey; try { const { allow_warnings: _allowWarnings, allow_warning_codes: _allowWarningCodes, ...manifestArgs } = args; // The Zod schema validates detect entries with a regex, but TS narrows // them to `string[]`. The SDK's manifest adapter + validateSpec rerun // the same checks before any wire call, so the cast here is purely a // type bridge between Zod-inferred and SDK strict shapes. const normalized = await normalizeDeployManifest(manifestArgs); spec = normalized.spec; idempotencyKey = normalized.idempotencyKey; } catch (err) { return mapSdkError(err, "validating deploy spec"); } try { const result = await getSdk()._applyEngine.apply(spec, { onEvent, idempotencyKey, allowWarnings: args.allow_warnings === true, allowWarningCodes: args.allow_warning_codes, }); if (result.urls.deployment_id) { updateProject(args.project_id, { last_deployment_id: result.urls.deployment_id }); } const lines = [ `## Release Activated`, ``, `| Field | Value |`, `|-------|-------|`, `| project_id | \`${args.project_id}\` |`, `| release_id | \`${result.release_id}\` |`, `| operation_id | \`${result.operation_id}\` |`, ]; for (const [k, v] of Object.entries(result.urls)) { lines.push(`| ${k} | ${v} |`); } if (result.warnings.length > 0) { lines.push(``, renderWarningsMarkdown(result.warnings)); } // One value, two views: the fenced JSON is the envelope, and the progress // events block is its `events` field. lines.push(``, `### Raw Deploy Result`, ``, jsonBlock({ status: "ok", result })); return { content: [ { type: "text", text: lines.join("\n") }, { type: "text", text: renderEventsBlock(events) }, ], structuredContent: { status: "ok", result, events }, }; } catch (err) { if (err instanceof PaymentRequired) { // An uncovered payment is an error, as `run` reports the same failure. const body = (err.body ?? {}); const error = toolErrorFrom(err, "deploying release"); const out = { content: [ { type: "text", text: `## Payment Required\n\nThis deploy requires payment (project lease renewal or x402 quote).\n\n` + jsonBlock(body) + "\n\nResolve payment via the organization's allowance or the wallet, and retry this tool call.", }, ], structuredContent: { status: "error", error, events }, isError: true, }; if (events.length > 0) out.content.push({ type: "text", text: renderEventsBlock(events) }); return out; } if (err instanceof Run402DeployError) { const lines = [ `## Deploy Failed`, ``, ...formatCanonicalErrorContext(err, { includeDetails: true }), ]; if (err.phase) lines.push(`**Phase:** \`${err.phase}\``); if (err.resource) lines.push(`**Resource:** \`${err.resource}\``); if (err.operationId) lines.push(`**Operation:** \`${err.operationId}\``); if (err.planId) lines.push(`**Plan:** \`${err.planId}\``); const warnings = warningsFromDeployError(err); if (warnings.length > 0) { lines.push(``, renderWarningsMarkdown(warnings)); const unacknowledgedCodes = unacknowledgedWarningCodes(err); if (unacknowledgedCodes.length > 0) { lines.push(``, `Unacknowledged warning code${unacknowledgedCodes.length === 1 ? "" : "s"}: ${unacknowledgedCodes.map((code) => `\`${code}\``).join(", ")}`); } } else { const guidance = routeGuidanceMarkdownForCodes([err.code]); if (guidance) lines.push(``, guidance); } lines.push(``, err.message); if (err.fix) { lines.push(``, `**Suggested fix:**`); lines.push("```json", JSON.stringify(err.fix, null, 2), "```"); } if (err.logs && err.logs.length > 0) { lines.push(``, `**Logs:**`); lines.push("```", ...err.logs.slice(0, 50), "```"); } const out = { content: [{ type: "text", text: lines.join("\n") }], structuredContent: { status: "error", error: toolErrorFrom(err, "deploying release"), events, warnings }, isError: true, }; if (events.length > 0) { out.content.push({ type: "text", text: renderEventsBlock(events) }); } return out; } const errResp = mapSdkError(err, "deploying release"); errResp.structuredContent = { ...errResp.structuredContent, events }; if (events.length > 0) { errResp.content.push({ type: "text", text: renderEventsBlock(events) }); } return errResp; } } function warningsFromDeployError(err) { const body = err.body; if (!body || typeof body !== "object" || Array.isArray(body)) return []; const warnings = body.warnings; return Array.isArray(warnings) ? warnings : []; } function unacknowledgedWarningCodes(err) { const body = err.body; if (!body || typeof body !== "object" || Array.isArray(body)) return []; const codes = body.unacknowledged_warning_codes; return Array.isArray(codes) ? codes.filter((code) => typeof code === "string") : []; } function renderWarningsMarkdown(warnings) { const lines = [`### Plan warnings`]; for (const warning of warnings) { const affected = warning.affected && warning.affected.length > 0 ? ` (${warning.affected.map((item) => `\`${item}\``).join(", ")})` : ""; lines.push(`- **${warning.code}** [${warning.severity}]${affected}: ${warning.message}`); } if (warnings.some((w) => w.code === "MISSING_REQUIRED_SECRET")) { const keys = Array.from(new Set(warnings.flatMap((w) => w.affected ?? []))); const suffix = keys.length > 0 ? ` for ${keys.map((k) => `\`${k}\``).join(", ")}` : ""; lines.push(``, `Set the missing secret value${keys.length === 1 ? "" : "s"}${suffix} with \`set_secret\`, then retry \`deploy\` with \`secrets.require\`.`); } else if (warnings.some((w) => w.requires_confirmation)) { const codes = Array.from(new Set(warnings .filter((w) => w.requires_confirmation) .map((w) => w.code) .filter(Boolean))); lines.push(``, codes.length > 0 ? `Review these warnings before retrying with \`allow_warning_codes: ${JSON.stringify(codes)}\`; use \`allow_warnings: true\` only after reviewing every warning.` : `Review these warnings before retrying with \`allow_warnings: true\`.`); } const routeGuidance = routeWarningRows(warnings); if (routeGuidance.length > 0) { lines.push(``, routeGuidanceTable(routeGuidance)); } return lines.join("\n"); } const ROUTE_WARNING_GUIDANCE = { PUBLIC_ROUTED_FUNCTION: { meaning: "A route makes the target function public same-origin browser ingress.", recovery: "Informational (requires_confirmation: false) — it never blocks a deploy and needs no allow_warning_codes entry. Review app auth, CSRF protection for cookie-authenticated unsafe methods, and CORS/OPTIONS behavior in the Node 22 Fetch Request -> Response handler; direct /functions/v1/:name remains API-key protected.", }, ROUTE_TARGET_CARRIED_FORWARD: { meaning: "A carried-forward route still points at a function from the base release.", recovery: "Inspect the active release routes and deploy a replacement route table if the target should change.", }, ROUTE_SHADOWS_STATIC_PATH: { meaning: "A dynamic route takes precedence over a static path.", recovery: "Inspect the affected warning details and active release routes; confirm only when intentional.", }, WILDCARD_ROUTE_SHADOWS_STATIC_PATHS: { meaning: "A prefix wildcard route shadows one or more static paths.", recovery: "Review affected paths, split exact routes if needed, and confirm only when intentional.", }, METHOD_SPECIFIC_ROUTE_ALLOWS_GET_STATIC_FALLBACK: { meaning: "A method-specific route leaves GET/HEAD static fallback available.", recovery: "Confirm that static fallback for unmatched methods is intended.", }, WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS: { meaning: "A wildcard function route only allows GET/HEAD.", recovery: "Add the mutation methods the function supports, omit methods for an API prefix that should accept all supported methods, set acknowledge_readonly: true when the prefix is intentionally read-only, or use allow_warning_codes as a reviewed escape hatch.", }, ROUTE_TABLE_NEAR_LIMIT: { meaning: "The route table is close to the project or gateway limit.", recovery: "Consolidate route patterns or remove stale routes before adding more.", }, ROUTES_NOT_ENABLED: { meaning: "Apply-v1 web routes are not enabled for this project or environment.", recovery: "Deploy without routes or request enablement. Direct /functions/v1/:name remains protected and is not a browser-route substitute.", }, STATIC_ALIAS_SHADOWS_STATIC_PATH: { meaning: "A static route target shadows a direct static path at the same public URL.", recovery: "Inspect the route pattern, target file, direct static path, and active release routes; confirm only when the static route target is intentional.", }, STATIC_ALIAS_RELATIVE_ASSET_RISK: { meaning: "Relative asset URLs inside the target HTML may resolve differently at the static route target URL.", recovery: "Inspect the target HTML for relative assets and confirm only when the alternate public URL is intentional.", }, STATIC_ALIAS_DUPLICATE_CANONICAL_URL: { meaning: "Both the static route target URL and the target file URL may be reachable.", recovery: "Decide which URL should be canonical, update links/canonical tags if needed, and confirm only when duplicate reachability is intended.", }, STATIC_ALIAS_EXTENSIONLESS_NON_HTML: { meaning: "An extensionless static route target points at a non-HTML file.", recovery: "Check the target content type and prefer extensionless static route targets for HTML pages.", }, STATIC_ALIAS_TABLE_NEAR_LIMIT: { meaning: "Static route targets count toward the route table limit.", recovery: "Consolidate manual static route targets and avoid one entry per page for large sites until framework-scale Web Output support exists.", }, CI_ROUTE_SCOPE_DENIED: { meaning: "The CI binding does not cover one or more route declarations in this deploy.", recovery: "Re-link the GitHub Actions binding with exact scopes like /admin or prefix scopes like /api/*, or run the route-changing deploy locally.", }, }; function routeWarningRows(warnings) { const seen = new Set(); const rows = []; for (const warning of warnings) { const guidance = ROUTE_WARNING_GUIDANCE[warning.code]; if (!guidance || seen.has(warning.code)) continue; seen.add(warning.code); rows.push(`| \`${warning.code}\` | ${guidance.meaning} | ${guidance.recovery} |`); } return rows; } function routeGuidanceMarkdownForCodes(codes) { const rows = []; for (const code of codes) { const guidance = ROUTE_WARNING_GUIDANCE[code]; if (!guidance) continue; rows.push(`| \`${code}\` | ${guidance.meaning} | ${guidance.recovery} |`); } return rows.length > 0 ? routeGuidanceTable(rows) : null; } function routeGuidanceTable(rows) { return [ `### Route warning guidance`, ``, `| Code | Meaning | Recovery |`, `|------|---------|----------|`, ...rows, ].join("\n"); } function renderEventsBlock(events) { return [ `### Progress events`, ``, "```json", JSON.stringify(events, null, 2), "```", ].join("\n"); } //# sourceMappingURL=deploy.js.map