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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4873,7 +4873,9 @@ export function registerIpcHandlers(
};

// The local MCP server offers the agent's tools to MCP clients the user runs
// (Claude Code, Codex…). Built here because this is where the agent's own
// (Claude Code, Codex…), on any project: the open one through the editor, the
// rest through the same DocumentService instance as every other save (see
// above for why there is only one). Built here because this is where the agent's own
// dependencies live, but NOT started here: the headless CLI shares this
// function and must never bind the port a running app is listening on.
// `main.ts` starts it. Its writes have their own switch, off by default and
Expand All @@ -4882,6 +4884,7 @@ export function registerIpcHandlers(
const mcpSettings = new McpSettingsStore(app.getPath("userData"), safeStorage);
const mcpController = new McpController(mcpSettings, {
host: new EditorDocumentHost(ipcMain),
projects: aiEditionDocuments,
editsAllowed: () => mcpSettings.getSettings().allowEdits,
cursor: agentCursorTelemetryReader,
version: app.getVersion(),
Expand Down
8 changes: 8 additions & 0 deletions electron/mcp/mcp-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer, type Server } from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { DocumentNotFoundError } from "../ai-edition/document-service";
import { McpController } from "./mcp-controller";
import { DEFAULT_MCP_PORT, McpSettingsStore, type McpTokenCrypto } from "./mcp-settings-store";
import type { McpToolDeps } from "./openscreen-mcp-server";
Expand All @@ -16,6 +17,13 @@ const fakeCrypto: McpTokenCrypto = {

const deps: McpToolDeps = {
host: { snapshot: async () => null, apply: async () => "no-editor" },
projects: {
listProjects: async () => [],
getProject: async (id) => {
throw new DocumentNotFoundError(id);
},
saveProject: async (document) => document,
},
editsAllowed: () => true,
version: "0.0.0",
};
Expand Down
200 changes: 190 additions & 10 deletions electron/mcp/openscreen-mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
// End-to-end over real HTTP: the SDK's own client against the server on an
// ephemeral port, with an in-memory stand-in for the editor window.
// ephemeral port, with an in-memory stand-in for the editor window and a real
// DocumentService on a temp directory for the projects that are not open.

import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type AxcutDocument,
createEmptyDocument,
documentSchema,
} from "../../src/lib/ai-edition/schema";
import { OPENSCREEN_TOOL_NAMES } from "../ai-edition/agent-tools";
import { TOOL_DESCRIPTIONS } from "../ai-edition/deep-agent/service";
import { DocumentService } from "../ai-edition/document-service";
import {
LIST_PROJECTS_TOOL,
MCP_ENDPOINT_PATH,
type McpApplyResult,
type McpDocumentHost,
Expand All @@ -21,10 +27,10 @@ import {

const TOKEN = "test-token-0123456789";

function fixtureDocument(): AxcutDocument {
function fixtureDocument(projectId = "proj_1"): AxcutDocument {
const base = createEmptyDocument({
title: "Test",
projectId: "proj_1",
title: `Test ${projectId}`,
projectId,
createdAt: "2026-01-01T00:00:00.000Z",
});
return documentSchema.parse({
Expand Down Expand Up @@ -85,12 +91,20 @@ class FakeEditor implements McpDocumentHost {

let running: RunningMcpServer | null = null;
let client: Client | null = null;
let dir: string;
let projects: DocumentService;

beforeEach(() => {
dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-mcp-projects-"));
projects = new DocumentService(path.join(dir, "projects"), dir);
});

afterEach(async () => {
await client?.close();
await running?.close();
client = null;
running = null;
rmSync(dir, { recursive: true, force: true });
});

async function connect(
Expand All @@ -100,7 +114,12 @@ async function connect(
running = await startMcpHttpServer({
port: 0,
token: TOKEN,
deps: { host: editor, editsAllowed: () => options.editsAllowed ?? true, version: "0.0.0" },
deps: {
host: editor,
projects,
editsAllowed: () => options.editsAllowed ?? true,
version: "0.0.0",
},
});
client = new Client({ name: "test", version: "0.0.0" });
const url = new URL(`http://127.0.0.1:${running.port}${MCP_ENDPOINT_PATH}`);
Expand All @@ -118,13 +137,15 @@ function resultText(result: Awaited<ReturnType<Client["callTool"]>>): string {
}

describe("the MCP tool surface", () => {
it("is exactly the in-app agent's tools, with its descriptions", async () => {
it("is listProjects plus 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([LIST_PROJECTS_TOOL, ...OPENSCREEN_TOOL_NAMES]);
for (const tool of tools.filter((t) => t.name !== LIST_PROJECTS_TOOL)) {
expect(tool.description).toBe(TOOL_DESCRIPTIONS[tool.name]);
expect(tool.inputSchema.type).toBe("object");
expect(Object.keys(tool.inputSchema.properties ?? {})).toContain("projectId");
expect(tool.inputSchema.required ?? []).not.toContain("projectId");
}
// The zod schemas survive the trip to JSON Schema with their fields intact.
const addTrim = tools.find((t) => t.name === "addTrim");
Expand All @@ -138,6 +159,7 @@ describe("the MCP tool surface", () => {
const { tools } = await mcp.listTools();
const byName = new Map(tools.map((t) => [t.name, t.annotations]));
expect(byName.get("getCurrentDocument")?.readOnlyHint).toBe(true);
expect(byName.get(LIST_PROJECTS_TOOL)?.readOnlyHint).toBe(true);
expect(byName.get("addTrim")?.readOnlyHint).toBe(false);
expect(byName.get("removeClip")?.destructiveHint).toBe(true);
});
Expand Down Expand Up @@ -212,12 +234,170 @@ describe("calling a tool", () => {
});
});

describe("projects other than the open one", () => {
const addTrimArgs = { assetId: "asset_1", startSec: 5, endSec: 6 };

it("lists every project and flags the one open in the editor", async () => {
await projects.saveProject(fixtureDocument("proj_1"));
await projects.saveProject(fixtureDocument("proj_2"));
const mcp = await connect(new FakeEditor());
const result = await mcp.callTool({ name: LIST_PROJECTS_TOOL, arguments: {} });
expect(result.isError).toBeFalsy();
const listed = JSON.parse(resultText(result)).projects as Array<{ id: string; open: boolean }>;
expect(listed.map((p) => [p.id, p.open]).sort()).toEqual([
["proj_1", true],
["proj_2", false],
]);
});

it("reads a project that is not open", async () => {
await projects.saveProject(fixtureDocument("proj_2"));
const mcp = await connect(new FakeEditor());
const result = await mcp.callTool({
name: "getCurrentDocument",
arguments: { projectId: "proj_2" },
});
expect(result.isError).toBeFalsy();
expect(resultText(result)).toContain("Test proj_2");
});

it("saves an edit to the project's file and leaves the editor alone", async () => {
await projects.saveProject(fixtureDocument("proj_2"));
const editor = new FakeEditor();
const mcp = await connect(editor);
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBeFalsy();
expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(1);
expect(editor.applied).toHaveLength(0);
expect(editor.document?.timeline.trimRanges).toHaveLength(0);
});

it("edits a project with no editor open at all", async () => {
await projects.saveProject(fixtureDocument("proj_2"));
const editor = new FakeEditor();
editor.document = null;
const mcp = await connect(editor);
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBeFalsy();
expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(1);
});

it("routes the open project's id through the editor, never its file", async () => {
await projects.saveProject(fixtureDocument("proj_1"));
const editor = new FakeEditor();
const mcp = await connect(editor);
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_1" },
});
expect(result.isError).toBeFalsy();
expect(editor.applied).toHaveLength(1);
expect((await projects.getProject("proj_1")).timeline.trimRanges).toHaveLength(0);
});

it("names listProjects for an unknown id", async () => {
const mcp = await connect(new FakeEditor());
const result = await mcp.callTool({
name: "getCurrentDocument",
arguments: { projectId: "proj_missing" },
});
expect(result.isError).toBe(true);
expect(resultText(result)).toContain("listProjects");
});

it("refuses a write when edits are off", async () => {
await projects.saveProject(fixtureDocument("proj_2"));
const mcp = await connect(new FakeEditor(), { editsAllowed: false });
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBe(true);
expect((await projects.getProject("proj_2")).timeline.trimRanges).toHaveLength(0);
});

it("does not save over a project the editor opened mid-call", async () => {
const saved = await projects.saveProject(fixtureDocument("proj_2"));
const editor = new FakeEditor();
const getProject = projects.getProject.bind(projects);
let reads = 0;
projects.getProject = async (id) => {
const document = await getProject(id);
if (++reads === 1) editor.document = saved;
return document;
};
const mcp = await connect(editor);
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBe(true);
expect(resultText(result)).toContain("NOT applied");
expect((await getProject("proj_2")).timeline.trimRanges).toHaveLength(0);
});

it("does not save over a project saved elsewhere mid-call", async () => {
const saved = await projects.saveProject(fixtureDocument("proj_2"));
const getProject = projects.getProject.bind(projects);
let reads = 0;
projects.getProject = async (id) => {
const document = await getProject(id);
if (++reads === 1) {
// updatedAt has millisecond resolution; make sure the second save moves it.
await new Promise((resolve) => setTimeout(resolve, 5));
await projects.saveProject({ ...saved, project: { ...saved.project, title: "Renamed" } });
}
return document;
};
const mcp = await connect(new FakeEditor());
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBe(true);
const onDisk = await getProject("proj_2");
expect(onDisk.project.title).toBe("Renamed");
expect(onDisk.timeline.trimRanges).toHaveLength(0);
});

it("does not save over a change that kept the file's updatedAt", async () => {
const saved = await projects.saveProject(fixtureDocument("proj_2"));
const file = path.join(dir, "projects", "proj_2.openscreen");
const getProject = projects.getProject.bind(projects);
let reads = 0;
projects.getProject = async (id) => {
const document = await getProject(id);
if (++reads === 1) {
// A writer outside the app (a sync tool, a restored copy): same stamp, new content.
const renamed = { ...saved, project: { ...saved.project, title: "Renamed" } };
writeFileSync(file, JSON.stringify(renamed));
}
return document;
};
const mcp = await connect(new FakeEditor());
const result = await mcp.callTool({
name: "addTrim",
arguments: { ...addTrimArgs, projectId: "proj_2" },
});
expect(result.isError).toBe(true);
const onDisk = await getProject("proj_2");
expect(onDisk.project.title).toBe("Renamed");
expect(onDisk.timeline.trimRanges).toHaveLength(0);
});
});

describe("the HTTP guard", () => {
async function post(headers: Record<string, string>, path = MCP_ENDPOINT_PATH) {
running = await startMcpHttpServer({
port: 0,
token: TOKEN,
deps: { host: new FakeEditor(), editsAllowed: () => true, version: "0.0.0" },
deps: { host: new FakeEditor(), projects, editsAllowed: () => true, version: "0.0.0" },
});
return fetch(`http://127.0.0.1:${running.port}${path}`, {
method: "POST",
Expand Down
Loading
Loading