Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,9 @@ or delete action; restoring working source remains an explicit CLI/MCP operation

## Development

See the [source map](docs/architecture.md) for runtime boundaries, MCP modules,
hosted services, and Herdr action entrypoints.

```bash
bun run test
bun run test:e2e
Expand Down
32 changes: 32 additions & 0 deletions cloudflare/artifact-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { DurableObjectStub, DurableObjectNamespace } from "@cloudflare/workers-types";
import type { ArtifactLinks, ArtifactTarget } from "./links";
import type { ScriptLibrary } from "./scripts";
import type { ScriptBackend } from "./script-backend";

export type HostedArtifacts = {
links: DurableObjectStub<ArtifactLinks>;
scripts: DurableObjectStub<ScriptLibrary>;
scriptBackends: DurableObjectNamespace<ScriptBackend>;
origin: string;
};

/** Shared artifact identity and URL metadata for canvas and script operations. */
export class ArtifactService {
constructor(readonly workspace: string, readonly libraryKey: string, readonly hosted?: HostedArtifacts) {}

requireHosted(): HostedArtifacts {
if (!this.hosted) throw new Error("Standalone artifacts are unavailable in this runtime");
return this.hosted;
}

target(kind: "canvas" | "script", name: string): ArtifactTarget {
return { libraryKey: this.libraryKey, workspace: this.workspace, kind, name: name.trim().replace(kind === "canvas" ? /\.canvas\.tsx$/ : /\.script\.ts$/, "") };
}

async linkDetails(target: ArtifactTarget) {
const link = await this.hosted?.links.find(target);
if (link) return { slug: link.slug, access: link.access, url: `${this.hosted!.origin}/${link.slug}` };
const pending = await this.hosted?.links.draft(target);
return pending?.slug ? { slug: pending.slug, access: pending.access ?? "private" } : {};
}
}
2 changes: 1 addition & 1 deletion cloudflare/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
ListResourceTemplatesRequestSchema, ReadResourceRequestSchema,
ErrorCode, McpError,
} from "@modelcontextprotocol/sdk/types.js";
import { CANVAS_APP_URI, CANVAS_RESOURCE } from "../src/mcpAppContract";
import { CANVAS_APP_URI, CANVAS_RESOURCE } from "../src/mcp/app-contract";
import shell from "../dist/cloudflare/mcp-app.json";
import * as validators from "../dist/cloudflare/tool-validators.js";
import { CLOUD_MCP_TOOLS } from "./tool-contract";
Expand Down
17 changes: 17 additions & 0 deletions cloudflare/script-guide.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export const HOSTED_SCRIPT_GUIDE = `Hosted standalone scripts and root URLs

Canvases and scripts coexist. Use canvas_write with an optional slug, or artifact_link({kind:"canvas",name,slug,access}), to give a canvas a self-contained root URL. Script writes choose slug explicitly or default to the script name. Root slugs are unique across this deployment, use lowercase letters/numbers/hyphens, and exclude application routes. No namespace or type prefix is added. Names and slugs are separate. Private is the default; access:"public" enables external callers. Management remains authenticated.

Create arbitrary Workers-compatible TypeScript using script_write({name,slug,contents,access}). Export default {async fetch(request, env, ctx) { return new Response("Hello"); }}. A handler can return HTML, JSON, binary responses, or streams, receive webhooks, or call external APIs through fetch. Supported runtime bindings are env.secrets (explicitly configured per-script values) and env.sql (persistent native SQLite, with .exec and bound parameters). ScriptEnv and ExecutionContext types are available. No application credentials are implicitly supplied. Workers-supported cloudflare: and node: imports are available. Unbundled third-party imports, relative modules and package.json dependency installation are not supported by script_write. The server accepts one self-contained module, at most 256 KiB of UTF-8 source.

Use script_secrets({name,secrets:{TOKEN:"value",OLD_TOKEN:null}}) to set/delete values, or omit secrets to list names. Values never appear in source history or management reads. Use env.secrets.TOKEN inside the handler. Avoid returning secrets in HTTP responses. Initialize database tables with create table if not exists; source edits/restores preserve the database.

script_write and script_edit save source before validation. Inspect applied, ok, and diagnostics: failed drafts remain editable, while the URL keeps serving the last validated code. No separate publish operation is needed. script_read reads draft or historical source, script_history lists revisions, script_version reads one, and script_restore restores and validates a prior revision. script_list, script_read, and gallery views never execute handlers.

script_run({name,request:{path:"/chosen-slug?key=value",method:"POST",headers:[["content-type","text/plain"]],body:"aGVsbG8="}}) explicitly invokes the last validated handler. The request path and query are passed exactly as provided, using the deployment origin; include the slug in path to reproduce a direct URL request. Body is base64. MCP requests and responses are limited to 256 KiB; use direct URLs for streaming or larger responses. script_logs reads bounded recent execution logs and errors. Direct URLs preserve incoming paths, methods and body bytes and return the handler response. Management cookies and Cloudflare Access credentials are stripped; private routes also strip Authorization. Public Authorization is passed to the handler. Script HTML gets an opaque-origin sandbox and responses cannot set cookies. Public handlers can implement their own authentication, including provider signature checks. Cloudflare Access policies protecting the entire hostname need a bypass for intended public paths. No schedules or separate publishing workflow are provided. Execution has 30 seconds CPU and 50 subrequests; logs retain 100 entries of up to 2 KiB with literal secret redaction.

Third-party dependencies
Keep the editable source, package.json and lockfile locally. Install pinned packages with bun add --exact <package>@<version>. Typecheck your original TypeScript before bundling. Bundle locally with Bun.build({entrypoints:["handler.ts"],target:"browser",format:"esm",external:["node:*","cloudflare:*"],banner:"// @ts-nocheck",minify:true}). Check build.success, then read build.outputs[0].text() and pass that entire string as script_write.contents. The generated-JavaScript banner avoids implicit-any errors after type erasure; it is only for the generated bundle, not handwritten source. Retain your original sources and rebuild after edits rather than editing bundled internals. Workers cannot execute arbitrary Node processes/native addons; use Workers-compatible packages. If the bundle exceeds 256 KiB, reduce the dependency footprint; splitting it into relative imports is not supported.

After writing, check ok/diagnostics and the returned URL. Creation loads the module for validation but does not invoke the handler. Explicit script_run or an HTTP request invokes it and may mutate state or call external services. Inspect the HTTP status/body and script_logs before reporting the handler working. script_guide and canvas_guide describe the hosted contracts; local Bun stdio does not expose script tools.
`;
127 changes: 127 additions & 0 deletions cloudflare/script-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { createHash } from "node:crypto";
import type { CanvasEdit } from "./library";
import type { ArtifactService } from "./artifact-service";
import type { LinkUpdate } from "./links";
import type { CanvasHttpRequest } from "../src/httpTypes";
import { formatCanvasCheck } from "../src/diagnostics";
import { nativeRequest } from "./backend";
import { compileScriptSource } from "./compiler";
import { scriptResponse } from "./script-service-http";
import { HOSTED_SCRIPT_GUIDE } from "./script-guide";

export class CloudScriptService {
constructor(private readonly artifacts: ArtifactService) {}

async readSource(selection: { name?: string; version_id?: string }) {
const scripts = this.artifacts.requireHosted().scripts;
if (selection.version_id) {
const version = await scripts.version({ workspace: this.artifacts.workspace, id: selection.version_id });
if (selection.name && version.name !== this.artifacts.target("script", selection.name).name) throw new Error("Version does not belong to this script");
return { ...version, path: `${this.artifacts.workspace}/${version.name}.script.ts` };
}
if (!selection.name) throw new Error("name or version_id is required");
return scripts.readRange({ workspace: this.artifacts.workspace, name: selection.name, end_line: Number.MAX_SAFE_INTEGER });
}

async callTool(tool: string, args: Record<string, unknown>): Promise<CallToolResult> {
const hosted = this.artifacts.requireHosted(), scripts = hosted.scripts;
const name = args.name as string, input = { workspace: this.artifacts.workspace, name };
const offset = args.offset as number | undefined ?? 0;
switch (tool) {
case "script_guide": return { content: [{ type: "text", text: HOSTED_SCRIPT_GUIDE }] };
case "script_list": {
const drafts = await scripts.listDrafts({ workspace: this.artifacts.workspace, offset });
const entries = await Promise.all(drafts.map(async draft => ({ ...draft, ...await this.artifacts.linkDetails(this.artifacts.target("script", draft.name)) })));
return text({ scripts: entries, next_offset: entries.length === 100 ? offset + 100 : null });
}
case "script_read": {
if (!args.version_id) return text(await scripts.readRange({ ...input, start_line: args.start_line as number | undefined, end_line: args.end_line as number | undefined }));
const version = await this.readSource({ name, version_id: args.version_id as string });
const lines = version.source.match(/[^\n]*\n|[^\n]+$/g) ?? [""];
const start = args.start_line as number | undefined ?? 1, end = args.end_line as number | undefined ?? start + 199;
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 1 || end < start || start > lines.length) throw new Error("invalid line range");
const actualEnd = Math.min(end, lines.length);
return text({ ...version, source: lines.slice(start - 1, actualEnd).join(""), start_line: start, end_line: actualEnd, total_lines: lines.length, next_line: actualEnd < lines.length ? actualEnd + 1 : null });
}
case "script_history": {
const versions = await scripts.history({ workspace: this.artifacts.workspace, name, offset });
return text({ versions, next_offset: versions.length === 100 ? offset + 100 : null });
}
case "script_version": return text(await scripts.version({ workspace: this.artifacts.workspace, id: args.version_id as string }));
case "script_logs": return text({ logs: await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).logs({ limit: args.limit as number | undefined }) });
case "script_secrets": {
if (args.secrets !== undefined) {
if (!args.secrets || typeof args.secrets !== "object" || Array.isArray(args.secrets)) throw new Error("secrets must be an object");
for (const [key, value] of Object.entries(args.secrets)) await scripts.setSecret({ ...input, key, value: value as string | null });
}
return text({ ok: true, names: await scripts.secretNames(input) });
}
case "script_run": {
const link = await hosted.links.find(this.artifacts.target("script", name));
if (!link?.script_hash) throw new Error("Script has no validated URL revision");
const active = await scripts.active({ ...input, hash: link.script_hash });
const incoming = nativeRequest(args.request as CanvasHttpRequest);
const request = new Request(new URL(new URL(incoming.url).pathname + new URL(incoming.url).search, hosted.origin), incoming);
const response = await hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, this.artifacts.target("script", name).name])).request({ ...active, request });
const envelope = await scriptResponse(response, incoming.method);
return { ...text({ status: envelope.status, ...(link ? { url: `${hosted.origin}/${link.slug}` } : {}) }), structuredContent: { response: envelope } };
}
case "script_write": case "script_edit": case "script_restore": {
const target = this.artifacts.target("script", name);
// Read pending intent only after taking this operation's generation.
// A concurrent explicit access change then either precedes this read or
// supersedes this generation; it cannot be undone by stale metadata.
const generation = await hosted.links.begin(target);
const settings: LinkUpdate = await hosted.links.draft(target);
if (tool === "script_write") {
const previous = await hosted.links.find(target);
settings.slug = args.slug as string | undefined ?? settings.slug ?? previous?.slug ?? target.name;
if (args.access !== undefined) settings.access = args.access as "private" | "public";
}
if (settings.slug !== undefined) await hosted.links.check(target, settings.slug);
if (tool === "script_restore") {
const version = await scripts.version({ workspace: this.artifacts.workspace, id: args.version_id as string });
if (version.name !== target.name) throw new Error("Version does not belong to this script");
}
const mutation = tool === "script_write" ? await scripts.writeDraft({ ...input, source: args.contents as string })
: tool === "script_edit" ? await scripts.editDraft({ ...input, edits: args.edits as CanvasEdit[], expected_hash: args.expected_hash as string | undefined })
: await scripts.restore({ workspace: this.artifacts.workspace, id: args.version_id as string });
await hosted.links.stage(target, generation, settings);
const compiled = await compileScriptSource(mutation.source);
if (compiled.ok && compiled.js) {
const secrets = await scripts.executionSecrets(input);
const backend = hosted.scriptBackends.getByName(JSON.stringify([this.artifacts.libraryKey, this.artifacts.workspace, target.name]));
const validation = await backend.validate({ code: compiled.js, hash: createHash("sha256").update(compiled.js).digest("hex"), secrets });
if (validation.ok) {
try {
const activated = await scripts.activate({ ...input, source_hash: mutation.source_hash, code: compiled.js });
const link = await hosted.links.commit(target, generation, { ...settings, script_hash: activated.hash });
if (!link) {
const { source, ...summary } = mutation;
const superseded = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, superseded: true, error: "A newer update superseded this URL activation" };
return { ...text(superseded, true), structuredContent: superseded };
}
} catch (error) {
const { source, ...summary } = mutation;
const message = error instanceof Error ? error.message : "Script URL activation failed";
const failed = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: false, ...(message.includes("changed during validation") ? { superseded: true } : {}), error: message };
return { ...text(failed, true), structuredContent: failed };
}
} else {
compiled.ok = false;
compiled.diagnostics.push({ severity: "error", message: "Script module could not initialize" });
}
}
const { source, ...summary } = mutation;
const payload = { ...summary, ...await this.artifacts.linkDetails(target), applied: true, ok: compiled.ok, check: formatCanvasCheck(compiled.diagnostics), diagnostics: compiled.diagnostics };
return { ...text(payload, !compiled.ok), structuredContent: payload };
}
default: throw new Error("Unknown script tool");
}
}
}

function text(value: unknown, isError = false): CallToolResult {
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], isError };
}
Loading
Loading