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.
619 lines • 28.8 kB
JavaScript
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 siteSignInPath = z
.string()
.nullable()
.describe("The app's own same-origin sign-in page (e.g. \"/join\") that MCP authorization (/_run402/oauth/authorize) sends a signed-out user to, with returnTo appended, instead of the hosted /auth/sign-in. Single leading /, at most 512 chars, no query/fragment, not under /_run402/ or /auth/ (the gateway refuses with INVALID_SPEC on site.sign_in_path). Omit to carry the previous release's value forward; null returns to the hosted page.");
const siteNoindex = z
.boolean()
.nullable()
.describe("true adds `X-Robots-Tag: noindex, nofollow, nosnippet` to every response of the host (gateway INVALID_SPEC on site.noindex for anything but a boolean or null). Omit to carry the previous release's value forward; false or null makes the host indexable again (the default).");
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,
})
.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(),
sign_in_path: siteSignInPath.optional(),
noindex: siteNoindex.optional(),
})
.strict(),
z
.object({
patch: z
.object({
put: fileMap.optional(),
delete: z.array(z.string()).optional(),
})
.strict(),
public_paths: sitePublicPaths.optional(),
embedding: siteEmbedding.optional(),
sign_in_path: siteSignInPath.optional(),
noindex: siteNoindex.optional(),
})
.strict(),
z.object({ public_paths: sitePublicPaths, embedding: siteEmbedding.optional(), sign_in_path: siteSignInPath.optional(), noindex: siteNoindex.optional() }).strict(),
z.object({ embedding: siteEmbedding, sign_in_path: siteSignInPath.optional(), noindex: siteNoindex.optional() }).strict(),
z.object({ sign_in_path: siteSignInPath, noindex: siteNoindex.optional() }).strict(),
z.object({ noindex: siteNoindex }).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, or, when the prefix is intentionally read-only, acknowledge with allow_warning_codes: ['WILDCARD_ROUTE_EXCLUDES_MUTATION_METHODS'].",
},
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