diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts index f57b22c98..a19bf535d 100644 --- a/electron/mcp/openscreen-mcp-server.test.ts +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -121,8 +121,12 @@ describe("the MCP tool surface", () => { it("is exactly the in-app agent's tools, with its descriptions", async () => { const mcp = await connect(new FakeEditor()); const { tools } = await mcp.listTools(); - expect(tools.map((t) => t.name)).toEqual([...OPENSCREEN_TOOL_NAMES]); - for (const tool of tools) { + expect(tools.map((t) => t.name)).toEqual([ + ...OPENSCREEN_TOOL_NAMES, + "createCheckpoint", + "restoreCheckpoint", + ]); + for (const tool of tools.slice(0, OPENSCREEN_TOOL_NAMES.length)) { expect(tool.description).toBe(TOOL_DESCRIPTIONS[tool.name]); expect(tool.inputSchema.type).toBe("object"); } @@ -140,6 +144,8 @@ describe("the MCP tool surface", () => { expect(byName.get("getCurrentDocument")?.readOnlyHint).toBe(true); expect(byName.get("addTrim")?.readOnlyHint).toBe(false); expect(byName.get("removeClip")?.destructiveHint).toBe(true); + expect(byName.get("createCheckpoint")?.readOnlyHint).toBe(true); + expect(byName.get("restoreCheckpoint")?.destructiveHint).toBe(true); }); it("hands the client the in-app agent's guidance as server instructions", async () => { @@ -212,6 +218,69 @@ describe("calling a tool", () => { }); }); +describe("checkpoints", () => { + async function checkpoint(mcp: Client): Promise { + const result = await mcp.callTool({ name: "createCheckpoint", arguments: {} }); + expect(result.isError).toBeFalsy(); + return JSON.parse(resultText(result)).checkpointId; + } + + function addTrim(mcp: Client, startSec: number) { + return mcp.callTool({ + name: "addTrim", + arguments: { assetId: "asset_1", startSec, endSec: startSec + 1 }, + }); + } + + it("reverts several edits in one apply", async () => { + const editor = new FakeEditor(); + const before = structuredClone(editor.document); + const mcp = await connect(editor); + const checkpointId = await checkpoint(mcp); + expect(editor.applied).toHaveLength(0); + await addTrim(mcp, 5); + await addTrim(mcp, 10); + expect(editor.document?.timeline.trimRanges).toHaveLength(2); + + const result = await mcp.callTool({ name: "restoreCheckpoint", arguments: { checkpointId } }); + expect(result.isError).toBeFalsy(); + expect(editor.applied).toHaveLength(3); + expect(editor.document).toEqual(before); + }); + + it("refuses to restore when the user has turned project edits off", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor, { editsAllowed: false }); + const checkpointId = await checkpoint(mcp); + const result = await mcp.callTool({ name: "restoreCheckpoint", arguments: { checkpointId } }); + expect(result.isError).toBe(true); + expect(editor.applied).toHaveLength(0); + }); + + it("refuses an unknown checkpoint", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor); + const result = await mcp.callTool({ + name: "restoreCheckpoint", + arguments: { checkpointId: "cp_nope" }, + }); + expect(result.isError).toBe(true); + expect(editor.applied).toHaveLength(0); + }); + + it("never restores one project's checkpoint over another project", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor); + const checkpointId = await checkpoint(mcp); + const other = fixtureDocument(); + editor.document = { ...other, project: { ...other.project, id: "proj_2" } }; + const result = await mcp.callTool({ name: "restoreCheckpoint", arguments: { checkpointId } }); + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("another project"); + expect(editor.applied).toHaveLength(0); + }); +}); + describe("the HTTP guard", () => { async function post(headers: Record, path = MCP_ENDPOINT_PATH) { running = await startMcpHttpServer({ diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index 1e24700b9..d8e046306 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -11,16 +11,22 @@ // the same revision-guarded apply, so a user edit landing mid-call is never // overwritten and every edit is one undo step. // +// Two tools exist only here: `createCheckpoint` / `restoreCheckpoint`. A client +// chains several edits per turn, and undo is per call, so they give it one step +// back to where the turn started. The in-app agent has no need for them: its +// whole turn is already a single apply. +// // The HTTP layer is local-only: bound to 127.0.0.1, a bearer token on every // request, and a Host/Origin check so a web page cannot reach it by DNS // rebinding. -import { timingSafeEqual } from "node:crypto"; +import { randomUUID, timingSafeEqual } from "node:crypto"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; import { type AxcutDocument, documentSchema } from "../../src/lib/ai-edition/schema"; import { isMutatingTool } from "../ai-edition/agent-tools"; import { @@ -89,9 +95,31 @@ const DESTRUCTIVE_TOOLS: ReadonlySet = new Set([ const MCP_PREAMBLE = [ "These tools act on the project currently open in the OpenScreen editor. Every edit is saved straight away and appears in the editor, where the user can undo it with Ctrl/Cmd+Z. Nothing here records, exports or imports media.", + "Before a series of edits, call createCheckpoint. Undo is one step per call, so if the result is not what the user wanted, restoreCheckpoint takes the project back in one step instead of many.", "", ].join("\n"); +// ponytail: kept in memory, last 20 only. Lost when OpenScreen quits or the MCP +// server is turned off; persist them per project if agents need them across sessions. +const MAX_CHECKPOINTS = 20; + +const CHECKPOINT_TOOLS = [ + { + name: "createCheckpoint", + description: + "Save the current state of the open project and return its checkpointId. Changes nothing. Call it before a series of edits so restoreCheckpoint can revert all of them in one step.", + inputSchema: z.object({}), + mutating: false, + }, + { + name: "restoreCheckpoint", + description: + "Put the open project back exactly as it was when createCheckpoint returned this checkpointId, discarding every edit made since, including the user's. Lands as one undo step, so the user can undo the restore itself.", + inputSchema: z.object({ checkpointId: z.string() }), + mutating: true, + }, +] as const; + function textResult(text: string, isError: boolean): CallToolResult { return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }; } @@ -107,6 +135,48 @@ function errorMessage(error: unknown): string { */ export function createToolRunner(deps: McpToolDeps) { let queue: Promise = Promise.resolve(); + const checkpoints = new Map(); + + function createCheckpoint(document: AxcutDocument): CallToolResult { + const checkpointId = `cp_${randomUUID()}`; + checkpoints.set(checkpointId, document); + // A Map iterates in insertion order: the first key is the oldest. + for (const oldest of checkpoints.keys()) { + if (checkpoints.size <= MAX_CHECKPOINTS) break; + checkpoints.delete(oldest); + } + return textResult(JSON.stringify({ checkpointId }), false); + } + + async function restoreCheckpoint( + document: AxcutDocument, + revision: number, + args: unknown, + ): Promise { + if (!deps.editsAllowed()) { + return textResult( + "Project edits are turned off in OpenScreen, so the checkpoint was NOT restored. Ask the user to re-enable 'Project edits' in Settings → AI, or to undo the edits themselves.", + true, + ); + } + const id = (args as { checkpointId?: unknown } | null)?.checkpointId; + const checkpoint = typeof id === "string" ? checkpoints.get(id) : undefined; + if (!checkpoint) { + return textResult( + "Unknown checkpointId. Checkpoints last until OpenScreen quits, and only the 20 most recent are kept.", + true, + ); + } + if (checkpoint.project.id !== document.project.id) { + return textResult( + "The edit was NOT applied: this checkpoint belongs to another project than the one open in the editor.", + true, + ); + } + const applied = await deps.host.apply(checkpoint, revision); + if (applied !== "applied") return textResult(APPLY_FAILURE_MESSAGES[applied], true); + return textResult(JSON.stringify({ ok: true, restored: id }), false); + } async function run(name: string, args: unknown): Promise { const snapshot = await deps.host.snapshot(); @@ -116,6 +186,8 @@ export function createToolRunner(deps: McpToolDeps) { return textResult("The project open in the editor could not be read.", true); } const document = parsed.data; + if (name === "createCheckpoint") return createCheckpoint(document); + if (name === "restoreCheckpoint") return restoreCheckpoint(document, snapshot.revision, args); const availableByAssetId = await probeCursorTelemetry(document, deps.cursor); const execution = await runDocumentTool(document, name, args, deps.editsAllowed(), { cursor: deps.cursor, @@ -162,6 +234,21 @@ export function createOpenScreenMcpServer( (args: unknown) => runTool(name, args), ); } + for (const tool of CHECKPOINT_TOOLS) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.inputSchema, + annotations: { + readOnlyHint: !tool.mutating, + destructiveHint: tool.mutating, + openWorldHint: false, + }, + }, + (args: unknown) => runTool(tool.name, args), + ); + } return server; } diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 4d3ca2155..7b7a336b2 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -15,7 +15,7 @@ OpenScreen can offer the in-app agent's tools to MCP clients the user runs thems Nothing about the tools is reimplemented. The server registers `TOOL_ARG_SCHEMAS` (names and zod schemas), `TOOL_DESCRIPTIONS`, and hands `buildSystemPrompt` to the client as the server `instructions` — all exported from [`deep-agent/service.ts`](../../electron/ai-edition/deep-agent/service.ts), where `buildTools` builds the in-app agent from the same table. Every call goes through `runDocumentTool`, the function the in-app agent's `documentTool` also calls: the cursor-telemetry read the zoom tools need, then `executeAgentTool`. -So a tool added to the agent appears over MCP with no further work, and the MCP test asserts the listed tools equal `OPENSCREEN_TOOL_NAMES`. What the server adds is MCP metadata only: `readOnlyHint` for the reads (`!isMutatingTool`) and `destructiveHint` for `replaceTimeline` and the three `remove*` tools. +So a tool added to the agent appears over MCP with no further work, and the MCP test asserts the listed tools equal `OPENSCREEN_TOOL_NAMES`, followed by the two checkpoint tools below, the only ones that exist over MCP alone. What the server adds is MCP metadata only: `readOnlyHint` for the reads (`!isMutatingTool`) and `destructiveHint` for `replaceTimeline`, the three `remove*` tools and `restoreCheckpoint`. **Writes are a second opt-in.** MCP clients have their own "Project edits" switch, `allowEdits` in `mcp-server.json`, **off by default** and independent of the in-app agent's `allowAgentEdits`. Turning the server on therefore grants read access only. The value is read on every call and passed to the executor as `editsAllowed`, the same gate the in-app agent's switch drives: while it is off every mutating tool is refused with the executor's consent message, and the consent block of the system prompt is in the instructions. It is independent because `allowAgentEdits` defaults to allowed and lives in the provider form, so a user with no provider configured could never have turned MCP writes off. @@ -31,6 +31,14 @@ A user edit that lands between 1 and 3 moves the revision and the apply is refus Only the webContents that registered on `ai-edition.mcp-host` is asked, and only its replies count. An editor that unmounts or is destroyed stops being asked. A request unanswered for 30 s resolves to "no project" (reads) or `timeout` (writes, reported as "did not confirm", not as a failure, since the save may have landed). +## Checkpoints + +A client chains several edits in one turn, and each is its own undo step, so reverting a turn by hand means one Ctrl+Z per call, interleaved with whatever the user did meanwhile. `createCheckpoint` saves the live document in the main process and returns a `checkpointId`; `restoreCheckpoint` applies that document back through the same revision-guarded apply, so the whole revert is **one undo step** the user can itself undo. The server instructions tell the client to checkpoint before a series of edits. + +- A restore is a write: refused while MCP "Project edits" is off, and refused when the checkpoint's project id is not the open project's. +- It discards every edit since the checkpoint, the user's included. The tool description says so. +- Checkpoints live in memory, the 20 most recent. They are lost when the app quits or the server is turned off. The in-app agent does not need them: its whole turn is a single apply. + ## Transport and security - Streamable HTTP, **stateless**: a fresh `McpServer` + transport per request, the SDK's documented shape for a server that keeps no session state. diff --git a/website/docs/ai-editing.md b/website/docs/ai-editing.md index cf3399a66..99337601a 100644 --- a/website/docs/ai-editing.md +++ b/website/docs/ai-editing.md @@ -67,4 +67,4 @@ If you already use an AI coding agent such as Claude Code or Codex, it can drive 2. Copy the **Claude Code** or **Codex** command shown there and run it in a terminal. The Claude Code command carries the access token. Codex reads it from the `OPENSCREEN_MCP_TOKEN` environment variable instead: set that in the shell you start Codex from. 3. Keep a project open in the OpenScreen editor, then ask your agent for the edit. -The tools are the ones the built-in agent uses, and they act on the project open in the editor. MCP clients can only **read** the project until you also turn on **Project edits** in the MCP server section; this switch is separate from the built-in agent's, and it starts off. Once on, each edit is saved as it lands and undone with `Ctrl/Cmd + Z`. **Regenerate** the token to disconnect every client set up with the old one. +The tools are the ones the built-in agent uses, and they act on the project open in the editor. MCP clients can only **read** the project until you also turn on **Project edits** in the MCP server section; this switch is separate from the built-in agent's, and it starts off. Once on, each edit is saved as it lands and undone with `Ctrl/Cmd + Z`. Clients are told to save a checkpoint before a series of edits, so you can ask them to put the project back as it was in a single step, which `Ctrl/Cmd + Z` can itself undo. **Regenerate** the token to disconnect every client set up with the old one.