From f77c5d9aa57a10e14cdfab4f7a5ed42dcdf18d73 Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 12 May 2026 16:50:02 -0300 Subject: [PATCH 1/5] add cfb_* MCP tools for Cloudflare Workers Builds management 14 new MCP tools across 5 shared UIs (cfb-setup, cfb-secrets, cfb-build-vars, cfb-builds, cfb-versions) that wrap the admin loaders/actions landing in deco-sites/admin#3170. Each tool is a thin callAdmin wrapper; no Cloudflare tokens live in this process. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/main.ts | 10 + api/resources/cfb-build-vars.ts | 28 ++ api/resources/cfb-builds.ts | 28 ++ api/resources/cfb-secrets.ts | 28 ++ api/resources/cfb-setup.ts | 28 ++ api/resources/cfb-versions.ts | 28 ++ api/tools/cfb-build-vars.ts | 133 +++++++++ api/tools/cfb-builds.ts | 201 ++++++++++++++ api/tools/cfb-secrets.ts | 146 ++++++++++ api/tools/cfb-setup.ts | 136 +++++++++ api/tools/cfb-versions.ts | 132 +++++++++ api/tools/index.ts | 32 +++ web/router.tsx | 19 ++ web/tools/cfb-build-vars/index.tsx | 175 ++++++++++++ web/tools/cfb-builds/index.tsx | 426 +++++++++++++++++++++++++++++ web/tools/cfb-secrets/index.tsx | 221 +++++++++++++++ web/tools/cfb-setup/index.tsx | 322 ++++++++++++++++++++++ web/tools/cfb-versions/index.tsx | 375 +++++++++++++++++++++++++ 18 files changed, 2468 insertions(+) create mode 100644 api/resources/cfb-build-vars.ts create mode 100644 api/resources/cfb-builds.ts create mode 100644 api/resources/cfb-secrets.ts create mode 100644 api/resources/cfb-setup.ts create mode 100644 api/resources/cfb-versions.ts create mode 100644 api/tools/cfb-build-vars.ts create mode 100644 api/tools/cfb-builds.ts create mode 100644 api/tools/cfb-secrets.ts create mode 100644 api/tools/cfb-setup.ts create mode 100644 api/tools/cfb-versions.ts create mode 100644 web/tools/cfb-build-vars/index.tsx create mode 100644 web/tools/cfb-builds/index.tsx create mode 100644 web/tools/cfb-secrets/index.tsx create mode 100644 web/tools/cfb-setup/index.tsx create mode 100644 web/tools/cfb-versions/index.tsx diff --git a/api/main.ts b/api/main.ts index 3dc5bfa..354838e 100644 --- a/api/main.ts +++ b/api/main.ts @@ -1,6 +1,11 @@ import { withRuntime } from "@decocms/runtime"; import { storefrontSkillsPrompts } from "./prompts/storefront-skills.ts"; import { assetsAppResource } from "./resources/assets.ts"; +import { cfbBuildVarsAppResource } from "./resources/cfb-build-vars.ts"; +import { cfbBuildsAppResource } from "./resources/cfb-builds.ts"; +import { cfbSecretsAppResource } from "./resources/cfb-secrets.ts"; +import { cfbSetupAppResource } from "./resources/cfb-setup.ts"; +import { cfbVersionsAppResource } from "./resources/cfb-versions.ts"; import { environmentsAppResource } from "./resources/environments.ts"; import { fileExplorerAppResource } from "./resources/file-explorer.ts"; import { issuesAppResource } from "./resources/issues.ts"; @@ -58,6 +63,11 @@ const runtime = withRuntime({ pullRequestsAppResource, releasesAppResource, renderHtmlAppResource, + cfbSetupAppResource, + cfbSecretsAppResource, + cfbBuildVarsAppResource, + cfbBuildsAppResource, + cfbVersionsAppResource, ], }); diff --git a/api/resources/cfb-build-vars.ts b/api/resources/cfb-build-vars.ts new file mode 100644 index 0000000..e334dc2 --- /dev/null +++ b/api/resources/cfb-build-vars.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_BUILD_VARS_RESOURCE_URI } from "../tools/cfb-build-vars.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const cfbBuildVarsAppResource = createPublicResource({ + uri: CFB_BUILD_VARS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Build Vars", + description: + "Manage build-time variables on the Cloudflare production trigger for the configured site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: CFB_BUILD_VARS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/resources/cfb-builds.ts b/api/resources/cfb-builds.ts new file mode 100644 index 0000000..6a3c90e --- /dev/null +++ b/api/resources/cfb-builds.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_BUILDS_RESOURCE_URI } from "../tools/cfb-builds.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const cfbBuildsAppResource = createPublicResource({ + uri: CFB_BUILDS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Builds", + description: + "List recent Cloudflare Workers Builds, view per-build logs, and trigger new builds.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: CFB_BUILDS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/resources/cfb-secrets.ts b/api/resources/cfb-secrets.ts new file mode 100644 index 0000000..3a64421 --- /dev/null +++ b/api/resources/cfb-secrets.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_SECRETS_RESOURCE_URI } from "../tools/cfb-secrets.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const cfbSecretsAppResource = createPublicResource({ + uri: CFB_SECRETS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Secrets", + description: + "Manage runtime secrets bound to the Cloudflare Worker for the configured site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: CFB_SECRETS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/resources/cfb-setup.ts b/api/resources/cfb-setup.ts new file mode 100644 index 0000000..be7f67d --- /dev/null +++ b/api/resources/cfb-setup.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_SETUP_RESOURCE_URI } from "../tools/cfb-setup.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const cfbSetupAppResource = createPublicResource({ + uri: CFB_SETUP_RESOURCE_URI, + name: "Cloudflare Workers Builds — Setup", + description: + "One-click onboarding and setup status for a Cloudflare Workers Builds-hosted site.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: CFB_SETUP_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/resources/cfb-versions.ts b/api/resources/cfb-versions.ts new file mode 100644 index 0000000..80f77c3 --- /dev/null +++ b/api/resources/cfb-versions.ts @@ -0,0 +1,28 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPublicResource } from "@decocms/runtime/tools"; +import { CFB_VERSIONS_RESOURCE_URI } from "../tools/cfb-versions.ts"; + +const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); + return join(projectRoot, "dist", "client", "index.html"); +} + +export const cfbVersionsAppResource = createPublicResource({ + uri: CFB_VERSIONS_RESOURCE_URI, + name: "Cloudflare Workers Builds — Versions", + description: + "List recent Cloudflare Worker versions and roll back to a previous version.", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await readFile(getDistPath(), "utf-8"); + return { + uri: CFB_VERSIONS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, +}); diff --git a/api/tools/cfb-build-vars.ts b/api/tools/cfb-build-vars.ts new file mode 100644 index 0000000..b29fcc1 --- /dev/null +++ b/api/tools/cfb-build-vars.ts @@ -0,0 +1,133 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_BUILD_VARS_RESOURCE_URI = "ui://mcp-app/cfb-build-vars"; + +const NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; +const NAME_HINT = + "Letters, digits, and underscore only; must start with a letter or underscore; max 64 chars."; + +export const buildVarsResultSchema = z.object({ + triggerUuid: z.string(), + buildVars: z.record(z.string(), z.string()), +}); +export type BuildVarsResult = z.infer; + +// ─── cfb_list_build_vars ────────────────────────────────────────────────────── + +export const cfbListBuildVarsInputSchema = z.object({}); +export type CfbListBuildVarsInput = z.infer; + +export const cfbListBuildVarsOutputSchema = buildVarsResultSchema; +export type CfbListBuildVarsOutput = z.infer< + typeof cfbListBuildVarsOutputSchema +>; + +export const cfbListBuildVarsTool = createTool({ + id: "cfb_list_build_vars", + description: + "List Cloudflare build-time variables for the configured site's production trigger. Build vars are visible to anyone with access to the site — for secrets that must stay hidden, use `cfb_set_secret` instead.", + inputSchema: cfbListBuildVarsInputSchema, + outputSchema: cfbListBuildVarsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/vars/list.ts", + { sitename: site }, + apiKey, + )) as CfbListBuildVarsOutput; + }, +}); + +// ─── cfb_set_build_var ──────────────────────────────────────────────────────── + +export const cfbSetBuildVarInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe(`Build var name. ${NAME_HINT}`), + value: z + .string() + .describe("Build var value (plain text, visible after set)."), +}); +export type CfbSetBuildVarInput = z.infer; + +export const cfbSetBuildVarOutputSchema = buildVarsResultSchema; +export type CfbSetBuildVarOutput = z.infer; + +export const cfbSetBuildVarTool = createTool({ + id: "cfb_set_build_var", + description: + "Create or update a Cloudflare build-time variable on the configured site's production trigger. Overwrites the value if the name already exists. These are NOT runtime secrets — values are plain-text and visible.", + inputSchema: cfbSetBuildVarInputSchema, + outputSchema: cfbSetBuildVarOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/vars/set.ts", + { sitename: site, name: context.name, value: context.value }, + apiKey, + )) as CfbSetBuildVarOutput; + }, +}); + +// ─── cfb_delete_build_var ───────────────────────────────────────────────────── + +export const cfbDeleteBuildVarInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe("Build var name to remove."), +}); +export type CfbDeleteBuildVarInput = z.infer< + typeof cfbDeleteBuildVarInputSchema +>; + +export const cfbDeleteBuildVarOutputSchema = buildVarsResultSchema; +export type CfbDeleteBuildVarOutput = z.infer< + typeof cfbDeleteBuildVarOutputSchema +>; + +export const cfbDeleteBuildVarTool = createTool({ + id: "cfb_delete_build_var", + description: + "Remove a Cloudflare build-time variable from the configured site's production trigger.", + inputSchema: cfbDeleteBuildVarInputSchema, + outputSchema: cfbDeleteBuildVarOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILD_VARS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/vars/delete.ts", + { sitename: site, name: context.name }, + apiKey, + )) as CfbDeleteBuildVarOutput; + }, +}); diff --git a/api/tools/cfb-builds.ts b/api/tools/cfb-builds.ts new file mode 100644 index 0000000..0159b72 --- /dev/null +++ b/api/tools/cfb-builds.ts @@ -0,0 +1,201 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_BUILDS_RESOURCE_URI = "ui://mcp-app/cfb-builds"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const cfBuildStatusSchema = z.enum([ + "queued", + "initializing", + "running", + "success", + "failure", + "cancelled", +]); +export type CfBuildStatus = z.infer; + +export const cfBuildSchema = z + .object({ + build_uuid: z.string(), + build_id: z.string().optional(), + external_script_id: z.string(), + status: cfBuildStatusSchema, + branch: z.string().optional(), + commit_hash: z.string().optional(), + commit_message: z.string().optional(), + created_on: z.string(), + modified_on: z.string().optional(), + build_duration_ms: z.number().optional(), + trigger: z + .object({ + source: z.string(), + trigger_uuid: z.string().optional(), + }) + .optional(), + }) + .passthrough(); +export type CfBuild = z.infer; + +export const cfBuildLogChunkSchema = z + .object({ + line: z.number(), + message: z.string(), + timestamp: z.string().optional(), + stream: z.enum(["stdout", "stderr"]).optional(), + }) + .passthrough(); +export type CfBuildLogChunk = z.infer; + +// ─── cfb_list_builds ────────────────────────────────────────────────────────── + +export const cfbListBuildsInputSchema = z.object({ + pageSize: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Maximum number of builds to return (default: 25)."), +}); +export type CfbListBuildsInput = z.infer; + +export const cfbListBuildsOutputSchema = z.object({ + builds: z.array(cfBuildSchema), +}); +export type CfbListBuildsOutput = z.infer; + +export const cfbListBuildsTool = createTool({ + id: "cfb_list_builds", + description: + "List recent Cloudflare Workers Builds for the configured site, newest first. Returns build id, status, branch, commit, and duration.", + inputSchema: cfbListBuildsInputSchema, + outputSchema: cfbListBuildsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + const builds = (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/list.ts", + { + sitename: site, + ...(context.pageSize ? { pageSize: context.pageSize } : {}), + }, + apiKey, + )) as CfBuild[]; + return { builds }; + }, +}); + +// ─── cfb_get_build ──────────────────────────────────────────────────────────── + +export const cfbGetBuildInputSchema = z.object({ + buildId: z.string().describe("The Cloudflare build UUID."), +}); +export type CfbGetBuildInput = z.infer; + +export const cfbGetBuildOutputSchema = cfBuildSchema; +export type CfbGetBuildOutput = z.infer; + +export const cfbGetBuildTool = createTool({ + id: "cfb_get_build", + description: + "Fetch details for a single Cloudflare Workers build by UUID. Use this to refresh the status of a running build without re-listing.", + inputSchema: cfbGetBuildInputSchema, + outputSchema: cfbGetBuildOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/get.ts", + { sitename: site, buildId: context.buildId }, + apiKey, + )) as CfbGetBuildOutput; + }, +}); + +// ─── cfb_get_build_logs ─────────────────────────────────────────────────────── + +export const cfbGetBuildLogsInputSchema = z.object({ + buildId: z.string().describe("The Cloudflare build UUID."), +}); +export type CfbGetBuildLogsInput = z.infer; + +export const cfbGetBuildLogsOutputSchema = z.object({ + lines: z.array(cfBuildLogChunkSchema), +}); +export type CfbGetBuildLogsOutput = z.infer; + +export const cfbGetBuildLogsTool = createTool({ + id: "cfb_get_build_logs", + description: + "Fetch the full log output for a Cloudflare Workers build by UUID. Cloudflare returns the logs as a single blob; the UI paginates client-side for large logs.", + inputSchema: cfbGetBuildLogsInputSchema, + outputSchema: cfbGetBuildLogsOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/logs.ts", + { sitename: site, buildId: context.buildId }, + apiKey, + )) as CfbGetBuildLogsOutput; + }, +}); + +// ─── cfb_trigger_build ──────────────────────────────────────────────────────── + +export const cfbTriggerBuildInputSchema = z.object({}); +export type CfbTriggerBuildInput = z.infer; + +export const cfbTriggerBuildOutputSchema = cfBuildSchema; +export type CfbTriggerBuildOutput = z.infer; + +export const cfbTriggerBuildTool = createTool({ + id: "cfb_trigger_build", + description: + "Manually trigger a new Cloudflare Workers production build for the configured site. Returns the new build's metadata so the UI can poll its status.", + inputSchema: cfbTriggerBuildInputSchema, + outputSchema: cfbTriggerBuildOutputSchema, + _meta: { + ui: { resourceUri: CFB_BUILDS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/builds/trigger.ts", + { sitename: site }, + apiKey, + )) as CfbTriggerBuildOutput; + }, +}); diff --git a/api/tools/cfb-secrets.ts b/api/tools/cfb-secrets.ts new file mode 100644 index 0000000..def4e12 --- /dev/null +++ b/api/tools/cfb-secrets.ts @@ -0,0 +1,146 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_SECRETS_RESOURCE_URI = "ui://mcp-app/cfb-secrets"; + +// CF: secret/var names must match this regex. +const NAME_REGEX = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/; +const NAME_HINT = + "Letters, digits, and underscore only; must start with a letter or underscore; max 64 chars."; + +export const workerSecretTypeSchema = z.enum(["secret_text", "secret_key"]); +export type WorkerSecretType = z.infer; + +export const workerSecretBindingSchema = z.object({ + name: z.string(), + type: workerSecretTypeSchema, +}); +export type WorkerSecretBinding = z.infer; + +// ─── cfb_list_secrets ───────────────────────────────────────────────────────── + +export const cfbListSecretsInputSchema = z.object({}); +export type CfbListSecretsInput = z.infer; + +export const cfbListSecretsOutputSchema = z.object({ + secrets: z.array(workerSecretBindingSchema), +}); +export type CfbListSecretsOutput = z.infer; + +export const cfbListSecretsTool = createTool({ + id: "cfb_list_secrets", + description: + "List Cloudflare runtime secret names bound to the Worker for the configured site. Cloudflare never returns secret values — only names and types.", + inputSchema: cfbListSecretsInputSchema, + outputSchema: cfbListSecretsOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + const secrets = (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/secrets/list.ts", + { sitename: site }, + apiKey, + )) as WorkerSecretBinding[]; + return { secrets }; + }, +}); + +// ─── cfb_set_secret ─────────────────────────────────────────────────────────── + +export const cfbSetSecretInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe(`Secret name. ${NAME_HINT}`), + value: z.string().describe("Secret value (will be PUT to Cloudflare)."), + type: workerSecretTypeSchema + .optional() + .describe( + "Cloudflare secret type. `secret_text` (default) for plain strings; `secret_key` for cryptographic key material.", + ), +}); +export type CfbSetSecretInput = z.infer; + +export const cfbSetSecretOutputSchema = workerSecretBindingSchema; +export type CfbSetSecretOutput = z.infer; + +export const cfbSetSecretTool = createTool({ + id: "cfb_set_secret", + description: + "Create or update a Cloudflare runtime secret on the Worker for the configured site. Overwrites the value if a secret with the same name already exists.", + inputSchema: cfbSetSecretInputSchema, + outputSchema: cfbSetSecretOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/secrets/set.ts", + { + sitename: site, + name: context.name, + value: context.value, + ...(context.type ? { type: context.type } : {}), + }, + apiKey, + )) as CfbSetSecretOutput; + }, +}); + +// ─── cfb_delete_secret ──────────────────────────────────────────────────────── + +export const cfbDeleteSecretInputSchema = z.object({ + name: z + .string() + .regex(NAME_REGEX, NAME_HINT) + .describe("Secret name to remove."), +}); +export type CfbDeleteSecretInput = z.infer; + +export const cfbDeleteSecretOutputSchema = z.object({ + ok: z.literal(true), + name: z.string(), +}); +export type CfbDeleteSecretOutput = z.infer; + +export const cfbDeleteSecretTool = createTool({ + id: "cfb_delete_secret", + description: + "Permanently remove a Cloudflare runtime secret from the Worker for the configured site.", + inputSchema: cfbDeleteSecretInputSchema, + outputSchema: cfbDeleteSecretOutputSchema, + _meta: { + ui: { resourceUri: CFB_SECRETS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/secrets/delete.ts", + { sitename: site, name: context.name }, + apiKey, + ); + return { ok: true as const, name: context.name }; + }, +}); diff --git a/api/tools/cfb-setup.ts b/api/tools/cfb-setup.ts new file mode 100644 index 0000000..d5dedea --- /dev/null +++ b/api/tools/cfb-setup.ts @@ -0,0 +1,136 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; +import { cfBuildSchema } from "./cfb-builds.ts"; + +export const CFB_SETUP_RESOURCE_URI = "ui://mcp-app/cfb-setup"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const setupStepSchema = z.enum([ + "load_site", + "resolve_repo", + "connect_repo", + "ensure_worker", + "check_worker_collision", + "create_prod_trigger", + "create_preview_trigger", + "audit", +]); +export type SetupStep = z.infer; + +export const setupErrorSchema = z.object({ + step: setupStepSchema, + code: z.string(), + cfErrorCode: z.number().optional(), + message: z.string(), + hint: z.string().optional(), +}); +export type SetupError = z.infer; + +// ─── cfb_setup ──────────────────────────────────────────────────────────────── + +export const cfbSetupInputSchema = z.object({ + rootDirectory: z + .string() + .optional() + .describe( + "Root directory inside the repo to build from (defaults to repo root).", + ), + buildCommand: z + .string() + .optional() + .describe("Custom build command (defaults to the repo's wrangler config)."), + deployCommand: z + .string() + .optional() + .describe("Custom deploy command (defaults to `wrangler deploy`)."), +}); +export type CfbSetupInput = z.infer; + +export const cfbSetupOutputSchema = z.object({ + ok: z.boolean(), + workerName: z.string(), + workerExists: z.boolean(), + workerTag: z.string().nullable(), + repoConnectionId: z.string().nullable(), + prodTriggerUuid: z.string().nullable(), + previewTriggerUuid: z.string().nullable(), + errors: z.array(setupErrorSchema), +}); +export type CfbSetupOutput = z.infer; + +export const cfbSetupTool = createTool({ + id: "cfb_setup", + description: + "One-click Cloudflare Workers Builds onboarding for the configured site: connects the GitHub repo, ensures the Worker script exists, and creates the production + preview build triggers. Idempotent — safe to re-run; missing steps will be filled in and per-step errors are surfaced in `errors[]`.", + inputSchema: cfbSetupInputSchema, + outputSchema: cfbSetupOutputSchema, + _meta: { + ui: { resourceUri: CFB_SETUP_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/setup/oneClick.ts", + { + sitename: site, + ...(context.rootDirectory + ? { rootDirectory: context.rootDirectory } + : {}), + ...(context.buildCommand ? { buildCommand: context.buildCommand } : {}), + ...(context.deployCommand + ? { deployCommand: context.deployCommand } + : {}), + }, + apiKey, + )) as CfbSetupOutput; + }, +}); + +// ─── cfb_setup_status ───────────────────────────────────────────────────────── + +export const cfbSetupStatusInputSchema = z.object({}); +export type CfbSetupStatusInput = z.infer; + +export const cfbSetupStatusOutputSchema = z.object({ + workerName: z.string(), + workerExists: z.boolean(), + workerTag: z.string().nullable(), + repoConnected: z.boolean(), + prodTrigger: z.object({ trigger_uuid: z.string() }).nullable(), + previewTrigger: z.object({ trigger_uuid: z.string() }).nullable(), + lastBuild: cfBuildSchema.nullable(), +}); +export type CfbSetupStatusOutput = z.infer; + +export const cfbSetupStatusTool = createTool({ + id: "cfb_setup_status", + description: + "Read-only snapshot of the Cloudflare Workers Builds setup for the configured site: whether the repo is connected, the Worker exists, the prod/preview triggers exist, and the most recent build. Safe to call before any setup has happened.", + inputSchema: cfbSetupStatusInputSchema, + outputSchema: cfbSetupStatusOutputSchema, + _meta: { + ui: { resourceUri: CFB_SETUP_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/setup/status.ts", + { sitename: site }, + apiKey, + )) as CfbSetupStatusOutput; + }, +}); diff --git a/api/tools/cfb-versions.ts b/api/tools/cfb-versions.ts new file mode 100644 index 0000000..ddaa849 --- /dev/null +++ b/api/tools/cfb-versions.ts @@ -0,0 +1,132 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { callAdmin, getConfig } from "../lib/admin.ts"; + +export const CFB_VERSIONS_RESOURCE_URI = "ui://mcp-app/cfb-versions"; + +// ─── shared types ───────────────────────────────────────────────────────────── + +export const workerVersionSchema = z + .object({ + id: z.string(), + number: z.number().optional(), + created_on: z.string().optional(), + metadata: z + .object({ + has_assets: z.boolean().optional(), + has_modules: z.boolean().optional(), + annotations: z.record(z.string(), z.string()).optional(), + }) + .passthrough() + .optional(), + resources: z + .object({ + bindings: z.array(z.record(z.string(), z.unknown())).optional(), + script: z + .object({ etag: z.string().optional() }) + .passthrough() + .optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); +export type WorkerVersion = z.infer; + +export const versionRowSchema = workerVersionSchema.and( + z.object({ isActive: z.boolean() }), +); +export type VersionRow = z.infer; + +export const workerDeploymentSchema = z + .object({ + id: z.string(), + source: z.string().optional(), + strategy: z.string().optional(), + versions: z.array( + z.object({ version_id: z.string(), percentage: z.number() }), + ), + annotations: z.record(z.string(), z.string()).optional(), + created_on: z.string().optional(), + author_email: z.string().optional(), + }) + .passthrough(); +export type WorkerDeployment = z.infer; + +// ─── cfb_list_versions ──────────────────────────────────────────────────────── + +export const cfbListVersionsInputSchema = z.object({}); +export type CfbListVersionsInput = z.infer; + +export const cfbListVersionsOutputSchema = z.object({ + versions: z.array(versionRowSchema), +}); +export type CfbListVersionsOutput = z.infer; + +export const cfbListVersionsTool = createTool({ + id: "cfb_list_versions", + description: + "List the most recent Cloudflare Worker versions for the configured site (last 100). Each row is flagged `isActive` if it receives any traffic in the current deployment.", + inputSchema: cfbListVersionsInputSchema, + outputSchema: cfbListVersionsOutputSchema, + _meta: { + ui: { resourceUri: CFB_VERSIONS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + execute: async (_input, ctx) => { + const { site, apiKey } = getConfig(ctx); + const versions = (await callAdmin( + "deco-sites/admin/loaders/hosting/cfworkers-builds/versions/list.ts", + { sitename: site }, + apiKey, + )) as VersionRow[]; + return { versions }; + }, +}); + +// ─── cfb_rollback ───────────────────────────────────────────────────────────── + +export const cfbRollbackInputSchema = z.object({ + versionId: z + .string() + .describe( + "The Worker version id to roll back to (must be in the last 100 versions returned by `cfb_list_versions`).", + ), +}); +export type CfbRollbackInput = z.infer; + +export const cfbRollbackOutputSchema = z.object({ + deployment: workerDeploymentSchema, + warnings: z.array(z.string()), +}); +export type CfbRollbackOutput = z.infer; + +export const cfbRollbackTool = createTool({ + id: "cfb_rollback", + description: + "Roll the Worker for the configured site back to a previous version by creating a new 100% deployment of that version. Best-effort compatibility check warns about binding-shape mismatches; Cloudflare may still reject incompatible rollbacks for D1/R2/KV/DO data shape changes.", + inputSchema: cfbRollbackInputSchema, + outputSchema: cfbRollbackOutputSchema, + _meta: { + ui: { resourceUri: CFB_VERSIONS_RESOURCE_URI, visibility: ["app"] }, + }, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, + }, + execute: async ({ context }, ctx) => { + const { site, apiKey } = getConfig(ctx); + return (await callAdmin( + "deco-sites/admin/actions/hosting/cfworkers-builds/deployments/rollback.ts", + { sitename: site, versionId: context.versionId }, + apiKey, + )) as CfbRollbackOutput; + }, +}); diff --git a/api/tools/index.ts b/api/tools/index.ts index 6db2f69..002e5fd 100644 --- a/api/tools/index.ts +++ b/api/tools/index.ts @@ -1,5 +1,23 @@ import { analyticsQueryTool } from "./analytics-query.ts"; import { assetsTool, deleteAssetTool, uploadAssetTool } from "./assets.ts"; +import { + cfbDeleteBuildVarTool, + cfbListBuildVarsTool, + cfbSetBuildVarTool, +} from "./cfb-build-vars.ts"; +import { + cfbGetBuildLogsTool, + cfbGetBuildTool, + cfbListBuildsTool, + cfbTriggerBuildTool, +} from "./cfb-builds.ts"; +import { + cfbDeleteSecretTool, + cfbListSecretsTool, + cfbSetSecretTool, +} from "./cfb-secrets.ts"; +import { cfbSetupStatusTool, cfbSetupTool } from "./cfb-setup.ts"; +import { cfbListVersionsTool, cfbRollbackTool } from "./cfb-versions.ts"; import { suggestCommitMessageTool } from "./commit-summary.ts"; import { createEnvironmentTool, @@ -127,4 +145,18 @@ export const tools = [ podLogsTool, renderHtmlTool, testLoaderTool, + cfbSetupTool, + cfbSetupStatusTool, + cfbListSecretsTool, + cfbSetSecretTool, + cfbDeleteSecretTool, + cfbListBuildVarsTool, + cfbSetBuildVarTool, + cfbDeleteBuildVarTool, + cfbListBuildsTool, + cfbGetBuildTool, + cfbGetBuildLogsTool, + cfbTriggerBuildTool, + cfbListVersionsTool, + cfbRollbackTool, ]; diff --git a/web/router.tsx b/web/router.tsx index fec5d27..97721db 100644 --- a/web/router.tsx +++ b/web/router.tsx @@ -8,6 +8,11 @@ import { } from "@tanstack/react-router"; import { useMcpHostContext, useMcpState } from "./context.tsx"; import AssetsPage from "./tools/assets/index.tsx"; +import CfbBuildVarsPage from "./tools/cfb-build-vars/index.tsx"; +import CfbBuildsPage from "./tools/cfb-builds/index.tsx"; +import CfbSecretsPage from "./tools/cfb-secrets/index.tsx"; +import CfbSetupPage from "./tools/cfb-setup/index.tsx"; +import CfbVersionsPage from "./tools/cfb-versions/index.tsx"; import FileExplorerPage from "./tools/file-explorer/index.tsx"; import IssuesPage from "./tools/issues/index.tsx"; import MonitorPage from "./tools/monitor/index.tsx"; @@ -23,6 +28,20 @@ const TOOL_PAGES: Record = { list_pull_requests: PullRequestsPage, list_releases: ReleasesPage, render_html: RenderHtmlPage, + cfb_setup: CfbSetupPage, + cfb_setup_status: CfbSetupPage, + cfb_list_secrets: CfbSecretsPage, + cfb_set_secret: CfbSecretsPage, + cfb_delete_secret: CfbSecretsPage, + cfb_list_build_vars: CfbBuildVarsPage, + cfb_set_build_var: CfbBuildVarsPage, + cfb_delete_build_var: CfbBuildVarsPage, + cfb_list_builds: CfbBuildsPage, + cfb_get_build: CfbBuildsPage, + cfb_get_build_logs: CfbBuildsPage, + cfb_trigger_build: CfbBuildsPage, + cfb_list_versions: CfbVersionsPage, + cfb_rollback: CfbVersionsPage, }; function ToolRouter() { diff --git a/web/tools/cfb-build-vars/index.tsx b/web/tools/cfb-build-vars/index.tsx new file mode 100644 index 0000000..132093f --- /dev/null +++ b/web/tools/cfb-build-vars/index.tsx @@ -0,0 +1,175 @@ +import { AlertTriangle, CheckCircle2, Loader2, Variable } from "lucide-react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpState } from "@/context.tsx"; +import type { + BuildVarsResult, + CfbDeleteBuildVarOutput, + CfbListBuildVarsOutput, + CfbSetBuildVarOutput, +} from "../../../api/tools/cfb-build-vars.ts"; + +type AnyOutput = + | CfbListBuildVarsOutput + | CfbSetBuildVarOutput + | CfbDeleteBuildVarOutput; + +function VarsTable({ data }: { data: BuildVarsResult }) { + const entries = Object.entries(data.buildVars); + + return ( + + +
+ + + Build vars ({entries.length}) + + + trigger {data.triggerUuid.slice(0, 8)} + +
+
+ + {entries.length === 0 ? ( +
+ No build vars. Use{" "} + + cfb_set_build_var + {" "} + to add one. +
+ ) : ( + + + + Name + Value + + + + {entries.map(([name, value]) => ( + + {name} + + {value} + + + ))} + +
+ )} +
+
+ ); +} + +export default function CfbBuildVarsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Build vars + + +

+ Call{" "} + + cfb_list_build_vars + {" "} + to view build-time variables. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + const justChanged = + state.toolName === "cfb_set_build_var" || + state.toolName === "cfb_delete_build_var"; + + return ( +
+
+

Build-time variables

+

+ + Values are plain-text and visible to anyone with site access. Use{" "} + cfb_set_secret for + sensitive values. +

+
+ + {justChanged && ( +
+ + {state.toolName === "cfb_set_build_var" + ? "Build var updated." + : "Build var removed."} +
+ )} + + +
+ ); +} diff --git a/web/tools/cfb-builds/index.tsx b/web/tools/cfb-builds/index.tsx new file mode 100644 index 0000000..4a25f90 --- /dev/null +++ b/web/tools/cfb-builds/index.tsx @@ -0,0 +1,426 @@ +import { + CheckCircle2, + ChevronDown, + ChevronRight, + CircleDashed, + Clock, + GitBranch, + GitCommit, + Loader2, + XCircle, + Zap, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import { cn } from "@/lib/utils.ts"; +import type { + CfBuild, + CfBuildLogChunk, + CfBuildStatus, + CfbGetBuildLogsOutput, + CfbGetBuildOutput, + CfbListBuildsOutput, + CfbTriggerBuildOutput, +} from "../../../api/tools/cfb-builds.ts"; + +type AnyOutput = + | CfbListBuildsOutput + | CfbGetBuildOutput + | CfbGetBuildLogsOutput + | CfbTriggerBuildOutput; + +// ─── helpers ────────────────────────────────────────────────────────────────── + +function statusBadge(status: CfBuildStatus) { + const map: Record< + CfBuildStatus, + { label: string; className: string; Icon: typeof Loader2 } + > = { + queued: { + label: "Queued", + className: "bg-muted text-muted-foreground", + Icon: CircleDashed, + }, + initializing: { + label: "Initializing", + className: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + Icon: Loader2, + }, + running: { + label: "Running", + className: "bg-blue-500/10 text-blue-600 dark:text-blue-400", + Icon: Loader2, + }, + success: { + label: "Success", + className: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", + Icon: CheckCircle2, + }, + failure: { + label: "Failure", + className: "bg-destructive/10 text-destructive", + Icon: XCircle, + }, + cancelled: { + label: "Cancelled", + className: "bg-muted text-muted-foreground", + Icon: XCircle, + }, + }; + const m = map[status]; + const spin = status === "running" || status === "initializing"; + return ( + + + {m.label} + + ); +} + +function formatDuration(ms?: number) { + if (ms === undefined) return "—"; + if (ms < 1000) return `${ms}ms`; + const s = Math.round(ms / 100) / 10; + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + const rem = Math.round(s - m * 60); + return `${m}m ${rem}s`; +} + +function timeAgo(iso: string) { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// ─── BuildLogs ──────────────────────────────────────────────────────────────── + +function BuildLogs({ + buildId, + status, +}: { + buildId: string; + status: CfBuildStatus; +}) { + const app = useMcpApp(); + const [logs, setLogs] = useState(null); + const [error, setError] = useState(); + const [loading, setLoading] = useState(true); + const isRunning = status === "running" || status === "initializing"; + + const fetchLogs = useCallback(async () => { + if (!app) return; + try { + const res = await app.callServerTool({ + name: "cfb_get_build_logs", + arguments: { buildId }, + }); + if (res?.isError) { + const text = res.content?.find((c) => c.type === "text"); + setError(text?.type === "text" ? text.text : "Failed to fetch logs"); + return; + } + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + const parsed = JSON.parse(text.text) as CfbGetBuildLogsOutput; + setLogs(parsed.lines); + } catch { + setError("Could not parse logs response"); + } + } + } finally { + setLoading(false); + } + }, [app, buildId]); + + useEffect(() => { + fetchLogs(); + if (!isRunning) return; + const id = setInterval(fetchLogs, 2_000); + return () => clearInterval(id); + }, [fetchLogs, isRunning]); + + if (loading) { + return ( +
+ + Fetching logs… +
+ ); + } + if (error) { + return
{error}
; + } + if (!logs || logs.length === 0) { + return ( +
No log lines yet.
+ ); + } + return ( +
+			{logs.map((l) => (
+				
+ {l.line} + {l.message} +
+ ))} +
+ ); +} + +// ─── BuildRow ───────────────────────────────────────────────────────────────── + +function BuildRow({ build }: { build: CfBuild }) { + const [expanded, setExpanded] = useState(false); + const shortSha = build.commit_hash?.slice(0, 7); + const buildId = build.build_uuid; + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +// ─── views ──────────────────────────────────────────────────────────────────── + +function BuildsList({ builds }: { builds: CfBuild[] }) { + const app = useMcpApp(); + const [triggering, setTriggering] = useState(false); + + const handleTrigger = async () => { + if (!app) return; + setTriggering(true); + try { + await app.callServerTool({ + name: "cfb_trigger_build", + arguments: {}, + }); + } finally { + setTriggering(false); + } + }; + + return ( +
+
+
+

Builds

+

+ {builds.length} recent build{builds.length === 1 ? "" : "s"} +

+
+ +
+ {builds.length === 0 ? ( + + + No builds yet. + + + ) : ( + + {builds.map((b) => ( + + ))} + + )} +
+ ); +} + +function SingleBuild({ build }: { build: CfBuild }) { + return ( +
+
+

Build details

+ {statusBadge(build.status)} +
+ + + +
+ ); +} + +function StandaloneLogs({ logs }: { logs: CfBuildLogChunk[] }) { + return ( +
+

Build logs

+ + + {logs.length === 0 ? ( +

No log lines.

+ ) : ( +
+							{logs.map((l) => (
+								
+ {l.line} + {l.message} +
+ ))} +
+ )} +
+
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbBuildsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Builds + + +

+ Call{" "} + + cfb_list_builds + {" "} + to view recent Cloudflare builds. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+ {state.toolName === "cfb_list_builds" && "builds" in result ? ( + + ) : state.toolName === "cfb_get_build_logs" && "lines" in result ? ( + + ) : ( + + )} +
+ ); +} diff --git a/web/tools/cfb-secrets/index.tsx b/web/tools/cfb-secrets/index.tsx new file mode 100644 index 0000000..e60f818 --- /dev/null +++ b/web/tools/cfb-secrets/index.tsx @@ -0,0 +1,221 @@ +import { CheckCircle2, Key, Loader2, Trash2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpState } from "@/context.tsx"; +import type { + CfbDeleteSecretOutput, + CfbListSecretsOutput, + CfbSetSecretOutput, + WorkerSecretBinding, +} from "../../../api/tools/cfb-secrets.ts"; + +type AnyOutput = + | CfbListSecretsOutput + | CfbSetSecretOutput + | CfbDeleteSecretOutput; + +function isListOutput(o: AnyOutput): o is CfbListSecretsOutput { + return "secrets" in o && Array.isArray((o as CfbListSecretsOutput).secrets); +} + +function isDeleteOutput(o: AnyOutput): o is CfbDeleteSecretOutput { + return "ok" in o && "name" in o; +} + +function isSetOutput(o: AnyOutput): o is CfbSetSecretOutput { + return "name" in o && "type" in o; +} + +// ─── views ──────────────────────────────────────────────────────────────────── + +function SecretsTable({ secrets }: { secrets: WorkerSecretBinding[] }) { + if (secrets.length === 0) { + return ( +
+ + No secrets set. Use{" "} + + cfb_set_secret + {" "} + to add one. +
+ ); + } + + return ( + + + + + Name + Type + + + + {secrets.map((s) => ( + + {s.name} + + + {s.type} + + + + ))} + +
+
+ ); +} + +function SetSuccess({ binding }: { binding: CfbSetSecretOutput }) { + return ( + + + + + Secret saved + + + +
+ Name: + + {binding.name} + +
+
+ Type: + + {binding.type} + +
+

+ Cloudflare never returns secret values — the value you set is now + encrypted at rest on the Worker. +

+
+
+ ); +} + +function DeleteSuccess({ result }: { result: CfbDeleteSecretOutput }) { + return ( + + + + + Secret deleted + + + + + {result.name} + {" "} + is no longer bound to the Worker. + + + ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbSecretsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Secrets + + +

+ Call{" "} + + cfb_list_secrets + {" "} + to view the secrets bound to this Worker. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+
+

Worker secrets

+

+ Runtime secrets bound to the Cloudflare Worker. Values are never + returned by Cloudflare. +

+
+ {isListOutput(result) ? ( + + ) : isDeleteOutput(result) ? ( + + ) : isSetOutput(result) ? ( + + ) : null} +
+ ); +} diff --git a/web/tools/cfb-setup/index.tsx b/web/tools/cfb-setup/index.tsx new file mode 100644 index 0000000..b780cf4 --- /dev/null +++ b/web/tools/cfb-setup/index.tsx @@ -0,0 +1,322 @@ +import { + AlertTriangle, + Check, + CheckCircle2, + GitBranch, + HardDrive, + Loader2, + RefreshCw, + X, + Zap, +} from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import { cn } from "@/lib/utils.ts"; +import type { + CfbSetupInput, + CfbSetupOutput, + CfbSetupStatusOutput, + SetupError, +} from "../../../api/tools/cfb-setup.ts"; + +type AnyOutput = CfbSetupOutput | CfbSetupStatusOutput; + +function isSetupResult(o: AnyOutput): o is CfbSetupOutput { + return "ok" in o && "errors" in o; +} + +// ─── StatusRow ──────────────────────────────────────────────────────────────── + +function StatusRow({ + label, + ok, + detail, + Icon, +}: { + label: string; + ok: boolean; + detail?: string; + Icon: typeof GitBranch; +}) { + return ( +
+ + {label} + + {detail} + + {ok ? ( + + ) : ( + + )} +
+ ); +} + +// ─── ErrorList ──────────────────────────────────────────────────────────────── + +function ErrorList({ errors }: { errors: SetupError[] }) { + if (errors.length === 0) return null; + return ( +
+
+ + {errors.length} step{errors.length === 1 ? "" : "s"} failed +
+
    + {errors.map((e, idx) => ( +
  • + + + {e.step} + {" "} + · {e.message} + {e.cfErrorCode !== undefined ? ( + (CF {e.cfErrorCode}) + ) : null} + + {e.hint && {e.hint}} +
  • + ))} +
+
+ ); +} + +// ─── SetupView ──────────────────────────────────────────────────────────────── + +function SetupView({ data }: { data: AnyOutput }) { + const app = useMcpApp(); + const [running, setRunning] = useState(false); + const [latest, setLatest] = useState(data); + + const result: CfbSetupOutput | null = isSetupResult(latest) ? latest : null; + const status: CfbSetupStatusOutput | null = isSetupResult(latest) + ? null + : latest; + + const workerName = latest.workerName; + const workerTag = latest.workerTag; + const workerExists = latest.workerExists; + const repoConnected = result + ? !!result.repoConnectionId + : !!status?.repoConnected; + const prodTriggerUuid = result + ? result.prodTriggerUuid + : (status?.prodTrigger?.trigger_uuid ?? null); + const previewTriggerUuid = result + ? result.previewTriggerUuid + : (status?.previewTrigger?.trigger_uuid ?? null); + + const allWired = + workerExists && repoConnected && !!prodTriggerUuid && !!previewTriggerUuid; + const errors = result?.errors ?? []; + + const handleSetup = async () => { + if (!app) return; + setRunning(true); + try { + const res = await app.callServerTool({ + name: "cfb_setup", + arguments: {} satisfies CfbSetupInput, + }); + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + setLatest(JSON.parse(text.text) as CfbSetupOutput); + } catch { + // keep previous + } + } + } finally { + setRunning(false); + } + }; + + return ( +
+
+
+

Cloudflare Workers Builds

+

+ Worker:{" "} + + {workerName} + + {workerTag ? ( + <> + {" · tag "} + + {workerTag.slice(0, 12)} + + + ) : null} +

+
+ {allWired ? ( + + + Fully wired + + ) : ( + Needs setup + )} +
+ + + + Setup state + + + + + + + + + + + +
+ + {result && ( + + {result.ok ? "Last run succeeded" : "Last run had errors"} + + )} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbSetupPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting to host…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + + Cloudflare Workers Builds + + + +

+ Call{" "} + + cfb_setup_status + {" "} + or{" "} + + cfb_setup + {" "} + to begin. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + if (!state.toolResult) return null; + + return ( +
+ +
+ ); +} diff --git a/web/tools/cfb-versions/index.tsx b/web/tools/cfb-versions/index.tsx new file mode 100644 index 0000000..c008d44 --- /dev/null +++ b/web/tools/cfb-versions/index.tsx @@ -0,0 +1,375 @@ +import { + AlertTriangle, + CheckCircle2, + Clock, + Loader2, + RotateCcw, +} from "lucide-react"; +import { useState } from "react"; +import { Badge } from "@/components/ui/badge.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog.tsx"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table.tsx"; +import { useMcpApp, useMcpState } from "@/context.tsx"; +import type { + CfbListVersionsOutput, + CfbRollbackOutput, + VersionRow, +} from "../../../api/tools/cfb-versions.ts"; + +type AnyOutput = CfbListVersionsOutput | CfbRollbackOutput; + +function shortId(id: string) { + return id.slice(0, 8); +} + +function formatDate(iso?: string) { + if (!iso) return "—"; + return new Date(iso).toLocaleString(); +} + +// ─── RollbackDialog ─────────────────────────────────────────────────────────── + +type DialogState = "idle" | "running" | "done" | "error"; + +function RollbackDialog({ + version, + onClose, +}: { + version: VersionRow | null; + onClose: () => void; +}) { + const app = useMcpApp(); + const [state, setState] = useState("idle"); + const [error, setError] = useState(); + const [warnings, setWarnings] = useState([]); + + if (!version) return null; + + const handleRollback = async () => { + if (!app) return; + setState("running"); + setError(undefined); + try { + const res = await app.callServerTool({ + name: "cfb_rollback", + arguments: { versionId: version.id }, + }); + if (res?.isError) { + const text = res.content?.find((c) => c.type === "text"); + throw new Error(text?.type === "text" ? text.text : "Rollback failed"); + } + const text = res?.content?.find((c) => c.type === "text"); + if (text?.type === "text") { + try { + const parsed = JSON.parse(text.text) as CfbRollbackOutput; + setWarnings(parsed.warnings ?? []); + } catch { + // no warnings parsed + } + } + setState("done"); + } catch (err) { + setError(err instanceof Error ? err.message : "Rollback failed"); + setState("error"); + } + }; + + const handleClose = () => { + setState("idle"); + setError(undefined); + setWarnings([]); + onClose(); + }; + + return ( + !open && handleClose()}> + + + + + Roll back to version + + + This creates a new deployment that sends 100% of traffic to version{" "} + + {shortId(version.id)} + + . Cloudflare may reject the rollback if the version's bindings have + incompatible data shapes (D1, R2, KV, DO). + + + +
+ +
+ Rolling back does NOT revert build vars — vars are part of the new + deployment. +
+
+ + {state === "done" && ( +
+ + Rollback applied. +
+ )} + {warnings.length > 0 && ( +
    + {warnings.map((w) => ( +
  • {w}
  • + ))} +
+ )} + {state === "error" && ( +

{error}

+ )} + + + + {state !== "done" && ( + + )} + +
+
+ ); +} + +// ─── VersionsTable ──────────────────────────────────────────────────────────── + +function VersionsTable({ versions }: { versions: VersionRow[] }) { + const [target, setTarget] = useState(null); + + return ( + <> + + + + + Version + # + Created + Status + + + + + {versions.map((v) => ( + + + {shortId(v.id)} + + + {v.number ?? "—"} + + + + + {formatDate(v.created_on)} + + + + {v.isActive ? ( + + + Active + + ) : ( + + Available + + )} + + + + + + ))} + +
+
+ setTarget(null)} /> + + ); +} + +// ─── RollbackResultView ─────────────────────────────────────────────────────── + +function RollbackResultView({ result }: { result: CfbRollbackOutput }) { + return ( + + + + + Rollback applied + + + +
+ Deployment id:{" "} + + {result.deployment.id} + +
+ {result.warnings.length > 0 && ( +
+
+ + {result.warnings.length} warning + {result.warnings.length === 1 ? "" : "s"} +
+
    + {result.warnings.map((w) => ( +
  • {w}
  • + ))} +
+
+ )} +
+
+ ); +} + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function CfbVersionsPage() { + const state = useMcpState(); + + if (state.status === "initializing" || state.status === "tool-input") { + return ( +
+
+ + + {state.status === "tool-input" ? "Working…" : "Connecting…"} + +
+
+ ); + } + + if (state.status === "connected") { + return ( +
+ + + Versions + + +

+ Call{" "} + + cfb_list_versions + {" "} + to view the last 100 Worker versions. +

+
+
+
+ ); + } + + if (state.status === "error") { + return ( +
+ + + Error + + +

+ {state.error ?? "Unknown error"} +

+
+
+
+ ); + } + + if (state.status === "tool-cancelled") { + return ( +
+

+ Tool call was cancelled. +

+
+ ); + } + + const result = state.toolResult; + if (!result) return null; + + return ( +
+ {state.toolName === "cfb_list_versions" && "versions" in result ? ( + <> +
+

Worker versions

+

+ Last 100 versions. The active version receives 100% of traffic + unless a gradual deployment is in progress. +

+
+ + + ) : ( + + )} +
+ ); +} From c90bebd69abebfdfe60d8b2983889c2a96dd3d0f Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 12 May 2026 18:07:39 -0300 Subject: [PATCH 2/5] deploy to cloudflare workers Split api/main.ts into a shared createApp factory plus Bun and Workers entries. Resources are now factories that take a getClientHTML closure, so the Workers entry can read the bundled HTML via env.ASSETS.fetch while the Bun entry keeps using node:fs. Adds wrangler.toml with [assets] directory = "dist/client" (serves index.html + Monaco worker JS chunks) and an env.preview block for staging. Bump NODE_OPTIONS heap on build:web to fix the previous Vite OOM. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 1 + api/{main.ts => app.ts} | 77 ++++++++++++++++++---------------- api/main.bun.ts | 27 ++++++++++++ api/main.workers.ts | 32 ++++++++++++++ api/resources/assets.ts | 47 +++++++++------------ api/resources/environments.ts | 50 ++++++++++------------ api/resources/file-explorer.ts | 49 ++++++++++------------ api/resources/issues.ts | 37 +++++++--------- api/resources/monitor.ts | 50 ++++++++++------------ api/resources/pull-requests.ts | 39 ++++++++--------- api/resources/releases.ts | 40 ++++++++---------- api/resources/render-html.ts | 39 ++++++++--------- biome.json | 3 +- package.json | 12 ++++-- wrangler.toml | 23 ++++++++++ 15 files changed, 286 insertions(+), 240 deletions(-) rename api/{main.ts => app.ts} (62%) create mode 100644 api/main.bun.ts create mode 100644 api/main.workers.ts create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore index 9b35467..08e01e0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ data/ bun.lock *.tsbuildinfo .context/ +.wrangler/ diff --git a/api/main.ts b/api/app.ts similarity index 62% rename from api/main.ts rename to api/app.ts index 3dc5bfa..226749b 100644 --- a/api/main.ts +++ b/api/app.ts @@ -1,21 +1,18 @@ import { withRuntime } from "@decocms/runtime"; -import { storefrontSkillsPrompts } from "./prompts/storefront-skills.ts"; -import { assetsAppResource } from "./resources/assets.ts"; -import { environmentsAppResource } from "./resources/environments.ts"; -import { fileExplorerAppResource } from "./resources/file-explorer.ts"; -import { issuesAppResource } from "./resources/issues.ts"; -import { monitorAppResource } from "./resources/monitor.ts"; -import { pullRequestsAppResource } from "./resources/pull-requests.ts"; -import { releasesAppResource } from "./resources/releases.ts"; -import { renderHtmlAppResource } from "./resources/render-html.ts"; +import { createAssetsAppResource } from "./resources/assets.ts"; +import { createEnvironmentsAppResource } from "./resources/environments.ts"; +import { createFileExplorerAppResource } from "./resources/file-explorer.ts"; +import { createIssuesAppResource } from "./resources/issues.ts"; +import { createMonitorAppResource } from "./resources/monitor.ts"; +import { createPullRequestsAppResource } from "./resources/pull-requests.ts"; +import { createReleasesAppResource } from "./resources/releases.ts"; +import { createRenderHtmlAppResource } from "./resources/render-html.ts"; import { tools } from "./tools/index.ts"; import { type Env, StateSchema } from "./types/env.ts"; // biome-ignore lint/suspicious/noExplicitAny: runtime.fetch signature compatibility type Fetcher = (req: Request, ...args: any[]) => Response | Promise; -const PORT = process.env.PORT ? Number(process.env.PORT) : 3001; - const colors = { reset: "\x1b[0m", dim: "\x1b[2m", @@ -43,24 +40,6 @@ function getMethodColor(method: string): string { return colors[method as keyof typeof colors] || colors.reset; } -const runtime = withRuntime({ - configuration: { - state: StateSchema, - }, - tools, - prompts: storefrontSkillsPrompts, - resources: [ - assetsAppResource, - environmentsAppResource, - fileExplorerAppResource, - issuesAppResource, - monitorAppResource, - pullRequestsAppResource, - releasesAppResource, - renderHtmlAppResource, - ], -}); - function withLogging(fetcher: Fetcher): Fetcher { return async (req: Request, ...args) => { const start = performance.now(); @@ -111,12 +90,36 @@ function withMcpApiRoute(fetcher: Fetcher): Fetcher { }; } -Bun.serve({ - idleTimeout: 0, - hostname: "0.0.0.0", - port: PORT, - fetch: withLogging(withMcpApiRoute(runtime.fetch)), -}); +// biome-ignore lint/suspicious/noExplicitAny: prompts factory shape is opaque to us +type PromptsOption = any; + +export interface CreateAppOptions { + getClientHTML: () => Promise; + // Optional. Bun entry passes the filesystem-walking factory; Workers entry + // omits it (the module that produces prompts touches `import.meta.dir`). + prompts?: PromptsOption; +} -console.log(`MCP App server started on http://localhost:${PORT}`); -console.log(`- MCP endpoint: http://localhost:${PORT}/api/mcp`); +export function createApp(opts: CreateAppOptions): Fetcher { + const { getClientHTML, prompts } = opts; + + const runtime = withRuntime({ + configuration: { + state: StateSchema, + }, + tools, + ...(prompts !== undefined ? { prompts } : {}), + resources: [ + createAssetsAppResource(getClientHTML), + createEnvironmentsAppResource(getClientHTML), + createFileExplorerAppResource(getClientHTML), + createIssuesAppResource(getClientHTML), + createMonitorAppResource(getClientHTML), + createPullRequestsAppResource(getClientHTML), + createReleasesAppResource(getClientHTML), + createRenderHtmlAppResource(getClientHTML), + ], + }); + + return withLogging(withMcpApiRoute(runtime.fetch)); +} diff --git a/api/main.bun.ts b/api/main.bun.ts new file mode 100644 index 0000000..a5ee1eb --- /dev/null +++ b/api/main.bun.ts @@ -0,0 +1,27 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createApp } from "./app.ts"; +import { storefrontSkillsPrompts } from "./prompts/storefront-skills.ts"; + +const PORT = process.env.PORT ? Number(process.env.PORT) : 3001; + +function getDistPath(): string { + const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : ".."); + return join(projectRoot, "dist", "client", "index.html"); +} + +const handler = createApp({ + getClientHTML: () => readFile(getDistPath(), "utf-8"), + prompts: storefrontSkillsPrompts, +}); + +Bun.serve({ + idleTimeout: 0, + hostname: "0.0.0.0", + port: PORT, + fetch: handler, +}); + +console.log(`MCP App server started on http://localhost:${PORT}`); +console.log(`- MCP endpoint: http://localhost:${PORT}/api/mcp`); diff --git a/api/main.workers.ts b/api/main.workers.ts new file mode 100644 index 0000000..b0ded3b --- /dev/null +++ b/api/main.workers.ts @@ -0,0 +1,32 @@ +import { createApp } from "./app.ts"; + +interface AssetsFetcher { + fetch(input: string | Request): Promise; +} + +interface WorkerEnv { + ASSETS: AssetsFetcher; +} + +let cachedHandler: ReturnType | null = null; + +function getHandler(env: WorkerEnv): ReturnType { + if (cachedHandler) return cachedHandler; + + let cachedHTML: string | null = null; + const getClientHTML = async (): Promise => { + if (cachedHTML !== null) return cachedHTML; + const res = await env.ASSETS.fetch("https://assets.local/index.html"); + cachedHTML = await res.text(); + return cachedHTML; + }; + + cachedHandler = createApp({ getClientHTML }); + return cachedHandler; +} + +export default { + fetch(request: Request, env: WorkerEnv, ctx: unknown): Promise { + return Promise.resolve(getHandler(env)(request, env, ctx)); + }, +}; diff --git a/api/resources/assets.ts b/api/resources/assets.ts index 22a371f..2e1eacd 100644 --- a/api/resources/assets.ts +++ b/api/resources/assets.ts @@ -1,34 +1,27 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { ASSETS_RESOURCE_URI } from "../tools/assets.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const assetsAppResource = createPublicResource({ - uri: ASSETS_RESOURCE_URI, - name: "Assets UI", - description: "Interactive media asset gallery for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: ASSETS_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - connectDomains: ["https://admin.deco.cx"], +export const createAssetsAppResource = (getClientHTML: () => Promise) => + createPublicResource({ + uri: ASSETS_RESOURCE_URI, + name: "Assets UI", + description: "Interactive media asset gallery for deco.cx sites", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: ASSETS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: ["https://admin.deco.cx"], + }, }, }, - }, - }; - }, -}); + }; + }, + }); diff --git a/api/resources/environments.ts b/api/resources/environments.ts index 143cc17..9a584b0 100644 --- a/api/resources/environments.ts +++ b/api/resources/environments.ts @@ -1,16 +1,8 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { ENVIRONMENTS_RESOURCE_URI } from "../tools/environments.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - // Domains that deco.cx environments can be served from — used for preview iframes export const PREVIEW_FRAME_DOMAINS = [ "https://*.decocdn.com", @@ -20,25 +12,27 @@ export const PREVIEW_FRAME_DOMAINS = [ "https://*.deco.cx", ]; -export const environmentsAppResource = createPublicResource({ - uri: ENVIRONMENTS_RESOURCE_URI, - name: "Environments UI", - description: "Interactive environment management for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: ENVIRONMENTS_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - // Unlock frame-src so the preview tool can embed deco environments - _meta: { - ui: { - csp: { - frameDomains: PREVIEW_FRAME_DOMAINS, +export const createEnvironmentsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: ENVIRONMENTS_RESOURCE_URI, + name: "Environments UI", + description: "Interactive environment management for deco.cx sites", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: ENVIRONMENTS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + frameDomains: PREVIEW_FRAME_DOMAINS, + }, }, }, - }, - }; - }, -}); + }; + }, + }); diff --git a/api/resources/file-explorer.ts b/api/resources/file-explorer.ts index 324ce19..f552be3 100644 --- a/api/resources/file-explorer.ts +++ b/api/resources/file-explorer.ts @@ -1,35 +1,30 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { FILE_EXPLORER_RESOURCE_URI } from "../tools/files.ts"; import { PREVIEW_FRAME_DOMAINS } from "./environments.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const fileExplorerAppResource = createPublicResource({ - uri: FILE_EXPLORER_RESOURCE_URI, - name: "File Explorer UI", - description: "Interactive filesystem explorer for sandbox environments", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: FILE_EXPLORER_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - _meta: { - ui: { - csp: { - frameDomains: PREVIEW_FRAME_DOMAINS, +export const createFileExplorerAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: FILE_EXPLORER_RESOURCE_URI, + name: "File Explorer UI", + description: "Interactive filesystem explorer for sandbox environments", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: FILE_EXPLORER_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + frameDomains: PREVIEW_FRAME_DOMAINS, + }, }, }, - }, - }; - }, -}); + }; + }, + }); diff --git a/api/resources/issues.ts b/api/resources/issues.ts index a0b12c7..30b8dfb 100644 --- a/api/resources/issues.ts +++ b/api/resources/issues.ts @@ -1,27 +1,20 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { ISSUES_RESOURCE_URI } from "../tools/issues.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const issuesAppResource = createPublicResource({ - uri: ISSUES_RESOURCE_URI, - name: "Issues UI", - description: "Interactive issue listing for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: ISSUES_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - }; - }, -}); +export const createIssuesAppResource = (getClientHTML: () => Promise) => + createPublicResource({ + uri: ISSUES_RESOURCE_URI, + name: "Issues UI", + description: "Interactive issue listing for deco.cx sites", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: ISSUES_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/monitor.ts b/api/resources/monitor.ts index 164d41e..6c07a76 100644 --- a/api/resources/monitor.ts +++ b/api/resources/monitor.ts @@ -1,41 +1,35 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { MONITOR_RESOURCE_URI } from "../tools/monitor.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - // Domains needed to load the OneDollarStats (stonks) web component scripts const ANALYTICS_RESOURCE_DOMAINS = [ "https://admin.deco.cx", "https://deco.lilstts.com", ]; -export const monitorAppResource = createPublicResource({ - uri: MONITOR_RESOURCE_URI, - name: "Monitor UI", - description: "Performance monitoring dashboard for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: MONITOR_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - // Allow loading stonks scripts + analytics API from external origins - _meta: { - ui: { - csp: { - resourceDomains: ANALYTICS_RESOURCE_DOMAINS, +export const createMonitorAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: MONITOR_RESOURCE_URI, + name: "Monitor UI", + description: "Performance monitoring dashboard for deco.cx sites", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: MONITOR_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + resourceDomains: ANALYTICS_RESOURCE_DOMAINS, + }, }, }, - }, - }; - }, -}); + }; + }, + }); diff --git a/api/resources/pull-requests.ts b/api/resources/pull-requests.ts index a0212f5..aaff18c 100644 --- a/api/resources/pull-requests.ts +++ b/api/resources/pull-requests.ts @@ -1,27 +1,22 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { PULL_REQUESTS_RESOURCE_URI } from "../tools/pull-requests.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const pullRequestsAppResource = createPublicResource({ - uri: PULL_REQUESTS_RESOURCE_URI, - name: "Pull Requests UI", - description: "Interactive pull request management for deco.cx sites", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: PULL_REQUESTS_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - }; - }, -}); +export const createPullRequestsAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: PULL_REQUESTS_RESOURCE_URI, + name: "Pull Requests UI", + description: "Interactive pull request management for deco.cx sites", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: PULL_REQUESTS_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/releases.ts b/api/resources/releases.ts index 4184fa1..5c46a5f 100644 --- a/api/resources/releases.ts +++ b/api/resources/releases.ts @@ -1,27 +1,23 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { RELEASES_RESOURCE_URI } from "../tools/releases.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const projectRoot = join(import.meta.dir, "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const releasesAppResource = createPublicResource({ - uri: RELEASES_RESOURCE_URI, - name: "Releases UI", - description: - "Interactive commit history viewer with promote-to-production and revert capabilities", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: RELEASES_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - }; - }, -}); +export const createReleasesAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: RELEASES_RESOURCE_URI, + name: "Releases UI", + description: + "Interactive commit history viewer with promote-to-production and revert capabilities", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: RELEASES_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/api/resources/render-html.ts b/api/resources/render-html.ts index 372167d..231a462 100644 --- a/api/resources/render-html.ts +++ b/api/resources/render-html.ts @@ -1,27 +1,22 @@ -import { readFile } from "node:fs/promises"; -import { join } from "node:path"; import { createPublicResource } from "@decocms/runtime/tools"; import { RENDER_HTML_RESOURCE_URI } from "../tools/render-html.ts"; const RESOURCE_MIME_TYPE = "text/html;profile=mcp-app"; -function getDistPath(): string { - const IS_PRODUCTION = process.env.NODE_ENV === "production"; - const projectRoot = join(import.meta.dir, IS_PRODUCTION ? "../.." : "../.."); - return join(projectRoot, "dist", "client", "index.html"); -} - -export const renderHtmlAppResource = createPublicResource({ - uri: RENDER_HTML_RESOURCE_URI, - name: "Render HTML UI", - description: "Renders arbitrary HTML content visually", - mimeType: RESOURCE_MIME_TYPE, - read: async () => { - const html = await readFile(getDistPath(), "utf-8"); - return { - uri: RENDER_HTML_RESOURCE_URI, - mimeType: RESOURCE_MIME_TYPE, - text: html, - }; - }, -}); +export const createRenderHtmlAppResource = ( + getClientHTML: () => Promise, +) => + createPublicResource({ + uri: RENDER_HTML_RESOURCE_URI, + name: "Render HTML UI", + description: "Renders arbitrary HTML content visually", + mimeType: RESOURCE_MIME_TYPE, + read: async () => { + const html = await getClientHTML(); + return { + uri: RENDER_HTML_RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + }; + }, + }); diff --git a/biome.json b/biome.json index 3c0a280..73930f0 100644 --- a/biome.json +++ b/biome.json @@ -11,7 +11,8 @@ "**", "!**/styles.css", "!**/globals.css", - "!storefront-skills/**" + "!storefront-skills/**", + "!.wrangler/**" ] }, "formatter": { diff --git a/package.json b/package.json index 7f5bc6c..834c342 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,14 @@ "scripts": { "dev": "concurrently -k \"bun run dev:api\" \"bun run dev:web\"", "dev:tunnel": "concurrently -k \"bun run dev\" \"ngrok http ${PORT:-3001}\"", - "dev:api": "bun run --hot api/main.ts", + "dev:api": "bun run --hot api/main.bun.ts", "dev:web": "NODE_OPTIONS='--max-old-space-size=8192' vite build --watch", - "build:web": "vite build", - "build:server": "NODE_ENV=production bun build api/main.ts --target=bun --minify --outfile=dist/server/main.js", + "dev:workers": "bun run build:web && wrangler dev", + "build:web": "NODE_OPTIONS='--max-old-space-size=8192' vite build", + "build:server": "NODE_ENV=production bun build api/main.bun.ts --target=bun --minify --outfile=dist/server/main.js", "build": "bun run build:web && bun run build:server", + "deploy": "bun run build:web && wrangler deploy", + "deploy:preview": "bun run build:web && wrangler deploy --env preview", "ci:check": "bunx biome ci .", "check": "tsc --noEmit", "test": "bun test", @@ -81,6 +84,7 @@ "typescript": "^5.9.3", "vite": "^7.3.1", "vite-plugin-singlefile": "^2.3.0", - "worktree-devservers": "0.3.1" + "worktree-devservers": "0.3.1", + "wrangler": "^4.0.0" } } diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..8309750 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,23 @@ +name = "admin-mcp" +main = "api/main.workers.ts" +compatibility_date = "2026-05-12" +compatibility_flags = ["nodejs_compat"] +workers_dev = true + +# Serve dist/client/ (built by `bun run build:web`) as Workers static assets. +# The Worker handles /api/mcp/*; everything else (index.html, Monaco worker +# JS chunks) is served by the asset pipeline before the worker runs. +[assets] +directory = "dist/client" +binding = "ASSETS" + +[vars] +# DECO_ADMIN_URL = "https://admin-envs.deco.cx" # optional override + +[env.preview] +name = "admin-mcp-preview" +workers_dev = true + +[env.preview.assets] +directory = "dist/client" +binding = "ASSETS" From cb298ffb424d96c0526ca3d94004a11bb7d0654a Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 12 May 2026 18:13:11 -0300 Subject: [PATCH 3/5] add observability config to wrangler Co-Authored-By: Claude Opus 4.7 (1M context) --- wrangler.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/wrangler.toml b/wrangler.toml index 8309750..c0396c4 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -14,6 +14,21 @@ binding = "ASSETS" [vars] # DECO_ADMIN_URL = "https://admin-envs.deco.cx" # optional override +[observability] +enabled = false +head_sampling_rate = 1 + +[observability.logs] +enabled = true +head_sampling_rate = 0.1 +persist = true +invocation_logs = true + +[observability.traces] +enabled = true +persist = true +head_sampling_rate = 0.1 + [env.preview] name = "admin-mcp-preview" workers_dev = true From 8698cbdc109b97565c6ebf1c177f913521ad4fdc Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 12 May 2026 18:13:47 -0300 Subject: [PATCH 4/5] convert wrangler config from toml to jsonc Co-Authored-By: Claude Opus 4.7 (1M context) --- wrangler.jsonc | 44 ++++++++++++++++++++++++++++++++++++++++++++ wrangler.toml | 38 -------------------------------------- 2 files changed, 44 insertions(+), 38 deletions(-) create mode 100644 wrangler.jsonc delete mode 100644 wrangler.toml diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..5321fe8 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,44 @@ +{ + "name": "admin-mcp", + "main": "api/main.workers.ts", + "compatibility_date": "2026-05-12", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + + // Serve dist/client/ (built by `bun run build:web`) as Workers static assets. + // The Worker handles /api/mcp/*; everything else (index.html, Monaco worker + // JS chunks) is served by the asset pipeline before the worker runs. + "assets": { + "directory": "dist/client", + "binding": "ASSETS" + }, + + // "vars": { "DECO_ADMIN_URL": "https://admin-envs.deco.cx" }, // optional override + + "observability": { + "enabled": false, + "head_sampling_rate": 1, + "logs": { + "enabled": true, + "head_sampling_rate": 0.1, + "persist": true, + "invocation_logs": true + }, + "traces": { + "enabled": true, + "persist": true, + "head_sampling_rate": 0.1 + } + }, + + "env": { + "preview": { + "name": "admin-mcp-preview", + "workers_dev": true, + "assets": { + "directory": "dist/client", + "binding": "ASSETS" + } + } + } +} diff --git a/wrangler.toml b/wrangler.toml deleted file mode 100644 index c0396c4..0000000 --- a/wrangler.toml +++ /dev/null @@ -1,38 +0,0 @@ -name = "admin-mcp" -main = "api/main.workers.ts" -compatibility_date = "2026-05-12" -compatibility_flags = ["nodejs_compat"] -workers_dev = true - -# Serve dist/client/ (built by `bun run build:web`) as Workers static assets. -# The Worker handles /api/mcp/*; everything else (index.html, Monaco worker -# JS chunks) is served by the asset pipeline before the worker runs. -[assets] -directory = "dist/client" -binding = "ASSETS" - -[vars] -# DECO_ADMIN_URL = "https://admin-envs.deco.cx" # optional override - -[observability] -enabled = false -head_sampling_rate = 1 - -[observability.logs] -enabled = true -head_sampling_rate = 0.1 -persist = true -invocation_logs = true - -[observability.traces] -enabled = true -persist = true -head_sampling_rate = 0.1 - -[env.preview] -name = "admin-mcp-preview" -workers_dev = true - -[env.preview.assets] -directory = "dist/client" -binding = "ASSETS" From 627f0a0dbb25a55533aac1c786e22b893b692a1c Mon Sep 17 00:00:00 2001 From: Fernando Frizzatti Date: Tue, 12 May 2026 21:35:49 -0300 Subject: [PATCH 5/5] fix(cfb_list_builds): normalize admin response to an array MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin's CloudflareBuildsClient.listBuilds passes `json.result` through unchanged, but CF's Builds list endpoint may wrap results as `{ items: [...] }` instead of returning a bare array — so the MCP wrapper was receiving an object and failing output schema validation with "expected array, received object". Mirror the same defense the admin code already applies to `getBuildLogs` and `WorkerVersions.list`. The real fix belongs in admin (clients/cloudflareBuilds.ts), but this keeps the MCP unblocked. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/tools/cfb-builds.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/api/tools/cfb-builds.ts b/api/tools/cfb-builds.ts index 0159b72..8ebc00e 100644 --- a/api/tools/cfb-builds.ts +++ b/api/tools/cfb-builds.ts @@ -83,14 +83,22 @@ export const cfbListBuildsTool = createTool({ }, execute: async ({ context }, ctx) => { const { site, apiKey } = getConfig(ctx); - const builds = (await callAdmin( + const raw = await callAdmin( "deco-sites/admin/loaders/hosting/cfworkers-builds/builds/list.ts", { sitename: site, ...(context.pageSize ? { pageSize: context.pageSize } : {}), }, apiKey, - )) as CfBuild[]; + ); + // Defensive: admin should return CFBuild[] but the CF Builds list endpoint + // sometimes wraps results as `{ items: [...] }` or `{ builds: [...] }`, + // and admin doesn't always unwrap. Normalize to an array here. + const builds: CfBuild[] = Array.isArray(raw) + ? (raw as CfBuild[]) + : ((raw as { items?: CfBuild[]; builds?: CfBuild[] })?.items ?? + (raw as { items?: CfBuild[]; builds?: CfBuild[] })?.builds ?? + []); return { builds }; }, });