diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json
new file mode 100644
index 000000000..d0f47742c
--- /dev/null
+++ b/.agents/plugins/marketplace.json
@@ -0,0 +1,20 @@
+{
+ "name": "voidhash",
+ "interface": {
+ "displayName": "Voidhash"
+ },
+ "plugins": [
+ {
+ "name": "voidhash",
+ "source": {
+ "source": "local",
+ "path": "./plugins/voidhash"
+ },
+ "policy": {
+ "installation": "AVAILABLE",
+ "authentication": "ON_INSTALL"
+ },
+ "category": "Productivity"
+ }
+ ]
+}
diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 000000000..d68adbb21
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,23 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "studio",
+ "runtimeExecutable": "pnpm",
+ "runtimeArgs": ["--filter", "@voidhash/studio", "dev", "--port", "4830"],
+ "port": 4830
+ },
+ {
+ "name": "deployed",
+ "runtimeExecutable": "python3",
+ "runtimeArgs": [
+ "-m",
+ "http.server",
+ "4831",
+ "--directory",
+ "examples/react-native-example/.voidhash/.build"
+ ],
+ "port": 4831
+ }
+ ]
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..c0962f05e
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,13 @@
+.git
+.github
+.turbo
+**/.alchemy
+**/.next
+**/.output
+**/.turbo
+**/coverage
+**/dist
+**/node_modules
+**/.env
+**/.env.*
+*.log
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 000000000..c6d043880
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,75 @@
+name: Bug report
+description: Report a reproducible Community product or self-hosting defect.
+title: "bug: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Do not report security vulnerabilities here. Follow SECURITY.md and use the private reporting channel.
+ Voidhash is currently in private alpha; older commits and unpublished artifacts are unsupported.
+ - type: dropdown
+ id: area
+ attributes:
+ label: Affected area
+ options:
+ - Self-host runtime
+ - Backend API
+ - Dashboard or designer
+ - React Native SDK
+ - Web or Node SDK
+ - CLI or Studio
+ - Mimic
+ - Documentation
+ - Other Community package
+ validations:
+ required: true
+ - type: textarea
+ id: reproduction
+ attributes:
+ label: Reproduction
+ description: Provide the smallest reproducible sequence, project, or repository.
+ validations:
+ required: true
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ validations:
+ required: true
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ validations:
+ required: true
+ - type: input
+ id: revision
+ attributes:
+ label: Voidhash revision or package version
+ placeholder: Commit SHA or exact package version
+ validations:
+ required: true
+ - type: textarea
+ id: environment
+ attributes:
+ label: Environment
+ description: Include OS, Node, pnpm, Docker, SDK platform, and relevant provider versions.
+ validations:
+ required: true
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs or diagnostics
+ description: Remove credentials, personal data, API keys, and customer content.
+ render: shell
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Checklist
+ options:
+ - label: I reproduced this on the latest preview revision.
+ required: true
+ - label: This report does not contain a vulnerability or sensitive data.
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 000000000..cbe773c91
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Security vulnerability
+ url: https://github.com/voidhashcom/voidhash/security/policy
+ about: Report vulnerabilities privately; never disclose them in a public issue.
+ - name: Product documentation
+ url: https://voidhash.com/docs
+ about: Read the product and SDK documentation.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 000000000..b2a6e91ad
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,42 @@
+name: Feature request
+description: Propose an improvement to the Community product or self-hosting experience.
+title: "feature: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: External pull-request intake is currently closed, but well-scoped product proposals are welcome.
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem
+ description: Explain the user problem or operational limitation without prescribing an implementation.
+ validations:
+ required: true
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed outcome
+ description: Describe the observable behavior and who benefits.
+ validations:
+ required: true
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered
+ - type: dropdown
+ id: edition
+ attributes:
+ label: Intended edition
+ options:
+ - Community
+ - Cloud operations
+ - Enterprise
+ - Unsure
+ validations:
+ required: true
+ - type: textarea
+ id: context
+ attributes:
+ label: Additional context
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..52a319b63
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,22 @@
+## Summary
+
+
+
+## Validation
+
+- [ ] I ran the smallest relevant tests.
+- [ ] I ran `pnpm typecheck` and `pnpm test`, or documented why they do not apply.
+- [ ] Self-host or runtime changes pass the relevant Compose smoke suites.
+- [ ] I updated public JSDoc and user-facing documentation where behavior changed.
+
+## Publication boundary
+
+- [ ] `pnpm check:publication` passes.
+- [ ] Community code uses only `@voidhash/*` scopes and provider-neutral platform interfaces.
+- [ ] Every changed package retains the correct MIT or AGPL license metadata and license text.
+- [ ] The change contains no credentials, customer data, internal hostnames, or private operations/Enterprise code.
+
+## Security
+
+- [ ] I considered tenant isolation, authentication, replay/idempotency, storage ownership, and untrusted input where relevant.
+- [ ] I did not disclose a suspected vulnerability in this pull request.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 000000000..31d0c5c62
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,94 @@
+name: Repository CI
+
+on:
+ push:
+ branches: [main, preview]
+ pull_request:
+ types: [opened, reopened, synchronize, ready_for_review]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: repository-ci-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ validate:
+ name: Validate
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup pnpm
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ uses: pnpm/action-setup@v4
+ with:
+ version: 11.1.3
+ run_install: false
+
+ - name: Check publication boundary
+ run: node scripts/check-publication-boundary.mjs
+
+ - name: Setup Node.js
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: pnpm
+ cache-dependency-path: pnpm-lock.yaml
+
+ - name: Restore Turborepo cache
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ uses: actions/cache@v4
+ with:
+ path: .turbo
+ key: turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
+ restore-keys: |
+ turbo-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
+
+ - name: Install dependencies
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ run: pnpm install --frozen-lockfile
+
+ - name: Validate purchase and restore contracts
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ run: pnpm test:purchase-restore
+
+ - name: Build
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ run: pnpm build --concurrency=2
+
+ - name: Typecheck
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ run: pnpm typecheck --concurrency=2
+
+ - name: Test
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ run: pnpm test --concurrency=2
+
+ storekit-purchase-validation:
+ name: StoreKit purchase validation
+ if: github.event_name != 'pull_request' || github.event.pull_request.draft == false
+ runs-on: macos-15
+ timeout-minutes: 15
+ env:
+ DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 22
+
+ - name: Test StoreKit transaction retention
+ run: swift test --package-path libraries/react-native
+
+ - name: Test StoreKit purchase and restore
+ working-directory: libraries/react-native
+ run: bash ios-storekit-tests/run-tests.sh
diff --git a/.github/workflows/job_test_api_local.yml b/.github/workflows/job_test_api_local.yml
deleted file mode 100644
index 976a4600b..000000000
--- a/.github/workflows/job_test_api_local.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-# name: Test API Local
-# on:
-# workflow_call:
-
-# jobs:
-# test:
-# name: API Test Local
-# timeout-minutes: 60
-# runs-on: blacksmith-2vcpu-ubuntu-2404
-# steps:
-# - uses: actions/checkout@v4
-
-# - name: Delete huge unnecessary tools folder
-# run: rm -rf /opt/hostedtoolcache
-
-# - name: Run containers
-# run: docker compose -f ./deployment/docker-compose.yaml up -d
-
-# - name: Install
-# uses: ./.github/actions/install
-# with:
-# ts: true
-# go: true
-
-# - name: Build
-# run: pnpm turbo run build --filter=./apps/api...
-
-# - name: Load Schema into MySQL
-# run: pnpm drizzle-kit push
-# working-directory: internal/db
-# env:
-# DRIZZLE_DATABASE_URL: "mysql://unkey:password@localhost:3306/unkey"
-
-# - name: Migrate ClickHouse
-# run: goose up
-# env:
-# GOOSE_DRIVER: clickhouse
-# GOOSE_DBSTRING: "tcp://default:password@127.0.0.1:9000"
-# GOOSE_MIGRATION_DIR: ./internal/clickhouse/schema
-
-# - name: Test
-# run: pnpm vitest run -c vitest.integration.ts
-# working-directory: apps/api
-# env:
-# UNKEY_BASE_URL: http://localhost:8787
-# DATABASE_HOST: localhost:3900
-# DATABASE_USERNAME: unkey
-# DATABASE_PASSWORD: password
-# TEST_LOCAL: true
-
-# - name: Dump logs
-# if: always()
-# run: docker compose -f ./deployment/docker-compose.yaml logs --no-color > ./docker.logs
-
-# - name: Upload logs
-# uses: actions/upload-artifact@v4
-# if: always()
-# with:
-# name: ${{github.run_id}}-${{github.run_number}}-api.logs
-# path: docker.logs
-# retention-days: 7
diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml
new file mode 100644
index 000000000..10f7135ca
--- /dev/null
+++ b/.github/workflows/osv-scanner.yml
@@ -0,0 +1,50 @@
+name: OSV Scanner
+
+on:
+ pull_request:
+ paths:
+ - package.json
+ - pnpm-lock.yaml
+ - pnpm-workspace.yaml
+ - apps/**/package.json
+ - examples/**/package.json
+ - libraries/**/package.json
+ - packages/**/package.json
+ - selfhost/**/package.json
+ - .github/workflows/osv-scanner.yml
+ push:
+ branches: [main, preview]
+ paths:
+ - package.json
+ - pnpm-lock.yaml
+ - pnpm-workspace.yaml
+ - apps/**/package.json
+ - examples/**/package.json
+ - libraries/**/package.json
+ - packages/**/package.json
+ - selfhost/**/package.json
+ - .github/workflows/osv-scanner.yml
+ schedule:
+ - cron: "30 12 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: osv-scanner-${{ github.head_ref || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ scan:
+ name: Production lockfile
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Reject known vulnerabilities
+ uses: google/osv-scanner-action/osv-scanner-action@8dc09193bb540e09b23da07ad7e30bd33bf87018
+ with:
+ scan-args: |-
+ --lockfile=pnpm-lock.yaml
diff --git a/.github/workflows/pr-packages.yml b/.github/workflows/pr-packages.yml
new file mode 100644
index 000000000..5bcb6fc1a
--- /dev/null
+++ b/.github/workflows/pr-packages.yml
@@ -0,0 +1,115 @@
+# Publishes per-commit tarballs of all publishable packages to the pr-package
+# service at pkg.voidha.sh so any commit can be tried out without an npm
+# release:
+#
+# pnpm add @voidhash/node@https://pkg.voidha.sh/node/
:` message is an answered question, not an infrastructure failure.
+const isFsError = (error: unknown): error is Error =>
+ error instanceof Error && /^E[A-Z]+: /.test(error.message);
+
+/**
+ * Execute one command line in a fresh workspace shell. Filesystem and shell
+ * state live only for this call; `signal` cooperatively aborts execution.
+ */
+export const runWorkspaceBash = async (
+ sources: WorkspaceVfsSources,
+ command: string,
+ options: { readonly signal?: AbortSignal } = {},
+): Promise => {
+ const bash = new Bash({
+ fs: await makeWorkspaceVfs(sources),
+ cwd: "/",
+ env: { HOME: "/home/user" },
+ executionLimits: {
+ maxCommandCount: 512,
+ maxOutputSize: 2 * 1024 * 1024,
+ },
+ customCommands: workspaceCustomCommands(sources),
+ });
+ try {
+ return await bash.exec(command, options.signal === undefined ? {} : { signal: options.signal });
+ } catch (error) {
+ if (isFsError(error)) {
+ return { stdout: "", stderr: `bash: ${error.message}\n`, exitCode: 1 };
+ }
+ throw error;
+ }
+};
+
+const truncate = (text: string, max: number, label: string): string =>
+ text.length <= max
+ ? text
+ : `${text.slice(0, max)}\n[${label} truncated at ${Math.round(max / 1000)}kB — narrow with grep/head/wc and rerun]\n`;
+
+/** Cap a bash result's streams to token-friendly sizes, appending a notice. */
+export const truncateBashOutput = (
+ result: Pick,
+): { stdout: string; stderr: string } => ({
+ stdout: truncate(result.stdout, MAX_BASH_STDOUT, "stdout"),
+ stderr: truncate(result.stderr, MAX_BASH_STDERR, "stderr"),
+});
diff --git a/apps/backend/src/ai/vfs/readonly-fs.test.ts b/apps/backend/src/ai/vfs/readonly-fs.test.ts
new file mode 100644
index 000000000..85dd5f208
--- /dev/null
+++ b/apps/backend/src/ai/vfs/readonly-fs.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { LazyReadOnlyFs, type ReadOnlyDirEntry, type ReadOnlyDirProvider } from "./readonly-fs.ts";
+
+/**
+ * A fixture provider over a static two-level tree:
+ * /a (dir)
+ * /a/x.txt "hello"
+ * /top.txt "root file"
+ */
+const fixtureProvider = (): ReadOnlyDirProvider => {
+ const files = new Map([
+ ["a/x.txt", "hello"],
+ ["top.txt", "root file"],
+ ]);
+ const dirs = new Set(["", "a"]);
+ return {
+ async readdir(relPath): Promise | null> {
+ if (!dirs.has(relPath)) {
+ return null;
+ }
+ const prefix = relPath === "" ? "" : `${relPath}/`;
+ const entries: ReadOnlyDirEntry[] = [];
+ for (const dir of dirs) {
+ if (dir !== "" && dir.startsWith(prefix) && !dir.slice(prefix.length).includes("/")) {
+ entries.push({ name: dir.slice(prefix.length), kind: "dir" });
+ }
+ }
+ for (const file of files.keys()) {
+ if (file.startsWith(prefix) && !file.slice(prefix.length).includes("/")) {
+ entries.push({ name: file.slice(prefix.length), kind: "file" });
+ }
+ }
+ return entries;
+ },
+ async stat(relPath) {
+ if (dirs.has(relPath)) {
+ return { kind: "dir" };
+ }
+ const content = files.get(relPath);
+ return content === undefined ? null : { kind: "file" };
+ },
+ async readFile(relPath) {
+ return files.get(relPath) ?? null;
+ },
+ };
+};
+
+describe("LazyReadOnlyFs", () => {
+ it("maps readdir/stat/readFile onto the provider", async () => {
+ const fs = new LazyReadOnlyFs(fixtureProvider());
+ expect((await fs.readdir("/")).sort()).toEqual(["a", "top.txt"]);
+ expect(await fs.readdir("/a")).toEqual(["x.txt"]);
+ expect(await fs.readFile("/a/x.txt")).toBe("hello");
+ expect(await fs.exists("/a/x.txt")).toBe(true);
+ expect(await fs.exists("/nope")).toBe(false);
+ const stat = await fs.stat("/a/x.txt");
+ expect(stat.isFile).toBe(true);
+ expect(stat.size).toBe(5);
+ expect((await fs.stat("/a")).isDirectory).toBe(true);
+ expect(await fs.realpath("/a/../a/x.txt")).toBe("/a/x.txt");
+ });
+
+ it("reports typed dirents without extra stat calls", async () => {
+ const fs = new LazyReadOnlyFs(fixtureProvider());
+ const entries = await fs.readdirWithFileTypes("/");
+ expect(entries.find((entry) => entry.name === "a")?.isDirectory).toBe(true);
+ expect(entries.find((entry) => entry.name === "top.txt")?.isFile).toBe(true);
+ });
+
+ it("throws Node-shaped errors for missing and mistyped paths", async () => {
+ const fs = new LazyReadOnlyFs(fixtureProvider());
+ await expect(fs.readFile("/nope.txt")).rejects.toThrow(
+ "ENOENT: no such file or directory, open '/nope.txt'",
+ );
+ await expect(fs.readFile("/a")).rejects.toThrow(
+ "EISDIR: illegal operation on a directory, read '/a'",
+ );
+ await expect(fs.stat("/nope")).rejects.toThrow(
+ "ENOENT: no such file or directory, stat '/nope'",
+ );
+ await expect(fs.readdir("/nope")).rejects.toThrow(
+ "ENOENT: no such file or directory, scandir '/nope'",
+ );
+ await expect(fs.readdir("/top.txt")).rejects.toThrow(
+ "ENOTDIR: not a directory, scandir '/top.txt'",
+ );
+ await expect(fs.readlink("/a/x.txt")).rejects.toThrow(
+ "EINVAL: invalid argument, readlink '/a/x.txt'",
+ );
+ });
+
+ it("throws EROFS for every mutation", async () => {
+ const fs = new LazyReadOnlyFs(fixtureProvider());
+ const mutations: ReadonlyArray<[string, Promise]> = [
+ ["writeFile", fs.writeFile("/a/x.txt")],
+ ["appendFile", fs.appendFile("/a/x.txt")],
+ ["mkdir", fs.mkdir("/b")],
+ ["rm", fs.rm("/a/x.txt")],
+ ["cp", fs.cp("/a/x.txt", "/a/y.txt")],
+ ["mv", fs.mv("/a/x.txt")],
+ ["chmod", fs.chmod("/a/x.txt")],
+ ["symlink", fs.symlink("/a/x.txt", "/a/l")],
+ ["link", fs.link("/a/x.txt", "/a/l")],
+ ["utimes", fs.utimes("/a/x.txt")],
+ ];
+ for (const [name, promise] of mutations) {
+ await expect(promise, name).rejects.toThrow(/^EROFS: read-only file system/);
+ }
+ });
+});
diff --git a/apps/backend/src/ai/vfs/readonly-fs.ts b/apps/backend/src/ai/vfs/readonly-fs.ts
new file mode 100644
index 000000000..a5589704e
--- /dev/null
+++ b/apps/backend/src/ai/vfs/readonly-fs.ts
@@ -0,0 +1,222 @@
+/**
+ * A lazy, read-only just-bash filesystem backend over a small provider seam.
+ *
+ * Mounted under a {@link https://github.com/vercel-labs/just-bash MountableFs}
+ * mount point, so every path this filesystem sees is already mount-relative
+ * (`/` = the mount root). Listings and contents are resolved on demand from the
+ * provider — nothing is materialized up front — which keeps `ls /paywalls`
+ * cheap while `grep -r` still reaches every file. All mutations throw `EROFS`.
+ */
+import type { FsStat, IFileSystem } from "just-bash/browser";
+
+type DirentEntry = Awaited>>[number];
+
+/** One entry of a provider directory listing. */
+export interface ReadOnlyDirEntry {
+ readonly name: string;
+ readonly kind: "file" | "dir";
+}
+
+/**
+ * The data source behind one read-only mount. Paths are provider-relative with
+ * no leading slash (`""` = the mount root, `"a/b.txt"` = a nested file).
+ * Implementations should memoize expensive reads per instance — one provider
+ * instance lives for exactly one bash invocation.
+ */
+export interface ReadOnlyDirProvider {
+ /** Entries of a directory, or `null` when the path is not a directory. */
+ readdir(relPath: string): Promise | null>;
+ /** Kind (and optional byte size) of a path, or `null` when it does not exist. */
+ stat(relPath: string): Promise<{ kind: "file" | "dir"; size?: number } | null>;
+ /** Content of a file, or `null` when the path is not a file. */
+ readFile(relPath: string): Promise;
+}
+
+// Error messages mirror just-bash's own InMemoryFs formats exactly — its
+// coreutils branch on the `ENOENT:`/`EISDIR:`/... message prefixes when
+// rendering `cat`/`ls` failures.
+const enoent = (syscall: string, path: string): Error =>
+ new Error(`ENOENT: no such file or directory, ${syscall} '${path}'`);
+const eisdir = (syscall: string, path: string): Error =>
+ new Error(`EISDIR: illegal operation on a directory, ${syscall} '${path}'`);
+const enotdir = (syscall: string, path: string): Error =>
+ new Error(`ENOTDIR: not a directory, scandir '${path}'`);
+const einval = (syscall: string, path: string): Error =>
+ new Error(`EINVAL: invalid argument, ${syscall} '${path}'`);
+const erofs = (syscall: string, path: string): Error =>
+ new Error(
+ `EROFS: read-only file system, ${syscall} '${path}' — this folder is a read-only projection; use /tmp for scratch files`,
+ );
+
+/** Resolve `.`/`..` segments of an absolute POSIX path (no symlinks to follow). */
+const normalizePath = (path: string): string => {
+ const segments: string[] = [];
+ for (const segment of path.split("/")) {
+ if (segment === "" || segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ segments.pop();
+ continue;
+ }
+ segments.push(segment);
+ }
+ return `/${segments.join("/")}`;
+};
+
+const relative = (path: string): string => normalizePath(path).slice(1);
+
+const FILE_MODE = 0o644;
+const DIR_MODE = 0o755;
+
+export class LazyReadOnlyFs implements IFileSystem {
+ private readonly mtime = new Date();
+ private readonly pathPrefix: string;
+
+ constructor(
+ private readonly provider: ReadOnlyDirProvider,
+ options: { readonly pathPrefix?: string } = {},
+ ) {
+ this.pathPrefix = options.pathPrefix ?? "";
+ }
+
+ // Mounted filesystems receive mount-relative paths; errors that escape the
+ // interpreter (e.g. a redirect's failed `open`) render the raw path, so
+ // re-prefix with the mount point to keep messages in the user's vocabulary.
+ private display(path: string): string {
+ const normalized = normalizePath(path);
+ return normalized === "/" ? this.pathPrefix || "/" : `${this.pathPrefix}${normalized}`;
+ }
+
+ async readFile(path: string): Promise {
+ const content = await this.provider.readFile(relative(path));
+ if (content !== null) {
+ return content;
+ }
+ const stat = await this.provider.stat(relative(path));
+ throw stat?.kind === "dir"
+ ? eisdir("read", this.display(path))
+ : enoent("open", this.display(path));
+ }
+
+ async readFileBuffer(path: string): Promise {
+ return new TextEncoder().encode(await this.readFile(path));
+ }
+
+ async writeFile(path: string): Promise {
+ throw erofs("open", this.display(path));
+ }
+
+ async appendFile(path: string): Promise {
+ throw erofs("open", this.display(path));
+ }
+
+ async exists(path: string): Promise {
+ return (await this.provider.stat(relative(path))) !== null;
+ }
+
+ async stat(path: string): Promise {
+ const stat = await this.provider.stat(relative(path));
+ if (stat === null) {
+ throw enoent("stat", this.display(path));
+ }
+ const size =
+ stat.kind === "file"
+ ? (stat.size ??
+ new TextEncoder().encode((await this.provider.readFile(relative(path))) ?? "").length)
+ : 0;
+ return {
+ isFile: stat.kind === "file",
+ isDirectory: stat.kind === "dir",
+ isSymbolicLink: false,
+ mode: stat.kind === "file" ? FILE_MODE : DIR_MODE,
+ size,
+ mtime: this.mtime,
+ };
+ }
+
+ async lstat(path: string): Promise {
+ return this.stat(path);
+ }
+
+ async mkdir(path: string): Promise {
+ throw erofs("mkdir", this.display(path));
+ }
+
+ async readdir(path: string): Promise {
+ const entries = await this.provider.readdir(relative(path));
+ if (entries === null) {
+ const stat = await this.provider.stat(relative(path));
+ throw stat === null
+ ? enoent("scandir", this.display(path))
+ : enotdir("scandir", this.display(path));
+ }
+ return entries.map((entry) => entry.name);
+ }
+
+ async readdirWithFileTypes(path: string): Promise {
+ const entries = await this.provider.readdir(relative(path));
+ if (entries === null) {
+ const stat = await this.provider.stat(relative(path));
+ throw stat === null
+ ? enoent("scandir", this.display(path))
+ : enotdir("scandir", this.display(path));
+ }
+ return entries.map((entry) => ({
+ name: entry.name,
+ isFile: entry.kind === "file",
+ isDirectory: entry.kind === "dir",
+ isSymbolicLink: false,
+ }));
+ }
+
+ async rm(path: string): Promise {
+ throw erofs("rm", this.display(path));
+ }
+
+ async cp(src: string, dest: string): Promise {
+ throw erofs("cp", this.display(dest));
+ }
+
+ async mv(src: string): Promise {
+ throw erofs("rename", this.display(src));
+ }
+
+ resolvePath(base: string, path: string): string {
+ return path.startsWith("/") ? normalizePath(path) : normalizePath(`${base}/${path}`);
+ }
+
+ // Sync by contract, so a lazy backend cannot enumerate here. Nothing in
+ // just-bash consumes it outside FS-composition internals (globs walk
+ // `readdir`), so reporting just the root is safe.
+ getAllPaths(): string[] {
+ return ["/"];
+ }
+
+ async chmod(path: string): Promise {
+ throw erofs("chmod", this.display(path));
+ }
+
+ async symlink(_target: string, linkPath: string): Promise {
+ throw erofs("symlink", this.display(linkPath));
+ }
+
+ async link(_existingPath: string, newPath: string): Promise {
+ throw erofs("link", this.display(newPath));
+ }
+
+ async readlink(path: string): Promise {
+ throw einval("readlink", this.display(path));
+ }
+
+ async realpath(path: string): Promise {
+ if (!(await this.exists(path))) {
+ throw enoent("realpath", this.display(path));
+ }
+ return normalizePath(path);
+ }
+
+ async utimes(path: string): Promise {
+ throw erofs("utimes", this.display(path));
+ }
+}
diff --git a/apps/backend/src/ai/vfs/workspace-vfs.ts b/apps/backend/src/ai/vfs/workspace-vfs.ts
new file mode 100644
index 000000000..9a61ce613
--- /dev/null
+++ b/apps/backend/src/ai/vfs/workspace-vfs.ts
@@ -0,0 +1,208 @@
+/**
+ * The workspace virtual filesystem the `bash` tool executes over: read-only
+ * projections of the project's paywall workspace mounted over a writable
+ * in-memory base (`/README.md`, `/tmp`, `/home/user`).
+ *
+ * Layout:
+ *
+ * - `/paywalls//document.json` — cleaned document JSON (the same
+ * shape `get_paywall` returns).
+ * - `/paywalls//components/.tsx` — local code-component TSX.
+ *
+ * Adding a future folder (`/builtins`, `/components` catalog, `/examples`) is
+ * one new {@link ReadOnlyDirProvider} plus one mount entry in
+ * {@link makeWorkspaceVfs}.
+ */
+import { serializeDocument, type SnapshotDocumentNode } from "@voidhash/ai-shared";
+import { fileNameFromDocRelative } from "@voidhash/paywall-workspace";
+import { InMemoryFs, MountableFs, type IFileSystem } from "just-bash/browser";
+
+import {
+ LazyReadOnlyFs,
+ type ReadOnlyDirEntry,
+ type ReadOnlyDirProvider,
+} from "./readonly-fs.ts";
+
+/** One paywall of the scoped project, as listed by the workspace service. */
+export interface WorkspaceVfsPaywall {
+ readonly slug: string;
+ readonly paywallId: string;
+}
+
+/** The projected files of one paywall directory. */
+export interface PaywallVfsFiles {
+ readonly documentJson: string;
+ readonly components: ReadonlyArray<{ readonly fileName: string; readonly source: string }>;
+}
+
+/**
+ * The data the VFS reads, as plain promise-returning functions so the VFS
+ * modules stay Effect-free and unit-testable with fixtures. `readPaywall`
+ * returns `null` for an unknown id (e.g. a paywall deleted mid-call).
+ */
+export interface WorkspaceVfsSources {
+ listPaywalls(): Promise>;
+ readPaywall(paywallId: string): Promise;
+}
+
+/**
+ * Shape a decoded document root into its VFS files: pretty-printed cleaned
+ * document JSON plus the local `codeComponent` sources (walked from the
+ * singleton `library` node) named by their `.tsx` file basename.
+ */
+export const paywallVfsFiles = (root: SnapshotDocumentNode | null): PaywallVfsFiles => {
+ const cleaned = root === null ? null : serializeDocument([root]);
+ const library = (root?.children ?? []).find((child) => child.type === "library");
+ const components = (library?.children ?? []).flatMap((node) => {
+ if (node.type !== "codeComponent") {
+ return [];
+ }
+ const data = (node.data ?? {}) as { path?: unknown; source?: unknown };
+ return typeof data.path === "string" && typeof data.source === "string"
+ ? [{ fileName: fileNameFromDocRelative(data.path), source: data.source }]
+ : [];
+ });
+ return { documentJson: `${JSON.stringify(cleaned, null, 2)}\n`, components };
+};
+
+const COMPONENTS_DIR = "components";
+const DOCUMENT_FILE = "document.json";
+
+/**
+ * `/paywalls` provider: one directory per paywall id. The listing and each
+ * paywall's files are memoized per instance (= per bash invocation), so a
+ * `grep -r /paywalls` reads every document exactly once. Ids not present in
+ * the project listing resolve to ENOENT without touching `readPaywall` — the
+ * listing is the project-scope gate.
+ */
+export class PaywallsProvider implements ReadOnlyDirProvider {
+ private listing: Promise> | undefined;
+ private readonly files = new Map>();
+
+ constructor(private readonly sources: WorkspaceVfsSources) {}
+
+ private list(): Promise> {
+ this.listing ??= this.sources.listPaywalls();
+ return this.listing;
+ }
+
+ private async filesOf(paywallId: string): Promise {
+ const listed = (await this.list()).some((paywall) => paywall.paywallId === paywallId);
+ if (!listed) {
+ return null;
+ }
+ let files = this.files.get(paywallId);
+ if (files === undefined) {
+ files = this.sources.readPaywall(paywallId);
+ this.files.set(paywallId, files);
+ }
+ return files;
+ }
+
+ async readdir(relPath: string): Promise | null> {
+ const segments = relPath === "" ? [] : relPath.split("/");
+ if (segments.length === 0) {
+ const paywalls = await this.list();
+ return paywalls.map((paywall) => ({ name: paywall.paywallId, kind: "dir" }));
+ }
+ if (segments.length === 1) {
+ const files = await this.filesOf(segments[0]!);
+ return files === null
+ ? null
+ : [
+ { name: DOCUMENT_FILE, kind: "file" },
+ { name: COMPONENTS_DIR, kind: "dir" },
+ ];
+ }
+ if (segments.length === 2 && segments[1] === COMPONENTS_DIR) {
+ const files = await this.filesOf(segments[0]!);
+ return files === null
+ ? null
+ : files.components.map((component) => ({ name: component.fileName, kind: "file" }));
+ }
+ return null;
+ }
+
+ async stat(relPath: string): Promise<{ kind: "file" | "dir"; size?: number } | null> {
+ const segments = relPath === "" ? [] : relPath.split("/");
+ if (segments.length === 0) {
+ return { kind: "dir" };
+ }
+ if (segments.length === 1) {
+ return (await this.filesOf(segments[0]!)) === null ? null : { kind: "dir" };
+ }
+ if (segments.length === 2) {
+ const files = await this.filesOf(segments[0]!);
+ if (files === null) {
+ return null;
+ }
+ if (segments[1] === DOCUMENT_FILE) {
+ return { kind: "file", size: new TextEncoder().encode(files.documentJson).length };
+ }
+ return segments[1] === COMPONENTS_DIR ? { kind: "dir" } : null;
+ }
+ if (segments.length === 3 && segments[1] === COMPONENTS_DIR) {
+ const source = await this.componentSource(segments[0]!, segments[2]!);
+ return source === null
+ ? null
+ : { kind: "file", size: new TextEncoder().encode(source).length };
+ }
+ return null;
+ }
+
+ async readFile(relPath: string): Promise {
+ const segments = relPath === "" ? [] : relPath.split("/");
+ if (segments.length === 2 && segments[1] === DOCUMENT_FILE) {
+ const files = await this.filesOf(segments[0]!);
+ return files === null ? null : files.documentJson;
+ }
+ if (segments.length === 3 && segments[1] === COMPONENTS_DIR) {
+ return this.componentSource(segments[0]!, segments[2]!);
+ }
+ return null;
+ }
+
+ private async componentSource(paywallId: string, fileName: string): Promise {
+ const files = await this.filesOf(paywallId);
+ const component = files?.components.find((candidate) => candidate.fileName === fileName);
+ return component?.source ?? null;
+ }
+}
+
+export const WORKSPACE_VFS_README = `# Voidhash workspace (read-only projection)
+
+Layout:
+ /paywalls//document.json cleaned document JSON (same shape as get_paywall)
+ /paywalls//components/.tsx local code-component TSX sources
+ /tmp writable scratch — lives only within this single bash call
+
+Directory names under /paywalls are paywall ids: pass them directly to begin_paywall_edit.
+Run \`voidhash paywalls\` for an id → slug listing.
+
+Everything except /tmp and /home/user is READ-ONLY (writes fail with EROFS). Each bash call is
+a fresh shell and filesystem — chain steps with && and pipes instead of relying on state.
+To modify a paywall, use begin_paywall_edit + edit_paywall / write_component.
+`;
+
+/**
+ * Build the per-call workspace filesystem: a writable in-memory base carrying
+ * `/README.md` and the scratch dirs, with the read-only `/paywalls` projection
+ * mounted over it.
+ */
+export const makeWorkspaceVfs = async (sources: WorkspaceVfsSources): Promise => {
+ const base = new InMemoryFs({ "/README.md": WORKSPACE_VFS_README });
+ const fs = new MountableFs({
+ base,
+ mounts: [
+ {
+ mountPoint: "/paywalls",
+ filesystem: new LazyReadOnlyFs(new PaywallsProvider(sources), {
+ pathPrefix: "/paywalls",
+ }),
+ },
+ ],
+ });
+ await fs.mkdir("/tmp");
+ await fs.mkdir("/home/user", { recursive: true });
+ return fs;
+};
diff --git a/apps/backend/src/ai/workspace-tools.ts b/apps/backend/src/ai/workspace-tools.ts
new file mode 100644
index 000000000..03cb0ebb3
--- /dev/null
+++ b/apps/backend/src/ai/workspace-tools.ts
@@ -0,0 +1,1161 @@
+/**
+ * Stateful document-editing tool core: ONE implementation of each MCP
+ * `tools/call` handler,
+ * consumed by the MCP JSON-RPC frontend (`routes/mcp.ts`).
+ *
+ * Individual MCP HTTP requests are server-executed, while `editSessionId` keeps
+ * a leased Mimic participant connection alive across them. There is no fork, no
+ * `paywall.tsx`, and no whole-target overwrite. The surface is document-first —
+ * composition is edited as Mimic document ops and code components are managed by
+ * path:
+ *
+ * - `list_paywalls` — the project's paywall ids, slugs, and directories.
+ * - `begin_paywall_edit` / `finish_paywall_edit` / `revert_paywall_edit` — a
+ * version- and preview-bound edit lifecycle.
+ * - `get_paywall({ editSessionId, nodeId?, depth? })` — cleaned document JSON.
+ * - `get_components({ editSessionId })` — catalog, local, and builtin components.
+ * - `read_component({ editSessionId, path })` — a local component's TSX source.
+ * - `edit_paywall({ editSessionId, edits })` — an ATOMIC batch of document ops against
+ * the LIVE document (schema-validated, then reconciled+submitted with retry).
+ * - `duplicate_subtree` — copy an existing visual subtree with fresh node ids.
+ * - `write_component({ editSessionId, path, source })` — server-VALIDATED (headless build)
+ * then committed; diagnostics commit nothing.
+ * - `rename_component` / `delete_component` — path move / placeholder-degrade.
+ * - `get_paywall_preview` — a PNG plus the exact document version/signature it
+ * represents, which must be supplied when finishing.
+ *
+ * Each tool is a function `(scope, input) → Effect`: it
+ * runs the matching workspace effect and folds the outcome into a client-facing
+ * string. Expected failures (rejections, conflicts, not-found) become a readable
+ * `{ isError: true }` message rather than a thrown error. The Effect itself never
+ * fails (`E = never`); the frontend provides its context.
+ */
+import {
+ ComponentCompiler,
+ ComponentManifestCacheService,
+ componentServingPreviewKey,
+ PaywallArtifactStore,
+ PaywallDeployService,
+ PaywallEditSessionService,
+ PaywallWorkspaceService,
+ SnapshotImageRenderer,
+ type CompileExtractResult,
+} from "@voidhash/core/services";
+import { AuthSession } from "@voidhash/core/domain/auth/Auth";
+import {
+ serializeDocument,
+ validateDocumentEdits,
+ type DocumentEdit,
+ type EditableDocumentNode,
+ type NodeInput,
+ type SnapshotDocumentNode,
+} from "@voidhash/ai-shared";
+import { listBuiltinComponents } from "@voidhash/paywall-builtins";
+import {
+ fileNameFromDocRelative,
+ hashSource,
+ validateComponentFileName,
+} from "@voidhash/paywall-workspace";
+import { Cause, Effect, Exit, Option } from "effect";
+
+import { runWorkspaceBash, truncateBashOutput } from "./vfs/bash-tool.ts";
+import { paywallVfsFiles, type WorkspaceVfsSources } from "./vfs/workspace-vfs.ts";
+
+/** The context (services) every workspace tool closes over when it runs. */
+export type WorkspaceToolDeps =
+ | PaywallWorkspaceService
+ | PaywallDeployService
+ | ComponentManifestCacheService
+ | ComponentCompiler
+ | PaywallArtifactStore
+ | PaywallEditSessionService
+ | SnapshotImageRenderer
+ | AuthSession;
+
+export type WorkspaceToolContent =
+ | { readonly type: "text"; readonly text: string }
+ | { readonly type: "image"; readonly data: string; readonly mimeType: "image/png" };
+
+/**
+ * The result of running a workspace tool. `output` is the client-facing string
+ * (a formatted success or a readable failure message). `isError` is `true` when
+ * the tool did not complete its intended effect (a workspace rejection/conflict/
+ * not-found, or a validation failure) — the MCP frontend maps it to an
+ * `isError: true` tool result.
+ */
+export interface WorkspaceToolResult {
+ readonly output: string;
+ readonly isError: boolean;
+ readonly content?: ReadonlyArray;
+}
+
+/**
+ * The scope a workspace tool operates over. `projectId` is authoritative — the
+ * MCP frontend derives it from the API key. Internal durable sessions also
+ * supply their identity so newly opened edit sessions remain agent-session-owned.
+ */
+export interface WorkspaceToolScope {
+ readonly projectId: string;
+ readonly agentSessionId?: string;
+}
+
+const okResult = (
+ output: string,
+ content?: ReadonlyArray,
+): WorkspaceToolResult => ({
+ output,
+ isError: false,
+ ...(content === undefined ? {} : { content }),
+});
+const errResult = (output: string): WorkspaceToolResult => ({ output, isError: true });
+
+/**
+ * Diagnostics riding on a typed failure (e.g. `WorkspaceWriteRejectedError`),
+ * rendered as `- ` lines. These carry the ACTUAL rejection reasons —
+ * without them the client only sees the generic envelope message and cannot
+ * react.
+ */
+const failureDiagnosticLines = (error: unknown): string[] => {
+ const diagnostics = (error as { diagnostics?: unknown }).diagnostics;
+ if (!Array.isArray(diagnostics)) {
+ return [];
+ }
+ return diagnostics.flatMap((entry) => {
+ if (entry === null || typeof entry !== "object") {
+ return [];
+ }
+ const message = (entry as { message?: unknown }).message;
+ return typeof message === "string" && message.length > 0 ? [`- ${message}`] : [];
+ });
+};
+
+/**
+ * Render a typed failure value as a readable message. Tagged errors that carry
+ * their payload in `cause` instead of `message` (e.g. `PaywallServiceError`)
+ * inherit `Error.prototype.message === ""` — naively reading `.message` yields
+ * an EMPTY string. Prefer a non-empty `message`, then a `cause` payload
+ * (prefixed with the error's `_tag`), then the tag alone, and never return an
+ * empty string; any `diagnostics` the error carries are appended as `- ` lines.
+ */
+const failureMessage = (error: unknown): string => {
+ const base = (() => {
+ if (error !== null && typeof error === "object") {
+ const { message, cause, _tag } = error as {
+ message?: unknown;
+ cause?: unknown;
+ _tag?: unknown;
+ };
+ if (typeof message === "string" && message.length > 0) {
+ return message;
+ }
+ const tag = typeof _tag === "string" ? _tag : undefined;
+ const causeText =
+ typeof cause === "string" && cause.length > 0
+ ? cause
+ : cause instanceof Error && cause.message.length > 0
+ ? cause.message
+ : undefined;
+ if (causeText !== undefined) {
+ return tag !== undefined ? `${tag}: ${causeText}` : causeText;
+ }
+ if (tag !== undefined) {
+ return tag;
+ }
+ }
+ const text = String(error);
+ return text.length > 0 ? text : "unknown error (no message)";
+ })();
+ const diagnosticLines = failureDiagnosticLines(error);
+ return diagnosticLines.length > 0 ? `${base}\n${diagnosticLines.join("\n")}` : base;
+};
+
+/**
+ * Run a workspace effect and fold its exit into `{ ok, value | message }`. The
+ * context is supplied by the frontend (provided into the returned effect), so
+ * the shared core needs no direct context access. An expected typed failure
+ * becomes a readable message (see {@link failureMessage}) — never a defect the
+ * frontend has to catch.
+ */
+const runFolded = (
+ effect: Effect.Effect,
+): Effect.Effect<
+ { ok: true; value: A } | { ok: false; message: string },
+ never,
+ WorkspaceToolDeps
+> =>
+ effect.pipe(
+ Effect.exit,
+ Effect.map((exit) => {
+ if (Exit.isSuccess(exit)) {
+ return { ok: true as const, value: exit.value };
+ }
+ // Extract the first typed failure (`Fail` reason's `error`) from the
+ // cause; a die/interrupt has no `Fail` reason, so it pretty-prints the
+ // whole cause instead.
+ const failure = Cause.findErrorOption(exit.cause);
+ const message = Option.isSome(failure)
+ ? failureMessage(failure.value)
+ : Cause.pretty(exit.cause);
+ return { ok: false as const, message };
+ }),
+ );
+
+/** Inputs for each workspace tool, matching the MCP tool schemas. */
+export interface ListPaywallsInput {}
+
+export interface RunBashInput {
+ readonly command: string;
+}
+
+/** Stable paywall target shared by every paywall-scoped tool. */
+export interface PaywallTargetInput {
+ readonly paywallId: string;
+}
+
+export interface BeginPaywallEditInput extends PaywallTargetInput {}
+export interface EditSessionInput {
+ readonly editSessionId: string;
+}
+export interface GetPaywallInput extends EditSessionInput {
+ readonly nodeId?: string;
+ readonly depth?: number;
+}
+export interface GetComponentsInput extends EditSessionInput {}
+export interface ReadComponentInput extends EditSessionInput {
+ readonly path: string;
+}
+export interface EditPaywallInput extends EditSessionInput {
+ readonly edits: ReadonlyArray;
+}
+export interface DuplicateSubtreeInput extends EditSessionInput {
+ readonly nodeId: string;
+ readonly parentId: string;
+ readonly index?: number;
+ readonly nextName?: string;
+}
+export interface WriteComponentInput extends EditSessionInput {
+ readonly path: string;
+ readonly source: string;
+}
+export interface RenameComponentInput extends EditSessionInput {
+ readonly fromPath: string;
+ readonly toPath: string;
+}
+export interface DeleteComponentInput extends EditSessionInput {
+ readonly path: string;
+}
+export interface GetPaywallPreviewInput extends EditSessionInput {
+ readonly width?: number;
+ readonly height?: number;
+ readonly scale?: 1 | 2;
+}
+export interface FinishPaywallEditInput extends EditSessionInput {
+ readonly reviewedDocumentSignature: string;
+ readonly verdict: string;
+ readonly unresolvedIssues: ReadonlyArray;
+}
+export interface RevertPaywallEditInput {
+ readonly editSessionId: string;
+}
+
+interface ResolvedPaywallTarget {
+ readonly paywallId: string;
+ readonly slug: string;
+}
+
+interface ResolvedEditSession extends ResolvedPaywallTarget {
+ readonly editSessionId: string;
+}
+
+/** Resolve a stable paywall id within the scoped project. */
+const resolvePaywallTarget = (
+ scope: WorkspaceToolScope,
+ input: PaywallTargetInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const paywalls = yield* ws.listPaywalls(scope.projectId);
+ const target = paywalls.find((candidate) => candidate.paywallId === input.paywallId);
+ if (target === undefined) {
+ return yield* Effect.fail(
+ new Error(`No paywall with id "${input.paywallId}" exists in this project.`),
+ );
+ }
+ return target;
+ });
+
+/** Resolves and authorizes the Mimic connection represented by an edit session. */
+const resolveEditSession = (
+ scope: WorkspaceToolScope,
+ input: EditSessionInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const editSessions = yield* PaywallEditSessionService;
+ const session = yield* editSessions.connectActive({
+ projectId: scope.projectId,
+ editSessionId: input.editSessionId,
+ agentSessionId: scope.agentSessionId,
+ });
+ return {
+ editSessionId: session.editSessionId,
+ paywallId: session.paywallId,
+ slug: session.paywallSlug,
+ };
+ });
+
+const recordSessionMutation = (
+ scope: WorkspaceToolScope,
+ target: ResolvedEditSession,
+ result: { readonly version: number; readonly commandCount: number },
+) =>
+ result.commandCount === 0
+ ? Effect.void
+ : Effect.gen(function* () {
+ const editSessions = yield* PaywallEditSessionService;
+ yield* editSessions.recordMutation({
+ projectId: scope.projectId,
+ editSessionId: target.editSessionId,
+ agentSessionId: scope.agentSessionId,
+ documentVersion: result.version,
+ });
+ }).pipe(Effect.ignore);
+
+/** `begin_paywall_edit` — capture the revert baseline and mint the write capability. */
+export const beginPaywallEdit = (
+ scope: WorkspaceToolScope,
+ input: BeginPaywallEditInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const target = yield* resolvePaywallTarget(scope, input);
+ const editSessions = yield* PaywallEditSessionService;
+ return yield* scope.agentSessionId === undefined
+ ? editSessions.begin({
+ projectId: scope.projectId,
+ paywallId: target.paywallId,
+ source: "mcp",
+ })
+ : editSessions.begin({
+ projectId: scope.projectId,
+ paywallId: target.paywallId,
+ source: "built_in",
+ agentSessionId: scope.agentSessionId,
+ });
+ }),
+ );
+ return result.ok
+ ? okResult(
+ JSON.stringify({
+ editSessionId: result.value.editSessionId,
+ paywallId: result.value.paywallId,
+ baselineVersion: result.value.baselineVersion,
+ }),
+ )
+ : errResult(`begin_paywall_edit failed: ${result.message}`);
+ });
+
+/**
+ * `list_paywalls` — the project's paywalls (stable id + slug + path) as a list.
+ * MCP has no system prompt to embed the paywall set into, so it is a first-class
+ * tool here.
+ */
+export const listPaywalls = (
+ scope: WorkspaceToolScope,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ return yield* ws.listPaywalls(scope.projectId);
+ }),
+ );
+ if (!result.ok) {
+ return errResult(`list_paywalls failed: ${result.message}`);
+ }
+ if (result.value.length === 0) {
+ return okResult("No paywalls in this project.");
+ }
+ const lines = result.value.map(
+ (dir) => `- ${dir.paywallId}: slug ${dir.slug} (/paywalls/${dir.slug})`,
+ );
+ return okResult(`${result.value.length} paywall(s):\n${lines.join("\n")}`);
+ });
+
+/**
+ * `bash` — one read-only research command over the workspace VFS
+ * (`/paywalls//…`), executed in a fresh just-bash shell. A completed
+ * exec is a success even on a non-zero exit code (`grep` exiting 1 on no match
+ * is a valid answer) — the exit code and stderr are reported in the output;
+ * only infrastructure failures (service errors, the 30s timeout) fold to
+ * `isError`.
+ */
+export const runBash = (
+ scope: WorkspaceToolScope,
+ input: RunBashInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const context = yield* Effect.context();
+ const runEffect = Effect.runPromiseWith(context);
+ // A rejected VFS read gets re-rendered by the shell (e.g. `ls` prints a
+ // generic not-found), so the first service failure is recorded here and
+ // wins over whatever the shell made of it.
+ let serviceFailure: string | undefined;
+ const runSource = (effect: Effect.Effect): Promise =>
+ runEffect(runFolded(effect)).then((folded) => {
+ if (folded.ok) {
+ return folded.value;
+ }
+ serviceFailure ??= folded.message;
+ throw new Error(folded.message);
+ });
+ const sources: WorkspaceVfsSources = {
+ listPaywalls: () => runSource(ws.listPaywalls(scope.projectId)),
+ readPaywall: (paywallId) =>
+ runSource(
+ ws.readDocumentTree(paywallId).pipe(
+ Effect.map((document) =>
+ paywallVfsFiles((document.root as SnapshotDocumentNode | null) ?? null),
+ ),
+ Effect.catchTag("PaywallNotFoundError", () => Effect.succeed(null)),
+ ),
+ ),
+ };
+ const result = yield* runFolded(
+ Effect.tryPromise((signal) => runWorkspaceBash(sources, input.command, { signal })).pipe(
+ Effect.timeout("30 seconds"),
+ ),
+ );
+ if (serviceFailure !== undefined) {
+ return errResult(`bash failed: ${serviceFailure}`);
+ }
+ if (!result.ok) {
+ return errResult(`bash failed: ${result.message}`);
+ }
+ const { stdout, stderr } = truncateBashOutput(result.value);
+ const sections: string[] = [];
+ if (stdout.length > 0) {
+ sections.push(stdout);
+ }
+ if (stderr.length > 0) {
+ sections.push(`[stderr]\n${stderr}`);
+ }
+ if (result.value.exitCode !== 0) {
+ sections.push(`[exit code ${result.value.exitCode}]`);
+ }
+ return okResult(sections.length > 0 ? sections.join("\n") : "(no output)");
+ });
+
+/**
+ * Adapt a decoded document snapshot node to the ai-shared
+ * {@link EditableDocumentNode} the validator reads (`data` verbatim — the
+ * validator only touches scalar/enum leaves).
+ */
+const toEditableNode = (node: SnapshotDocumentNode): EditableDocumentNode => ({
+ id: node.id,
+ type: node.type,
+ data: node.data as Record,
+ children: (node.children ?? []).map(toEditableNode),
+});
+
+/** Resolve the target and read its decoded document root. */
+const readDocumentRoot = (scope: WorkspaceToolScope, input: EditSessionInput) =>
+ runFolded(
+ Effect.gen(function* () {
+ const target = yield* resolveEditSession(scope, input);
+ const ws = yield* PaywallWorkspaceService;
+ const document = yield* ws.readConnectedDocumentTree(scope.projectId, {
+ paywallId: target.paywallId,
+ connectionId: target.editSessionId,
+ });
+ return { target, document };
+ }),
+ );
+
+/**
+ * `get_paywall` — the paywall's LIVE document as cleaned JSON (nested
+ * `{ id, type, name?, ...data, children }`, defaults stripped, CRDT internals
+ * dropped). `nodeId` roots a subtree; `depth` caps the tree (deeper nodes render
+ * as stubs). The `id`s are the addressing keys for `edit_paywall`.
+ */
+export const getPaywall = (
+ scope: WorkspaceToolScope,
+ input: GetPaywallInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* readDocumentRoot(scope, input);
+ if (!result.ok) {
+ return errResult(`get_paywall failed: ${result.message}`);
+ }
+ const roots =
+ result.value.document.root != null
+ ? [result.value.document.root as SnapshotDocumentNode]
+ : [];
+ const cleaned = serializeDocument(roots, {
+ ...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
+ ...(input.depth !== undefined ? { depth: input.depth } : {}),
+ });
+ if (cleaned === null) {
+ const label = `${result.value.target.paywallId} (${result.value.target.slug})`;
+ return errResult(
+ input.nodeId !== undefined
+ ? `get_paywall: no node "${input.nodeId}" in paywall ${label}.`
+ : `get_paywall: paywall ${label} has no document.`,
+ );
+ }
+ return okResult(JSON.stringify(cleaned, null, 2));
+ });
+
+/**
+ * Walk a decoded document root for its local `codeComponent` definitions
+ * (`library` node → `codeComponent` children), returning `{ path, source }`.
+ */
+const localComponentsOf = (
+ root: SnapshotDocumentNode | null,
+): ReadonlyArray<{ readonly path: string; readonly source: string }> => {
+ if (root === null) {
+ return [];
+ }
+ const library = (root.children ?? []).find((child) => child.type === "library");
+ if (library === undefined) {
+ return [];
+ }
+ return (library.children ?? []).flatMap((node) => {
+ if (node.type !== "codeComponent") {
+ return [];
+ }
+ const data = (node.data ?? {}) as { path?: unknown; source?: unknown };
+ return typeof data.path === "string" && typeof data.source === "string"
+ ? [{ path: data.path, source: data.source }]
+ : [];
+ });
+};
+
+/**
+ * `get_components` — every component placeable in the paywall: CATALOG components
+ * (deployed/shared, from {@link PaywallDeployService.listComponents} — slug,
+ * version, description, props/actions/slot/previewStates from their §2 manifest)
+ * AND the paywall's LOCAL code components (walked from the document's `library`;
+ * their manifests resolved from the content-addressed cache by source hash — a
+ * component whose source has no cached manifest is listed with a
+ * "manifest unavailable" note, never evaluated inline).
+ */
+export const getComponents = (
+ scope: WorkspaceToolScope,
+ input: GetComponentsInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const deploy = yield* PaywallDeployService;
+ const ws = yield* PaywallWorkspaceService;
+ const manifestCache = yield* ComponentManifestCacheService;
+ const target = yield* resolveEditSession(scope, input);
+
+ const catalog = yield* deploy.listComponents({ projectId: scope.projectId });
+ const document = yield* ws.readConnectedDocumentTree(scope.projectId, {
+ paywallId: target.paywallId,
+ connectionId: target.editSessionId,
+ });
+ const locals = localComponentsOf((document.root as SnapshotDocumentNode | null) ?? null);
+ const cached = yield* manifestCache.getMany(
+ locals.map((local) => hashSource(local.source)),
+ );
+
+ return {
+ catalog: catalog.map((component) => ({
+ slug: component.slug,
+ title: component.title,
+ version: component.latestVersion,
+ manifest: component.latest.manifest,
+ previewStates: component.latest.previewStates,
+ })),
+ locals: locals.map((local) => {
+ const row = cached.get(hashSource(local.source));
+ return {
+ path: local.path,
+ manifest: row?.status === "ready" ? row.manifest : undefined,
+ };
+ }),
+ builtins: listBuiltinComponents()
+ .filter((builtin) => builtin.manifest.slot !== true)
+ .map((builtin) => ({
+ slug: builtin.slug,
+ name: builtin.name,
+ description: builtin.description,
+ props: builtin.manifest.props,
+ actions: builtin.manifest.actions,
+ previewStates: builtin.manifest.previewStates,
+ slot: builtin.manifest.slot ?? false,
+ insertAs: { componentSource: "builtin", componentSlug: builtin.slug },
+ })),
+ } as const;
+ }),
+ );
+ if (!result.ok) {
+ return errResult(`get_components failed: ${result.message}`);
+ }
+ const { catalog, locals, builtins } = result.value;
+ const sections: string[] = [];
+ sections.push(
+ catalog.length === 0
+ ? "Catalog components: none."
+ : `Catalog components (${catalog.length}):\n${JSON.stringify(catalog, null, 2)}`,
+ );
+ sections.push(
+ locals.length === 0
+ ? "Local code components: none."
+ : `Local code components (${locals.length}):\n${locals
+ .map((local) =>
+ local.manifest !== undefined
+ ? `- ${local.path}:\n${JSON.stringify(local.manifest, null, 2)}`
+ : `- ${local.path}: manifest unavailable (component not yet compiled in a session — read_component to see its source).`,
+ )
+ .join("\n")}`,
+ );
+ sections.push(
+ builtins.length === 0
+ ? "Builtin components: none."
+ : `Builtin components (${builtins.length}):\n${JSON.stringify(builtins, null, 2)}`,
+ );
+ return okResult(sections.join("\n\n"));
+ });
+
+/** `read_component` — a local code component's TSX source by its `components/.tsx` path. */
+export const readComponent = (
+ scope: WorkspaceToolScope,
+ input: ReadComponentInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* readDocumentRoot(scope, input);
+ if (!result.ok) {
+ return errResult(`read_component failed: ${result.message}`);
+ }
+ const locals = localComponentsOf(
+ (result.value.document.root as SnapshotDocumentNode | null) ?? null,
+ );
+ const component = locals.find((local) => local.path === input.path);
+ if (component === undefined) {
+ const available = locals.map((local) => local.path);
+ return errResult(
+ `read_component: no local component at "${input.path}" in paywall ${result.value.target.paywallId} (${result.value.target.slug}).${
+ available.length > 0 ? ` Available: [${available.join(", ")}].` : ""
+ }`,
+ );
+ }
+ return okResult(component.source);
+ });
+
+/**
+ * `edit_paywall` — an ATOMIC batch of document ops against the LIVE document. The
+ * ops are validated with the schema-derived `validateDocumentEdits` against the
+ * decoded tree; on failure the STRUCTURED errors are returned verbatim (the
+ * model reads them to converge). On success the ops are applied and reconciled
+ * into the live document through the version-retry submit loop, and the minted
+ * ids for created nodes are returned so the model can address them next.
+ */
+export const editPaywall = (
+ scope: WorkspaceToolScope,
+ input: EditPaywallInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const resolved = yield* runFolded(resolveEditSession(scope, input));
+ if (!resolved.ok) {
+ return errResult(`edit_paywall failed: ${resolved.message}`);
+ }
+ const target = resolved.value;
+ // Validate first (against a fresh read) so an invalid batch never opens a
+ // transaction and returns the model-facing errors verbatim.
+ const read = yield* readDocumentRoot(scope, input);
+ if (!read.ok) {
+ return errResult(`edit_paywall failed: ${read.message}`);
+ }
+ const root = read.value.document.root as SnapshotDocumentNode | null;
+ if (root === null) {
+ return errResult(
+ `edit_paywall: paywall ${target.paywallId} (${target.slug}) has no document to edit.`,
+ );
+ }
+ const validation = validateDocumentEdits(input.edits, toEditableNode(root));
+ if (!validation.ok) {
+ return errResult(
+ `edit_paywall rejected — fix these and retry:\n${validation.errors
+ .map((error) => `- [edit ${error.editIndex}] ${error.message}`)
+ .join("\n")}`,
+ );
+ }
+
+ const applied = yield* runFolded(
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const result = yield* ws.editConnectedDocument(
+ scope.projectId,
+ { paywallId: target.paywallId, connectionId: target.editSessionId },
+ validation.edits,
+ );
+ yield* recordSessionMutation(scope, target, result);
+ return result;
+ }),
+ );
+ if (!applied.ok) {
+ return errResult(`edit_paywall rejected: ${applied.message}`);
+ }
+ const { version, commandCount, mintedIds } = applied.value;
+ const mintedEntries = Object.entries(mintedIds);
+ const mintedNote =
+ mintedEntries.length > 0
+ ? `\nMinted ids (by op index): ${mintedEntries
+ .map(([index, ids]) => `${index}=[${ids.join(", ")}]`)
+ .join("; ")}`
+ : "";
+ return okResult(
+ `Applied ${validation.edits.length} edit(s) to ${target.paywallId} (${target.slug}) at version ${version} (${commandCount} command${commandCount === 1 ? "" : "s"}).${mintedNote}`,
+ );
+ });
+
+const isVisualNode = (node: SnapshotDocumentNode): boolean =>
+ node.type !== "root" && node.type !== "library" && node.type !== "codeComponent";
+
+/** Convert a visual snapshot subtree into an id-free document insert payload. */
+const cloneNodeInput = (node: SnapshotDocumentNode): NodeInput => {
+ const children = (node.children ?? []).filter(isVisualNode).map(cloneNodeInput);
+ return {
+ type: node.type,
+ ...structuredClone((node.data ?? {}) as Record),
+ ...(children.length === 0 ? {} : { children }),
+ } as NodeInput;
+};
+
+const findSnapshotNode = (
+ root: SnapshotDocumentNode,
+ nodeId: string,
+): SnapshotDocumentNode | null => {
+ if (root.id === nodeId) {
+ return root;
+ }
+ for (const child of root.children ?? []) {
+ const found = findSnapshotNode(child, nodeId);
+ if (found !== null) {
+ return found;
+ }
+ }
+ return null;
+};
+
+/** `duplicate_subtree` — clone a visual subtree and insert it with fresh ids. */
+export const duplicateSubtree = (
+ scope: WorkspaceToolScope,
+ input: DuplicateSubtreeInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const read = yield* readDocumentRoot(scope, input);
+ if (!read.ok) {
+ return errResult(`duplicate_subtree failed: ${read.message}`);
+ }
+ const { target, document } = read.value;
+ const root = document.root as SnapshotDocumentNode | null;
+ if (root === null) {
+ return errResult(
+ `duplicate_subtree: paywall ${target.paywallId} (${target.slug}) has no document.`,
+ );
+ }
+ const source = findSnapshotNode(root, input.nodeId);
+ if (source === null) {
+ return errResult(`duplicate_subtree: no node "${input.nodeId}" in the document.`);
+ }
+ if (!isVisualNode(source)) {
+ return errResult(
+ `duplicate_subtree: cannot duplicate engine-managed ${source.type} node "${input.nodeId}".`,
+ );
+ }
+ const node = cloneNodeInput(source);
+ if (input.nextName !== undefined) {
+ node.name = input.nextName;
+ }
+ return yield* editPaywall(scope, {
+ editSessionId: input.editSessionId,
+ edits: [
+ {
+ op: "insert",
+ parentId: input.parentId,
+ ...(input.index === undefined ? {} : { index: input.index }),
+ node,
+ },
+ ],
+ });
+ });
+
+/** Render a compile/extract failure's diagnostics as `- ` lines. */
+const formatExtractDiagnostics = (
+ result: Extract,
+): string =>
+ `[${result.phase}] compile failed:\n${result.diagnostics
+ .map((d) => {
+ const position =
+ d.line !== undefined
+ ? ` (line ${d.line}${d.column !== undefined ? `, col ${d.column}` : ""})`
+ : "";
+ return `- ${d.message}${position}`;
+ })
+ .join("\n")}`;
+
+/**
+ * `write_component` — server-VALIDATE the single component's source THEN commit:
+ * run the headless {@link ComponentCompiler.compileAndExtract} (compile,
+ * manifest extraction, and preview-state rendering on the container/native
+ * adapter). An unavailable compiler or compile/runtime diagnostics commit
+ * NOTHING. On success the source is written
+ * to the `codeComponent` node
+ * (created when the path is new) and, when a manifest was extracted, recorded in
+ * the content-addressed cache so a later projection can resolve it.
+ */
+export const writeComponent = (
+ scope: WorkspaceToolScope,
+ input: WriteComponentInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const resolved = yield* runFolded(resolveEditSession(scope, input));
+ if (!resolved.ok) {
+ return errResult(`write_component failed: ${resolved.message}`);
+ }
+ const target = resolved.value;
+ const nameError = validateComponentFileName(fileNameFromDocRelative(input.path));
+ if (nameError !== undefined) {
+ return errResult(`write_component rejected: ${nameError}`);
+ }
+
+ // Build the single component first. A compile/runtime error commits nothing.
+ const built = yield* runFolded(
+ Effect.gen(function* () {
+ const compiler = yield* ComponentCompiler;
+ return yield* compiler.compileAndExtract(input.source);
+ }),
+ );
+ if (!built.ok) {
+ return errResult(`write_component failed: ${built.message}`);
+ }
+ const build = built.value;
+ if (build.status === "error") {
+ return errResult(`write_component rejected: ${formatExtractDiagnostics(build)}`);
+ }
+ if (build.status === "unavailable") {
+ return errResult(
+ "write_component rejected: headless component compilation is unavailable; no source was committed.",
+ );
+ }
+
+ // Commit the source (create-or-replace the codeComponent node), then record
+ // the manifest extracted by the required headless compile.
+ const committed = yield* runFolded(
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const result = yield* ws.writeConnectedComponentSource(
+ scope.projectId,
+ { paywallId: target.paywallId, connectionId: target.editSessionId },
+ input.path,
+ input.source,
+ );
+ yield* recordSessionMutation(scope, target, result);
+ const manifestCache = yield* ComponentManifestCacheService;
+ yield* manifestCache.record({
+ sourceHash: hashSource(input.source),
+ status: "ready",
+ manifest: build.manifest,
+ });
+ return result;
+ }),
+ );
+ if (!committed.ok) {
+ return errResult(`write_component rejected: ${committed.message}`);
+ }
+ return okResult(
+ `Wrote ${input.path} to ${target.paywallId} (${target.slug}) at version ${committed.value.version} (compiled clean; manifest recorded).`,
+ );
+ });
+
+/**
+ * `rename_component` — move a local component from `fromPath` to `toPath` (a path
+ * rename): repaths the definition AND re-points every local instance referencing
+ * it (rename cascade). Addressed by `components/.tsx` file names.
+ */
+export const renameComponent = (
+ scope: WorkspaceToolScope,
+ input: RenameComponentInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const resolved = yield* runFolded(resolveEditSession(scope, input));
+ if (!resolved.ok) {
+ return errResult(`rename_component failed: ${resolved.message}`);
+ }
+ const target = resolved.value;
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const result = yield* ws.moveConnectedComponentFile(
+ scope.projectId,
+ { paywallId: target.paywallId, connectionId: target.editSessionId },
+ fileNameFromDocRelative(input.fromPath),
+ fileNameFromDocRelative(input.toPath),
+ );
+ yield* recordSessionMutation(scope, target, result);
+ return result;
+ }),
+ );
+ return result.ok
+ ? okResult(
+ `Renamed ${input.fromPath} → ${input.toPath} in ${target.paywallId} (${target.slug}) at version ${result.value.version}.`,
+ )
+ : errResult(`rename_component rejected: ${result.message}`);
+ });
+
+/**
+ * `delete_component` — remove a local component's `codeComponent` definition by
+ * path. Existing instances of it DEGRADE to placeholders (never cascade-deleted),
+ * so they should be replaced or removed afterward.
+ */
+export const deleteComponent = (
+ scope: WorkspaceToolScope,
+ input: DeleteComponentInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const resolved = yield* runFolded(resolveEditSession(scope, input));
+ if (!resolved.ok) {
+ return errResult(`delete_component failed: ${resolved.message}`);
+ }
+ const target = resolved.value;
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const ws = yield* PaywallWorkspaceService;
+ const result = yield* ws.deleteConnectedComponentFile(
+ scope.projectId,
+ { paywallId: target.paywallId, connectionId: target.editSessionId },
+ fileNameFromDocRelative(input.path),
+ );
+ yield* recordSessionMutation(scope, target, result);
+ return result;
+ }),
+ );
+ return result.ok
+ ? okResult(
+ `Deleted ${input.path} from ${target.paywallId} (${target.slug}) at version ${result.value.version}. Any instances of it now render as placeholders — replace or remove them.`,
+ )
+ : errResult(`delete_component rejected: ${result.message}`);
+ });
+
+const documentSignature = (root: unknown): string => `doc-${hashSource(JSON.stringify(root))}`;
+
+const pngBase64 = (png: Uint8Array): string => {
+ const chunks: string[] = [];
+ for (let offset = 0; offset < png.length; offset += 0x8000) {
+ chunks.push(String.fromCharCode(...png.subarray(offset, offset + 0x8000)));
+ }
+ return btoa(chunks.join(""));
+};
+
+const deployedComponentPreviewStates = (
+ root: SnapshotDocumentNode | null,
+): ReadonlyMap> => {
+ const statesByHash = new Map>();
+ const visit = (node: SnapshotDocumentNode): void => {
+ if (node.type === "component") {
+ const data = (node.data ?? {}) as Record;
+ const contentHash = data.contentHash;
+ if (data.componentSource !== "local" && typeof contentHash === "string" && contentHash) {
+ const states = statesByHash.get(contentHash) ?? new Set();
+ states.add("default");
+ if (typeof data.previewState === "string" && data.previewState) {
+ states.add(data.previewState);
+ }
+ statesByHash.set(contentHash, states);
+ }
+ }
+ for (const child of node.children ?? []) {
+ visit(child);
+ }
+ };
+ if (root !== null) {
+ visit(root);
+ }
+ return statesByHash;
+};
+
+const fetchPreviewComponentTrees = (root: SnapshotDocumentNode | null) =>
+ Effect.gen(function* () {
+ const store = yield* PaywallArtifactStore;
+ const trees: Record> = {};
+ for (const [contentHash, states] of deployedComponentPreviewStates(root)) {
+ for (const state of states) {
+ const object = yield* store.getObject(componentServingPreviewKey(contentHash, state));
+ if (object === null) {
+ continue;
+ }
+ const tree = yield* Effect.try({
+ try: () => JSON.parse(new TextDecoder().decode(object.body)) as unknown,
+ catch: () => null,
+ }).pipe(Effect.orElseSucceed(() => null));
+ if (tree !== null) {
+ (trees[contentHash] ??= {})[state] = tree;
+ }
+ }
+ }
+ return trees;
+ });
+
+const compileLocalPreviewTrees = (root: SnapshotDocumentNode | null) =>
+ Effect.gen(function* () {
+ const compiler = yield* ComponentCompiler;
+ const trees: Record> = {};
+ for (const local of localComponentsOf(root)) {
+ const result = yield* compiler.compileAndExtract(local.source);
+ if (result.status === "unavailable") {
+ return yield* Effect.fail(
+ new Error(
+ `Headless compilation is unavailable for local component ${local.path}; preview was not rendered.`,
+ ),
+ );
+ }
+ if (result.status === "error") {
+ return yield* Effect.fail(
+ new Error(
+ `Local component ${local.path} cannot be previewed. ${formatExtractDiagnostics(result)}`,
+ ),
+ );
+ }
+ trees[local.path] = { ...result.previewTrees };
+ }
+ return trees;
+ });
+
+/** `get_paywall_preview` — render and return a version-bound PNG review image. */
+export const getPaywallPreview = (
+ scope: WorkspaceToolScope,
+ input: GetPaywallPreviewInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const width = input.width ?? 375;
+ const height = input.height ?? 812;
+ const scale = input.scale ?? 1;
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const target = yield* resolveEditSession(scope, input);
+ const editSessions = yield* PaywallEditSessionService;
+ const workspace = yield* PaywallWorkspaceService;
+ const connection = {
+ paywallId: target.paywallId,
+ connectionId: target.editSessionId,
+ };
+ const before = yield* workspace.readConnectedDocumentTree(scope.projectId, connection);
+ const signature = documentSignature(before.root);
+ const root = (before.root as SnapshotDocumentNode | null) ?? null;
+ const componentTrees = yield* fetchPreviewComponentTrees(root);
+ const localComponentTrees = yield* compileLocalPreviewTrees(root);
+ const renderer = yield* SnapshotImageRenderer;
+ const png = yield* renderer.render({
+ snapshot: before.root,
+ componentTrees,
+ localComponentTrees,
+ width,
+ height,
+ deviceScaleFactor: scale,
+ });
+ const after = yield* workspace.readConnectedDocumentTree(scope.projectId, connection);
+ if (after.version !== before.version || documentSignature(after.root) !== signature) {
+ return yield* Effect.fail(
+ new Error(
+ "The paywall changed while its preview was rendering. Request a fresh preview.",
+ ),
+ );
+ }
+ yield* editSessions.recordPreview({
+ projectId: scope.projectId,
+ editSessionId: input.editSessionId,
+ agentSessionId: scope.agentSessionId,
+ documentSignature: signature,
+ documentVersion: before.version,
+ });
+ return { png, signature, version: before.version };
+ }),
+ );
+ if (!result.ok) {
+ return errResult(`get_paywall_preview failed: ${result.message}`);
+ }
+ const data = pngBase64(result.value.png);
+ const metadata = JSON.stringify({
+ kind: "paywall-preview",
+ mediaType: "image/png",
+ width,
+ height,
+ scale,
+ documentVersion: result.value.version,
+ documentSignature: result.value.signature,
+ message: "Review this image visually before finishing the edit session.",
+ });
+ return okResult(metadata, [
+ { type: "text", text: metadata },
+ { type: "image", data, mimeType: "image/png" },
+ ]);
+ });
+
+/** `finish_paywall_edit` — close an edit session after reviewing its exact latest preview. */
+export const finishPaywallEdit = (
+ scope: WorkspaceToolScope,
+ input: FinishPaywallEditInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ if (input.unresolvedIssues.length > 0) {
+ return errResult(
+ `finish_paywall_edit rejected: unresolved issues remain: ${input.unresolvedIssues.join("; ")}. Correct them and render a new preview.`,
+ );
+ }
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const target = yield* resolveEditSession(scope, input);
+ const workspace = yield* PaywallWorkspaceService;
+ const document = yield* workspace.readConnectedDocumentTree(scope.projectId, {
+ paywallId: target.paywallId,
+ connectionId: target.editSessionId,
+ });
+ const editSessions = yield* PaywallEditSessionService;
+ return yield* editSessions.finish({
+ projectId: scope.projectId,
+ editSessionId: input.editSessionId,
+ agentSessionId: scope.agentSessionId,
+ reviewedDocumentSignature: input.reviewedDocumentSignature,
+ currentDocumentSignature: documentSignature(document.root),
+ currentDocumentVersion: document.version,
+ verdict: input.verdict,
+ });
+ }),
+ );
+ return result.ok
+ ? okResult(
+ `Finished edit session ${input.editSessionId} after visual review. Verdict: ${input.verdict}`,
+ )
+ : errResult(`finish_paywall_edit rejected: ${result.message}`);
+ });
+
+/** `revert_paywall_edit` — reconcile the live document to the captured baseline. */
+export const revertPaywallEdit = (
+ scope: WorkspaceToolScope,
+ input: RevertPaywallEditInput,
+): Effect.Effect =>
+ Effect.gen(function* () {
+ const result = yield* runFolded(
+ Effect.gen(function* () {
+ const editSessions = yield* PaywallEditSessionService;
+ return yield* scope.agentSessionId === undefined
+ ? editSessions.revert(scope.projectId, input.editSessionId)
+ : editSessions.revertForAgentSession(
+ scope.projectId,
+ input.editSessionId,
+ scope.agentSessionId,
+ );
+ }),
+ );
+ return result.ok
+ ? okResult(
+ `Reverted edit session ${input.editSessionId} for "${result.value.paywallSlug}" at version ${result.value.version} (${result.value.commandCount} command${result.value.commandCount === 1 ? "" : "s"}).`,
+ )
+ : errResult(`revert_paywall_edit failed: ${result.message}`);
+ });
diff --git a/apps/backend/src/mcp/dispatch.test.ts b/apps/backend/src/mcp/dispatch.test.ts
new file mode 100644
index 000000000..a23e74f25
--- /dev/null
+++ b/apps/backend/src/mcp/dispatch.test.ts
@@ -0,0 +1,265 @@
+/**
+ * Tests the MCP tool dispatch path end-to-end through the stateful document-
+ * editing core against a mocked workspace context. Proves: a valid
+ * call runs the core and returns its string; an invalid argument set folds to an
+ * `isError` result (never a throw / JSON-RPC error); `list_paywalls` lists
+ * directories; `get_paywall` cleans the document; `edit_paywall` validates then
+ * applies; the write folds a rejection into a clean message (no Cause/_tag leak).
+ */
+import {
+ ComponentCompiler,
+ ComponentManifestCacheService,
+ PaywallArtifactStore,
+ PaywallDeployService,
+ PaywallEditSessionService,
+ PaywallWorkspaceService,
+ SnapshotImageRenderer,
+} from "@voidhash/core/services";
+import { AuthSession } from "@voidhash/core/domain/auth/Auth";
+import { Context, Effect } from "effect";
+import { describe, expect, it } from "vite-plus/test";
+
+import { findMcpTool } from "./tool-manifest.ts";
+import type { WorkspaceToolResult } from "../ai/workspace-tools.ts";
+
+/** A decoded document root: root → screen. */
+const documentRoot = {
+ id: "root1",
+ type: "root",
+ parentId: null,
+ pos: "a0",
+ data: { name: "Paywall" },
+ children: [
+ { id: "screen1", type: "screen", parentId: "root1", pos: "a0", data: {}, children: [] },
+ ],
+};
+
+const fakeWorkspace = (over: Partial = {}) =>
+ ({
+ listPaywalls: () =>
+ Effect.succeed([
+ { slug: "trial", paywallId: "pw_1" },
+ { slug: "onboarding", paywallId: "pw_2" },
+ ]),
+ readDocument: (_p: string, slug: string) =>
+ Effect.succeed({ slug, name: "Trial", paywallId: "pw_1", root: documentRoot }),
+ readConnectedDocumentTree: () => Effect.succeed({ tree: {}, root: documentRoot, version: 8 }),
+ editDocument: () => Effect.succeed({ version: 9, commandCount: 2, mintedIds: {} }),
+ editConnectedDocument: () => Effect.succeed({ version: 9, commandCount: 2, mintedIds: {} }),
+ writeComponentSource: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ writeConnectedComponentSource: () =>
+ Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ moveComponentFile: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ moveConnectedComponentFile: () =>
+ Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ deleteComponentFile: () => Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ deleteConnectedComponentFile: () =>
+ Effect.succeed({ version: 9, commandCount: 1, diagnostics: [] }),
+ ...over,
+ }) as unknown as PaywallWorkspaceService["Service"];
+
+const fakeDeploy = (over: Partial = {}) =>
+ ({
+ listComponents: () => Effect.succeed([]),
+ ...over,
+ }) as unknown as PaywallDeployService["Service"];
+
+const fakeManifestCache = (over: Partial = {}) =>
+ ({
+ getMany: () => Effect.succeed(new Map()),
+ record: () => Effect.void,
+ ...over,
+ }) as unknown as ComponentManifestCacheService["Service"];
+
+const fakeCompiler = (over: Partial = {}) =>
+ ({
+ compileCheck: () => Effect.succeed({ status: "unavailable" as const }),
+ compileAndExtract: () => Effect.succeed({ status: "unavailable" as const }),
+ ...over,
+ }) as unknown as ComponentCompiler["Service"];
+
+const fakeEditSessions = (over: Partial = {}) =>
+ ({
+ recordMutation: () => Effect.void,
+ connectActive: () =>
+ Effect.succeed({
+ editSessionId: "pw_edit_1",
+ projectId: "proj_1",
+ paywallId: "pw_1",
+ paywallSlug: "trial",
+ baselineVersion: 1,
+ }),
+ ...over,
+ }) as unknown as PaywallEditSessionService["Service"];
+
+interface Fakes {
+ readonly workspace?: PaywallWorkspaceService["Service"];
+ readonly deploy?: PaywallDeployService["Service"];
+ readonly manifestCache?: ComponentManifestCacheService["Service"];
+ readonly compiler?: ComponentCompiler["Service"];
+ readonly editSessions?: PaywallEditSessionService["Service"];
+}
+
+const contextWith = (fakes: Fakes) =>
+ Context.empty().pipe(
+ Context.add(PaywallWorkspaceService, fakes.workspace ?? fakeWorkspace()),
+ Context.add(PaywallDeployService, fakes.deploy ?? fakeDeploy()),
+ Context.add(ComponentManifestCacheService, fakes.manifestCache ?? fakeManifestCache()),
+ Context.add(ComponentCompiler, fakes.compiler ?? fakeCompiler()),
+ Context.add(PaywallEditSessionService, fakes.editSessions ?? fakeEditSessions()),
+ Context.add(PaywallArtifactStore, {
+ getObject: () => Effect.succeed(null),
+ } as unknown as PaywallArtifactStore["Service"]),
+ Context.add(SnapshotImageRenderer, {
+ render: () => Effect.succeed(new Uint8Array([1])),
+ } as SnapshotImageRenderer["Service"]),
+ Context.add(AuthSession, {} as never),
+ );
+
+const dispatchWith = (fakes: Fakes, name: string, args: unknown): Promise => {
+ const tool = findMcpTool(name);
+ if (tool === undefined) {
+ throw new Error(`tool ${name} not found`);
+ }
+ return Effect.runPromise(
+ tool.dispatch({ projectId: "proj_1" }, args).pipe(Effect.provide(contextWith(fakes))),
+ );
+};
+
+const dispatch = (name: string, args: unknown): Promise =>
+ dispatchWith({}, name, args);
+
+describe("MCP tool dispatch", () => {
+ it("list_paywalls lists the project directories (no input)", async () => {
+ const result = await dispatch("list_paywalls", {});
+ expect(result.isError).toBe(false);
+ expect(result.output).toContain("pw_1: slug trial (/paywalls/trial)");
+ expect(result.output).toContain("pw_2: slug onboarding (/paywalls/onboarding)");
+ });
+
+ it("get_paywall returns the cleaned document JSON with node ids", async () => {
+ const result = await dispatch("get_paywall", { editSessionId: "pw_edit_1" });
+ expect(result.isError).toBe(false);
+ expect(result.output).toContain('"id": "root1"');
+ expect(result.output).toContain('"type": "screen"');
+ });
+
+ it("edit_paywall validates then applies, reporting the new version", async () => {
+ const result = await dispatch("edit_paywall", {
+ editSessionId: "pw_edit_1",
+ edits: [{ op: "insert", parentId: "screen1", node: { type: "view" } }],
+ });
+ expect(result.isError).toBe(false);
+ expect(result.output).toContain("version 9");
+ });
+
+ it("edit_paywall returns structured validation errors verbatim (no apply)", async () => {
+ const editConnectedDocument = () => {
+ throw new Error("editConnectedDocument must not be called on a validation failure");
+ };
+ const result = await dispatchWith(
+ { workspace: fakeWorkspace({ editConnectedDocument: editConnectedDocument as never }) },
+ "edit_paywall",
+ // Unknown parent id → validation rejects before any submit.
+ {
+ editSessionId: "pw_edit_1",
+ edits: [{ op: "insert", parentId: "ghost", node: { type: "view" } }],
+ },
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("edit_paywall rejected");
+ expect(result.output).toContain("ghost");
+ });
+
+ it("write_component rejects broken source with diagnostics (commits nothing)", async () => {
+ const writeConnectedComponentSource = () => {
+ throw new Error("writeConnectedComponentSource must not be called on a compile error");
+ };
+ const result = await dispatchWith(
+ {
+ workspace: fakeWorkspace({
+ writeConnectedComponentSource: writeConnectedComponentSource as never,
+ }),
+ compiler: fakeCompiler({
+ compileAndExtract: () =>
+ Effect.succeed({
+ status: "error" as const,
+ phase: "compile" as const,
+ diagnostics: [{ message: "Unexpected token", line: 3 }],
+ }),
+ }),
+ },
+ "write_component",
+ {
+ editSessionId: "pw_edit_1",
+ path: "components/hero.tsx",
+ source: "export default (=> {",
+ },
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("write_component rejected");
+ expect(result.output).toContain("Unexpected token");
+ });
+
+ it("write_component commits a clean component and records its manifest", async () => {
+ const recorded: unknown[] = [];
+ const manifest = {
+ manifestVersion: 2 as const,
+ props: {},
+ actions: {},
+ slot: false,
+ previewStates: ["default"],
+ hostData: [],
+ };
+ const result = await dispatchWith(
+ {
+ manifestCache: fakeManifestCache({
+ record: ((input: unknown) => Effect.sync(() => void recorded.push(input))) as never,
+ }),
+ compiler: fakeCompiler({
+ compileAndExtract: () =>
+ Effect.succeed({ status: "ready" as const, manifest, previewTrees: {} }),
+ }),
+ },
+ "write_component",
+ {
+ editSessionId: "pw_edit_1",
+ path: "components/hero.tsx",
+ source: "export default () => null;",
+ },
+ );
+ expect(result.isError).toBe(false);
+ expect(result.output).toContain("version 9");
+ expect(result.output).toContain("manifest recorded");
+ expect(recorded).toHaveLength(1);
+ });
+
+ it("folds invalid arguments into an isError result (never throws)", async () => {
+ const result = await dispatch("get_paywall", {});
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("get_paywall: invalid arguments");
+ });
+
+ it("folds an edit rejection into an isError result with a CLEAN message (no Cause/_tag leak)", async () => {
+ const result = await dispatchWith(
+ {
+ workspace: fakeWorkspace({
+ editConnectedDocument: () =>
+ Effect.fail({
+ _tag: "WorkspaceWriteConflictError",
+ message: "lost the concurrency race",
+ }) as never,
+ }),
+ },
+ "edit_paywall",
+ {
+ editSessionId: "pw_edit_1",
+ edits: [{ op: "insert", parentId: "screen1", node: { type: "view" } }],
+ },
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("edit_paywall rejected: lost the concurrency race");
+ expect(result.output).not.toContain("Cause(");
+ expect(result.output).not.toContain("_tag");
+ });
+});
diff --git a/apps/backend/src/mcp/protocol.test.ts b/apps/backend/src/mcp/protocol.test.ts
new file mode 100644
index 000000000..a16672b20
--- /dev/null
+++ b/apps/backend/src/mcp/protocol.test.ts
@@ -0,0 +1,216 @@
+/**
+ * Unit tests for the hand-rolled MCP JSON-RPC handler. The tool executor is
+ * mocked (a context-free `callTool`), so these cover method dispatch, protocol
+ * negotiation, the tools/list shape, and the tool-error → `isError` mapping
+ * without a worker or the workspace service.
+ */
+import { Effect } from "effect";
+import { describe, expect, it } from "vite-plus/test";
+
+import {
+ handleMcpMessage,
+ parseJsonRpcMessage,
+ JsonRpcErrorCode,
+ SUPPORTED_PROTOCOL_VERSIONS,
+ type CallTool,
+ type JsonRpcMessage,
+ type JsonRpcResponse,
+} from "./protocol.ts";
+import type { WorkspaceToolResult } from "../ai/workspace-tools.ts";
+
+/** A canned tool executor: echoes the name/args, or fails as an `isError` result. */
+const cannedCallTool =
+ (result: WorkspaceToolResult): CallTool =>
+ () =>
+ Effect.succeed(result);
+
+/** Run the handler with a canned executor and no real context (protocol-only). */
+const run = (
+ message: JsonRpcMessage,
+ callTool: CallTool = cannedCallTool({ output: "ok", isError: false }),
+): Promise =>
+ Effect.runPromise(handleMcpMessage(message, callTool) as Effect.Effect);
+
+const msg = (
+ method: string,
+ params?: Record,
+ id: string | number = 1,
+): JsonRpcMessage => ({
+ jsonrpc: "2.0",
+ method,
+ id,
+ params,
+});
+
+describe("parseJsonRpcMessage", () => {
+ it("accepts a valid request", () => {
+ const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", method: "ping", id: 1 });
+ expect(parsed.ok).toBe(true);
+ });
+
+ it("rejects a missing jsonrpc version", () => {
+ const parsed = parseJsonRpcMessage({ method: "ping", id: 1 });
+ expect(parsed.ok).toBe(false);
+ });
+
+ it("rejects a missing method", () => {
+ const parsed = parseJsonRpcMessage({ jsonrpc: "2.0", id: 1 });
+ expect(parsed.ok).toBe(false);
+ });
+
+ it("rejects a batch (array)", () => {
+ const parsed = parseJsonRpcMessage([{ jsonrpc: "2.0", method: "ping", id: 1 }]);
+ expect(parsed.ok).toBe(false);
+ });
+});
+
+describe("initialize", () => {
+ it("negotiates the requested supported version and advertises tools", async () => {
+ for (const version of SUPPORTED_PROTOCOL_VERSIONS) {
+ const response = await run(msg("initialize", { protocolVersion: version }));
+ expect(response && "result" in response).toBe(true);
+ const result = (response as { result: Record }).result;
+ expect(result.protocolVersion).toBe(version);
+ expect(result.capabilities).toEqual({ tools: {}, resources: {}, prompts: {} });
+ expect((result.serverInfo as { name: string }).name).toBe("voidhash-paywall-workspace");
+ }
+ });
+
+ it("falls back to the latest version for an unsupported request", async () => {
+ const response = await run(msg("initialize", { protocolVersion: "1999-01-01" }));
+ const result = (response as { result: Record }).result;
+ expect(result.protocolVersion).toBe(SUPPORTED_PROTOCOL_VERSIONS[0]);
+ });
+});
+
+describe("notifications/initialized", () => {
+ it("is accepted with no response (route → 202)", async () => {
+ const response = await run({ jsonrpc: "2.0", method: "notifications/initialized" });
+ expect(response).toBeNull();
+ });
+});
+
+describe("ping", () => {
+ it("returns an empty result", async () => {
+ const response = await run(msg("ping"));
+ expect((response as { result: unknown }).result).toEqual({});
+ });
+});
+
+describe("tools/list", () => {
+ it("returns the tool descriptors with JSON Schema inputs", async () => {
+ const response = await run(msg("tools/list"));
+ const result = (response as { result: { tools: Array> } }).result;
+ expect(result.tools.length).toBe(14);
+ const listPaywalls = result.tools[0];
+ expect(listPaywalls.name).toBe("list_paywalls");
+ expect((listPaywalls.inputSchema as { type: string }).type).toBe("object");
+ });
+});
+
+describe("tools/call", () => {
+ it("maps a successful tool run to text content (isError false)", async () => {
+ const response = await run(
+ msg("tools/call", { name: "read_file", arguments: { path: "/x" } }),
+ cannedCallTool({ output: "FILE", isError: false }),
+ );
+ const result = (response as { result: Record }).result;
+ expect(result.isError).toBe(false);
+ expect(result.content).toEqual([{ type: "text", text: "FILE" }]);
+ });
+
+ it("maps a tool failure to isError content, NOT a JSON-RPC error", async () => {
+ const response = await run(
+ msg("tools/call", { name: "apply_paywall", arguments: {} }),
+ cannedCallTool({ output: "apply_paywall rejected: bad", isError: true }),
+ );
+ expect(response && "result" in response).toBe(true);
+ const result = (response as { result: Record }).result;
+ expect(result.isError).toBe(true);
+ expect(result.content).toEqual([{ type: "text", text: "apply_paywall rejected: bad" }]);
+ });
+
+ it("preserves multimodal image content from preview tools", async () => {
+ const content = [
+ { type: "text" as const, text: '{"documentSignature":"doc-1"}' },
+ { type: "image" as const, data: "iVBORw0KGgo=", mimeType: "image/png" as const },
+ ];
+ const response = await run(
+ msg("tools/call", { name: "get_paywall_preview", arguments: {} }),
+ cannedCallTool({ output: "preview", isError: false, content }),
+ );
+ expect((response as { result: { content: unknown } }).result.content).toEqual(content);
+ });
+
+ it("rejects a missing tool name with InvalidParams", async () => {
+ const response = await run(msg("tools/call", { arguments: {} }));
+ const error = (response as { error: { code: number } }).error;
+ expect(error.code).toBe(JsonRpcErrorCode.InvalidParams);
+ });
+});
+
+describe("resources", () => {
+ it("lists and reads both authoring skills", async () => {
+ const listed = await run(msg("resources/list"));
+ const resources = (listed as { result: { resources: Array<{ uri: string }> } }).result
+ .resources;
+ expect(resources[0]?.uri).toBe("voidhash://skills/paywall-authoring");
+ expect(resources[1]?.uri).toBe("voidhash://skills/code-component-authoring");
+
+ const read = await run(msg("resources/read", { uri: "voidhash://skills/paywall-authoring" }));
+ const text = (read as { result: { contents: Array<{ text: string }> } }).result.contents[0]
+ ?.text;
+ expect(text).toContain("begin_paywall_edit");
+ expect(text).toContain("Document model");
+ expect(text).toContain("Variables, states, and actions");
+ expect(text).toContain("selected_product");
+
+ const componentRead = await run(
+ msg("resources/read", { uri: "voidhash://skills/code-component-authoring" }),
+ );
+ const componentText = (componentRead as { result: { contents: Array<{ text: string }> } })
+ .result.contents[0]?.text;
+ expect(componentText).toContain("Custom designer panels");
+ expect(componentText).toContain("Runtime animation and gestures");
+ expect(componentText).toContain("useMotionValue");
+ });
+});
+
+describe("prompts", () => {
+ it("offers a design prompt with the verified lifecycle", async () => {
+ const listed = await run(msg("prompts/list"));
+ expect(
+ (listed as { result: { prompts: Array<{ name: string }> } }).result.prompts[0]?.name,
+ ).toBe("design_paywall");
+ const response = await run(
+ msg("prompts/get", {
+ name: "design_paywall",
+ arguments: { paywallId: "pw_1", request: "Improve hierarchy" },
+ }),
+ );
+ const text = (
+ response as {
+ result: { messages: Array<{ content: { text: string } }> };
+ }
+ ).result.messages[0]?.content.text;
+ expect(text).toContain('paywall "pw_1"');
+ expect(text).toContain("get_paywall_preview");
+ expect(text).toContain("dynamic behavior without code");
+ expect(text).toContain("actionBindings");
+ expect(text).toContain("Improve hierarchy");
+ });
+});
+
+describe("unknown method", () => {
+ it("returns method-not-found", async () => {
+ const response = await run(msg("completion/complete"));
+ const error = (response as { error: { code: number; message: string } }).error;
+ expect(error.code).toBe(JsonRpcErrorCode.MethodNotFound);
+ expect(error.message).toContain("completion/complete");
+ });
+
+ it("accepts an unknown notification silently", async () => {
+ const response = await run({ jsonrpc: "2.0", method: "notifications/cancelled" });
+ expect(response).toBeNull();
+ });
+});
diff --git a/apps/backend/src/mcp/protocol.ts b/apps/backend/src/mcp/protocol.ts
new file mode 100644
index 000000000..914d34b23
--- /dev/null
+++ b/apps/backend/src/mcp/protocol.ts
@@ -0,0 +1,310 @@
+/**
+ * Minimal, hand-rolled MCP JSON-RPC 2.0 handler for the STATELESS streamable-HTTP
+ * transport. Editing state lives behind `editSessionId`; the transport itself
+ * answers each `POST /api/mcp` with a single JSON response (or 202 for a
+ * notification), which is spec-compliant and exactly what Claude Code's
+ * streamable-HTTP client accepts.
+ *
+ * This module is transport- and auth-free: {@link handleMcpMessage} takes an
+ * already-parsed JSON-RPC message plus a `callTool` executor (the route supplies
+ * one bound to the authenticated project scope) and returns either a JSON-RPC
+ * response object to serialize, or `null` for an accepted notification (the route
+ * maps that to HTTP 202). Method dispatch, protocol-version negotiation, and the
+ * error taxonomy live here so they can be unit-tested without a worker.
+ *
+ * Implemented methods: `initialize`, `notifications/initialized`, `tools/list`,
+ * `tools/call`, `resources/list`, `resources/read`, `prompts/list`,
+ * `prompts/get`, and `ping`. Unknown methods → JSON-RPC method-not-found. Tool
+ * errors are `isError: true` content, never JSON-RPC errors (per MCP).
+ */
+import { Effect } from "effect";
+
+import { mcpToolDescriptors } from "./tool-manifest.ts";
+import * as WorkspaceTools from "../ai/workspace-tools.ts";
+import {
+ findSkill,
+ listSkills,
+ skillFromResourceUri,
+ skillResourceUri,
+} from "../ai/skills/registry.ts";
+
+/** Protocol versions we speak, newest first (used to pick the negotiated version). */
+export const SUPPORTED_PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26"] as const;
+const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
+
+/** Server identity advertised in the `initialize` result. */
+const SERVER_INFO = { name: "voidhash-paywall-workspace", version: "1.0.0" } as const;
+const AUTHORING_RESOURCE_URI = "voidhash://paywall-authoring/reference";
+const DESIGN_PROMPT_NAME = "design_paywall";
+
+const mcpAuthoringGuide = (): string => findSkill("paywall-authoring")?.body() ?? "";
+
+/** Standard JSON-RPC 2.0 error codes we emit. */
+export const JsonRpcErrorCode = {
+ ParseError: -32700,
+ InvalidRequest: -32600,
+ MethodNotFound: -32601,
+ InvalidParams: -32602,
+ InternalError: -32603,
+} as const;
+
+/** A JSON-RPC id (string or number, or null on a pre-id parse error). */
+export type JsonRpcId = string | number | null;
+
+/** A parsed JSON-RPC request/notification (validated by {@link parseJsonRpcMessage}). */
+export interface JsonRpcMessage {
+ readonly jsonrpc: "2.0";
+ readonly method: string;
+ readonly id?: JsonRpcId;
+ readonly params?: Record;
+}
+
+/** A JSON-RPC success/error response object to serialize back to the client. */
+export type JsonRpcResponse =
+ | { jsonrpc: "2.0"; id: JsonRpcId; result: unknown }
+ | { jsonrpc: "2.0"; id: JsonRpcId; error: { code: number; message: string; data?: unknown } };
+
+/** Executes a validated tool call against the authenticated scope. */
+export type CallTool = (
+ name: string,
+ args: unknown,
+) => Effect.Effect;
+
+const success = (id: JsonRpcId, result: unknown): JsonRpcResponse => ({
+ jsonrpc: "2.0",
+ id,
+ result,
+});
+
+const failure = (
+ id: JsonRpcId,
+ code: number,
+ message: string,
+ data?: unknown,
+): JsonRpcResponse => ({
+ jsonrpc: "2.0",
+ id,
+ error: data === undefined ? { code, message } : { code, message, data },
+});
+
+/**
+ * Validate an already-JSON-parsed value as a JSON-RPC 2.0 message. Returns the
+ * narrowed message or an `InvalidRequest` reason (missing `jsonrpc`/`method`).
+ * A notification is a message with no `id`.
+ */
+export const parseJsonRpcMessage = (
+ value: unknown,
+): { ok: true; message: JsonRpcMessage } | { ok: false; reason: string } => {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ // Batches (arrays) are not supported by this stateless single-response server.
+ return { ok: false, reason: "Expected a single JSON-RPC 2.0 request object" };
+ }
+ const record = value as Record;
+ if (record.jsonrpc !== "2.0") {
+ return { ok: false, reason: 'Missing or invalid "jsonrpc": expected "2.0"' };
+ }
+ if (typeof record.method !== "string") {
+ return { ok: false, reason: 'Missing or invalid "method"' };
+ }
+ const id = record.id;
+ if (id !== undefined && id !== null && typeof id !== "string" && typeof id !== "number") {
+ return { ok: false, reason: 'Invalid "id": expected string, number, or null' };
+ }
+ const params =
+ record.params !== undefined && typeof record.params === "object" && record.params !== null
+ ? (record.params as Record)
+ : undefined;
+ return {
+ ok: true,
+ message: { jsonrpc: "2.0", method: record.method, id: id as JsonRpcId, params },
+ };
+};
+
+/** Negotiate a protocol version: echo a supported one, else offer our latest. */
+const negotiateProtocolVersion = (requested: unknown): string =>
+ typeof requested === "string" &&
+ (SUPPORTED_PROTOCOL_VERSIONS as ReadonlyArray).includes(requested)
+ ? requested
+ : LATEST_PROTOCOL_VERSION;
+
+/** The `initialize` result: negotiated version, tool capability, server identity. */
+const initializeResult = (params: Record | undefined) => ({
+ protocolVersion: negotiateProtocolVersion(params?.protocolVersion),
+ capabilities: { tools: {}, resources: {}, prompts: {} },
+ serverInfo: SERVER_INFO,
+ instructions:
+ "Begin a paywall edit session before using scoped tools. The returned editSessionId is the connection handle. Prefer document variables, conditional states, and actions for dynamic behavior without code. Review the final PNG preview and finish with its exact document signature, or revert the session. Use the bash tool to research paywalls read-only before editing (`cat /README.md` for the layout).",
+});
+
+/** The `tools/list` result: the advertised tool descriptors. */
+const toolsListResult = () => ({ tools: mcpToolDescriptors() });
+
+const resourcesListResult = () => ({
+ resources: listSkills().map((skill) => ({
+ uri: skillResourceUri(skill.name),
+ name: skill.name,
+ title: skill.name === "paywall-authoring" ? "Voidhash paywall authoring reference" : skill.name,
+ description: skill.description,
+ mimeType: "text/markdown",
+ })),
+});
+
+const promptsListResult = () => ({
+ prompts: [
+ {
+ name: DESIGN_PROMPT_NAME,
+ title: "Design a paywall",
+ description:
+ "Start a visually verified MCP paywall-authoring workflow with dynamic no-code behavior.",
+ arguments: [
+ { name: "paywallId", description: "Stable paywall id to edit.", required: true },
+ { name: "request", description: "What to change or create.", required: false },
+ ],
+ },
+ ],
+});
+
+/**
+ * Handle a `tools/call`: read `name` + `arguments`, run the executor, and shape
+ * the result as MCP content. A tool failure is `isError: true` content (never a
+ * JSON-RPC error). A missing/invalid `name` is `InvalidParams` (a protocol
+ * error, not a tool error). The executor never fails — the shared core folds
+ * workspace failures into `{ isError }` — so this always resolves to a response.
+ */
+const handleToolsCall = (
+ id: JsonRpcId,
+ params: Record | undefined,
+ callTool: CallTool,
+): Effect.Effect => {
+ const name = params?.name;
+ if (typeof name !== "string" || name.length === 0) {
+ return Effect.succeed(
+ failure(id, JsonRpcErrorCode.InvalidParams, 'tools/call requires a string "name"'),
+ );
+ }
+ const args = params?.arguments ?? {};
+ return callTool(name, args).pipe(
+ Effect.map((result) =>
+ success(id, {
+ content: result.content ?? [{ type: "text", text: result.output }],
+ isError: result.isError,
+ }),
+ ),
+ );
+};
+
+/**
+ * Dispatch one parsed JSON-RPC message. Returns a {@link JsonRpcResponse} to
+ * serialize, or `null` for an accepted notification (`notifications/*`, id-less)
+ * — the route answers those with HTTP 202 and an empty body.
+ *
+ * `callTool` runs a tool against the authenticated project scope; it (and this
+ * effect) require the workspace/chat/auth context, provided by the route.
+ */
+export const handleMcpMessage = (
+ message: JsonRpcMessage,
+ callTool: CallTool,
+): Effect.Effect => {
+ const id: JsonRpcId = message.id ?? null;
+
+ switch (message.method) {
+ case "initialize":
+ return Effect.succeed(success(id, initializeResult(message.params)));
+
+ case "notifications/initialized":
+ // Accepted notification: no response body (the route sends 202).
+ return Effect.succeed(null);
+
+ case "ping":
+ // MCP ping → empty result.
+ return Effect.succeed(success(id, {}));
+
+ case "tools/list":
+ return Effect.succeed(success(id, toolsListResult()));
+
+ case "tools/call":
+ return handleToolsCall(id, message.params, callTool);
+
+ case "resources/list":
+ return Effect.succeed(success(id, resourcesListResult()));
+
+ case "resources/read": {
+ const requestedUri = message.params?.uri;
+ const skill =
+ requestedUri === AUTHORING_RESOURCE_URI
+ ? findSkill("paywall-authoring")
+ : typeof requestedUri === "string"
+ ? skillFromResourceUri(requestedUri)
+ : undefined;
+ if (skill === undefined || typeof requestedUri !== "string") {
+ return Effect.succeed(
+ failure(id, JsonRpcErrorCode.InvalidParams, "Unknown skill resource URI"),
+ );
+ }
+ return Effect.succeed(
+ success(id, {
+ contents: [
+ {
+ uri: requestedUri,
+ mimeType: "text/markdown",
+ text: skill.body(),
+ },
+ ],
+ }),
+ );
+ }
+
+ case "prompts/list":
+ return Effect.succeed(success(id, promptsListResult()));
+
+ case "prompts/get": {
+ if (message.params?.name !== DESIGN_PROMPT_NAME) {
+ return Effect.succeed(
+ failure(id, JsonRpcErrorCode.InvalidParams, "Unknown paywall authoring prompt"),
+ );
+ }
+ const args =
+ message.params.arguments !== null && typeof message.params.arguments === "object"
+ ? (message.params.arguments as Record)
+ : {};
+ const paywallId = args.paywallId;
+ if (typeof paywallId !== "string" || paywallId.length === 0) {
+ return Effect.succeed(
+ failure(
+ id,
+ JsonRpcErrorCode.InvalidParams,
+ 'design_paywall requires a string "paywallId"',
+ ),
+ );
+ }
+ const request =
+ typeof args.request === "string" && args.request.length > 0
+ ? `\nRequested outcome: ${args.request}`
+ : "";
+ return Effect.succeed(
+ success(id, {
+ description: `Design paywall ${paywallId} with a visually verified edit session.`,
+ messages: [
+ {
+ role: "user",
+ content: {
+ type: "text",
+ text: `Design paywall "${paywallId}" using the Voidhash MCP workflow.${request}\n\n${mcpAuthoringGuide()}`,
+ },
+ },
+ ],
+ }),
+ );
+ }
+
+ default: {
+ // Any other `notifications/*` is an id-less message we accept silently.
+ if (message.method.startsWith("notifications/") && message.id === undefined) {
+ return Effect.succeed(null);
+ }
+ return Effect.succeed(
+ failure(id, JsonRpcErrorCode.MethodNotFound, `Method not found: ${message.method}`),
+ );
+ }
+ }
+};
diff --git a/apps/backend/src/mcp/tool-manifest.test.ts b/apps/backend/src/mcp/tool-manifest.test.ts
new file mode 100644
index 000000000..dd729b660
--- /dev/null
+++ b/apps/backend/src/mcp/tool-manifest.test.ts
@@ -0,0 +1,148 @@
+/**
+ * Contract test for the stateful MCP editing manifest: the advertised tool set,
+ * each descriptor's JSON Schema shape, and that a validated tool's dispatcher
+ * folds an invalid-argument call into an `isError` tool result (never a throw).
+ */
+import { Effect } from "effect";
+import { describe, expect, it } from "vite-plus/test";
+
+import { findMcpTool, MCP_TOOLS, mcpToolDescriptors } from "./tool-manifest.ts";
+
+describe("MCP tool manifest", () => {
+ it("advertises the document-first tool set", () => {
+ const names = mcpToolDescriptors().map((d) => d.name);
+ expect(names).toEqual([
+ "list_paywalls",
+ "bash",
+ "begin_paywall_edit",
+ "get_paywall",
+ "get_components",
+ "read_component",
+ "edit_paywall",
+ "duplicate_subtree",
+ "write_component",
+ "rename_component",
+ "delete_component",
+ "get_paywall_preview",
+ "finish_paywall_edit",
+ "revert_paywall_edit",
+ ]);
+ });
+
+ it("no longer advertises the deleted stateless-build tools", () => {
+ const names = mcpToolDescriptors().map((d) => d.name);
+ for (const removed of [
+ "read_file",
+ "get_diagnostics",
+ "validate_paywall",
+ "apply_paywall",
+ ]) {
+ expect(names).not.toContain(removed);
+ }
+ });
+
+ it("bash requires a non-empty command", async () => {
+ const tool = findMcpTool("bash")!;
+ expect(tool.descriptor.inputSchema.required).toEqual(["command"]);
+ const result = await Effect.runPromise(
+ tool.dispatch({ projectId: "proj_1" }, { command: "" }) as Effect.Effect<{
+ output: string;
+ isError: boolean;
+ }>,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("invalid arguments");
+ });
+
+ it("every descriptor has an object JSON Schema with a description", () => {
+ for (const { descriptor } of MCP_TOOLS) {
+ expect(descriptor.description.length).toBeGreaterThan(0);
+ expect(descriptor.inputSchema.type).toBe("object");
+ }
+ });
+
+ it("list_paywalls takes no input (empty object schema)", () => {
+ const tool = findMcpTool("list_paywalls");
+ expect(tool?.descriptor.inputSchema.properties).toEqual({});
+ });
+
+ it("list_paywalls rejects unexpected input", async () => {
+ const tool = findMcpTool("list_paywalls")!;
+ const result = await Effect.runPromise(
+ tool.dispatch({ projectId: "proj_1" }, { paywallId: "pw_1" }) as Effect.Effect<{
+ output: string;
+ isError: boolean;
+ }>,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("invalid arguments");
+ });
+
+ it("get_paywall requires an edit session and allows nodeId + depth", () => {
+ const schema = findMcpTool("get_paywall")!.descriptor.inputSchema;
+ expect((schema.required as string[]).sort()).toEqual(["editSessionId"]);
+ const props = schema.properties as Record;
+ expect(props).toHaveProperty("nodeId");
+ expect(props).toHaveProperty("depth");
+ });
+
+ it("edit_paywall requires an edit session + a non-empty edits array", () => {
+ const schema = findMcpTool("edit_paywall")!.descriptor.inputSchema;
+ const required = (schema.required as string[] | undefined) ?? [];
+ expect(required).toContain("editSessionId");
+ expect(required).toContain("edits");
+ const edits = (schema.properties as Record).edits;
+ expect(edits.type).toBe("array");
+ });
+
+ it("write_component requires editSessionId + path + source", () => {
+ const schema = findMcpTool("write_component")!.descriptor.inputSchema;
+ const required = ((schema.required as string[] | undefined) ?? []).sort();
+ expect(required).toEqual(["editSessionId", "path", "source"]);
+ });
+
+ it("rename_component requires editSessionId + fromPath + toPath", () => {
+ const schema = findMcpTool("rename_component")!.descriptor.inputSchema;
+ const required = ((schema.required as string[] | undefined) ?? []).sort();
+ expect(required).toEqual(["editSessionId", "fromPath", "toPath"]);
+ });
+
+ it("advertises the explicit preview-gated lifecycle", () => {
+ expect(findMcpTool("begin_paywall_edit")!.descriptor.inputSchema.required as string[]).toEqual([
+ "paywallId",
+ ]);
+ const previewRequired =
+ (findMcpTool("get_paywall_preview")!.descriptor.inputSchema.required as string[]) ?? [];
+ expect(previewRequired.sort()).toEqual(["editSessionId"]);
+ const finishRequired =
+ (findMcpTool("finish_paywall_edit")!.descriptor.inputSchema.required as string[]) ?? [];
+ expect(finishRequired).toEqual(
+ expect.arrayContaining(["editSessionId", "reviewedDocumentSignature", "verdict"]),
+ );
+ });
+
+ it("a validated tool folds invalid arguments into an isError result (no throw)", async () => {
+ const tool = findMcpTool("edit_paywall")!;
+ const result = await Effect.runPromise(
+ // Missing required `edits` — the dispatcher validates and folds the failure.
+ tool.dispatch({ projectId: "proj_1" }, { editSessionId: "pw_edit_1" }) as Effect.Effect<{
+ output: string;
+ isError: boolean;
+ }>,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("invalid arguments");
+ });
+
+ it("rejects redundant paywallId arguments after a session is opened", async () => {
+ const tool = findMcpTool("get_paywall")!;
+ const result = await Effect.runPromise(
+ tool.dispatch(
+ { projectId: "proj_1" },
+ { editSessionId: "pw_edit_1", paywallId: "pw_1" },
+ ) as Effect.Effect<{ output: string; isError: boolean }>,
+ );
+ expect(result.isError).toBe(true);
+ expect(result.output).toContain("invalid arguments");
+ });
+});
diff --git a/apps/backend/src/mcp/tool-manifest.ts b/apps/backend/src/mcp/tool-manifest.ts
new file mode 100644
index 000000000..bbe6356fb
--- /dev/null
+++ b/apps/backend/src/mcp/tool-manifest.ts
@@ -0,0 +1,304 @@
+/**
+ * The MCP tool manifest: the JSON-Schema-described tools advertised over
+ * `tools/list` and dispatched over `tools/call`, plus the mapping from a tool
+ * name to the shared workspace-tool core ({@link WorkspaceTools}).
+ *
+ * MCP is document-first and stateful at the editing layer — there is no fork, no
+ * `paywall.tsx`, and no whole-target overwrite. Composition is read as cleaned
+ * document JSON and edited over a leased Mimic participant connection; code
+ * components are managed by their canonical `components/.tsx` path.
+ * Explicit edit sessions wrap scoped operations, subtree duplication is
+ * first-class, and finishing is gated on inspecting a version-bound PNG preview.
+ *
+ * **Schema alignment**: each validated tool's `inputSchema` is derived at module
+ * load from the SAME zod schema its dispatcher parses with, via `z.toJSONSchema`
+ * (zod 4, native). Deriving — rather than hand-writing — means the JSON Schema an
+ * MCP client validates against can never drift from the zod schema the executor
+ * parses with; the contract test (`mcp/tool-manifest.test.ts`) round-trips valid
+ * + invalid samples through both to prove structural agreement.
+ */
+import { documentEditSchema } from "@voidhash/ai-shared";
+import { Effect } from "effect";
+import { z } from "zod";
+
+import * as WorkspaceTools from "../ai/workspace-tools.ts";
+
+/** A JSON Schema object (the `inputSchema` advertised for a tool). */
+export type JsonSchema = Record;
+
+/** The description of one advertised MCP tool. */
+export interface McpToolDescriptor {
+ readonly name: string;
+ readonly description: string;
+ readonly inputSchema: JsonSchema;
+}
+
+/**
+ * One MCP tool: its advertised descriptor + a dispatcher that validates the raw
+ * arguments with the aligned zod schema and runs the shared workspace-tool
+ * core. `dispatch` never fails (`E = never`) — the core folds workspace failures
+ * into a `{ isError: true }` result; an invalid-arguments failure is likewise
+ * folded into a tool result (MCP maps a bad-input tool call to `isError`, not a
+ * JSON-RPC error).
+ */
+export interface McpTool {
+ readonly descriptor: McpToolDescriptor;
+ readonly dispatch: (
+ scope: WorkspaceTools.WorkspaceToolScope,
+ args: unknown,
+ ) => Effect.Effect;
+}
+
+/**
+ * MCP tool input schemas. The internal Pi agent consumes this same manifest,
+ * and `documentEditSchema` remains the single validation vocabulary for every
+ * server-executed document batch.
+ */
+const mcpToolSchemas = {
+ list_paywalls: z.strictObject({}),
+ bash: z.strictObject({
+ command: z
+ .string()
+ .min(1)
+ .describe(
+ "Bash command line. Pipes, &&, and redirects into /tmp are allowed; each call is a fresh shell (no state across calls).",
+ ),
+ }),
+ begin_paywall_edit: z.strictObject({
+ paywallId: z.string().describe("Stable id of the paywall to edit (from list_paywalls)."),
+ }),
+ get_paywall: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ nodeId: z
+ .string()
+ .optional()
+ .describe(
+ "Optional node id to root the returned tree at (defaults to the whole document). Use to zoom into a subtree; ids come from a prior get_paywall.",
+ ),
+ depth: z
+ .number()
+ .int()
+ .optional()
+ .describe(
+ "Optional max depth from the root. Nodes past the limit render as stubs `{ id, type, name?, childCount }` you expand with a follow-up get_paywall(nodeId).",
+ ),
+ }),
+ get_components: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ }),
+ read_component: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ path: z
+ .string()
+ .describe("Canonical document-relative path of a LOCAL component (`components/.tsx`)."),
+ }),
+ edit_paywall: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ edits: z
+ .array(documentEditSchema)
+ .min(1)
+ .describe(
+ "Ordered batch of document edits, applied ATOMICALLY (all-or-nothing) against the LIVE document. Returns minted ids for inserts, or per-edit structured errors naming the offending node/field/value. Setting any background/border/shadow (or path fill/stroke) style field automatically sets the group's `*Enabled` flag to true; set it to `false` explicitly to hide the group non-destructively.",
+ ),
+ }),
+ duplicate_subtree: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ nodeId: z.string().describe("Id of the visual subtree to clone."),
+ parentId: z.string().describe("Id of the destination parent."),
+ index: z.number().int().optional().describe("Optional destination child index."),
+ nextName: z.string().optional().describe("Optional display name for the cloned root."),
+ }),
+ write_component: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ path: z
+ .string()
+ .describe(
+ "Canonical document-relative path (`components/.tsx`). Writing a path that does not exist yet CREATES the component (its path IS its identity).",
+ ),
+ source: z.string().describe("Full TSX component source."),
+ }),
+ rename_component: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ fromPath: z.string().describe("Existing component path (`components/.tsx`)."),
+ toPath: z
+ .string()
+ .describe(
+ "New component path. Instances referencing the old path are re-pointed automatically.",
+ ),
+ }),
+ delete_component: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ path: z.string().describe("Component path to delete (`components/.tsx`)."),
+ }),
+ get_paywall_preview: z.strictObject({
+ editSessionId: z.string().describe("Active edit session returned by begin_paywall_edit."),
+ width: z
+ .number()
+ .int()
+ .min(240)
+ .max(1440)
+ .optional()
+ .describe("Viewport width; defaults to 375."),
+ height: z
+ .number()
+ .int()
+ .min(240)
+ .max(1600)
+ .optional()
+ .describe("Viewport height; defaults to 812."),
+ scale: z
+ .union([z.literal(1), z.literal(2)])
+ .optional()
+ .describe("Device scale; defaults to 1."),
+ }),
+ finish_paywall_edit: z.strictObject({
+ editSessionId: z.string().describe("Active edit session to finish."),
+ reviewedDocumentSignature: z
+ .string()
+ .describe("Exact documentSignature from the latest get_paywall_preview result."),
+ verdict: z.string().min(1).describe("Concise visual QA verdict based on the preview image."),
+ unresolvedIssues: z
+ .array(z.string())
+ .default([])
+ .describe("Any remaining visual issues. Must be empty to finish."),
+ }),
+ revert_paywall_edit: z.strictObject({
+ editSessionId: z.string().describe("Edit session whose edits should be reverted."),
+ }),
+} as const;
+
+/**
+ * Build a tool that validates its arguments with `schema`, then runs `run` with
+ * the parsed input. A zod parse failure is folded into an `isError` tool result
+ * (never a JSON-RPC error), matching the shared core's fold-don't-throw contract.
+ */
+const validatedTool = (
+ name: string,
+ description: string,
+ schema: z.ZodType,
+ run: (
+ scope: WorkspaceTools.WorkspaceToolScope,
+ input: Input,
+ ) => Effect.Effect,
+): McpTool => ({
+ descriptor: {
+ name,
+ description,
+ inputSchema: z.toJSONSchema(schema) as JsonSchema,
+ },
+ dispatch: (scope, args) => {
+ const parsed = schema.safeParse(args);
+ if (!parsed.success) {
+ const issues = parsed.error.issues
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
+ .join("; ");
+ return Effect.succeed({
+ output: `${name}: invalid arguments — ${issues}`,
+ isError: true,
+ });
+ }
+ return run(scope, parsed.data);
+ },
+});
+
+/**
+ * The MCP tools, in advertised order: discovery (`list_paywalls`), research
+ * (`bash` over the read-only workspace VFS), read (`get_paywall` /
+ * `get_components` / `read_component`), then the write surface (`edit_paywall`
+ * for composition, `write_component` / `rename_component` / `delete_component`
+ * for code components).
+ */
+export const MCP_TOOLS: ReadonlyArray = [
+ validatedTool(
+ "list_paywalls",
+ "List every paywall in the project with its stable paywallId, display slug, and workspace path. Pass a paywallId to begin_paywall_edit, then use its editSessionId for every scoped tool.",
+ mcpToolSchemas.list_paywalls,
+ (scope) => WorkspaceTools.listPaywalls(scope),
+ ),
+ validatedTool(
+ "bash",
+ "Run a read-only bash command over a virtual filesystem projecting this project's paywall workspace — use it to RESEARCH, not edit. Standard tools work: ls, cat, grep, rg, find, head, tail, sed, awk, jq, wc, sort, diff, tree. Layout: /README.md (this map), /paywalls//document.json (cleaned document JSON, same shape as get_paywall), /paywalls//components/