From d62b285a2fd96be83bc8051ac0eca8d948d99a50 Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Tue, 6 Oct 2026 23:31:42 +0530 Subject: [PATCH 1/2] feat(mcp): let MCP clients read and edit every project, not only the open one v2 of the MCP server. A new `listProjects` tool returns every project with an `open` flag, and every agent tool takes an optional `projectId`: - omitted, or the open project's id: through the editor, exactly as before (revision-guarded apply, saved, one undo step). - any other id: read with DocumentService.getProject and saved with saveProject, the app's single instance and its per-project write queue. Works with no editor window at all. The open project is never written to disk from here, since the editor would overwrite it on its next save. For the file path, right before saving, the edit is refused if the editor has opened that project meanwhile or its file's updatedAt has moved. `projectId` is stripped before the executor sees it. Settings copy updated in all 15 locales; mcp-server.md documents the routing, the guard, and its remaining gaps (a small open-vs-save window, no undo for edits to closed projects, no create/rename/delete). Co-Authored-By: Claude Opus 5.5 --- electron/ipc/handlers.ts | 5 +- electron/mcp/mcp-controller.test.ts | 8 + electron/mcp/openscreen-mcp-server.test.ts | 175 +++++++++++++++++- electron/mcp/openscreen-mcp-server.ts | 142 ++++++++++++-- src/i18n/locales/ar/editor.json | 6 +- src/i18n/locales/cs/editor.json | 6 +- src/i18n/locales/de/editor.json | 6 +- src/i18n/locales/en/editor.json | 6 +- src/i18n/locales/es/editor.json | 6 +- src/i18n/locales/fr/editor.json | 6 +- src/i18n/locales/it/editor.json | 6 +- src/i18n/locales/ja-JP/editor.json | 4 +- src/i18n/locales/ko-KR/editor.json | 4 +- src/i18n/locales/pt-BR/editor.json | 6 +- src/i18n/locales/ru/editor.json | 6 +- src/i18n/locales/tr/editor.json | 6 +- src/i18n/locales/vi/editor.json | 6 +- src/i18n/locales/zh-CN/editor.json | 6 +- src/i18n/locales/zh-TW/editor.json | 6 +- .../architecture/mcp-server.md | 35 +++- 20 files changed, 381 insertions(+), 70 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 81e706c08..2e56de5c2 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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 @@ -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(), diff --git a/electron/mcp/mcp-controller.test.ts b/electron/mcp/mcp-controller.test.ts index 3a23bae09..bb5c79d01 100644 --- a/electron/mcp/mcp-controller.test.ts +++ b/electron/mcp/mcp-controller.test.ts @@ -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"; @@ -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", }; diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts index f57b22c98..9e4aeb529 100644 --- a/electron/mcp/openscreen-mcp-server.test.ts +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -1,9 +1,13 @@ // 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 } 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, @@ -11,7 +15,9 @@ import { } 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, @@ -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({ @@ -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( @@ -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}`); @@ -118,13 +137,15 @@ function resultText(result: Awaited>): 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"); @@ -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); }); @@ -212,12 +234,145 @@ 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); + }); +}); + describe("the HTTP guard", () => { async function post(headers: Record, 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", diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index 1e24700b9..e230228c3 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -1,5 +1,6 @@ // The OpenScreen MCP server: the in-app agent's tools, offered to any MCP client -// (Claude Code, Codex, Cursor…) that the user runs themselves. +// (Claude Code, Codex, Cursor…) that the user runs themselves, on any of the +// user's projects — not only the one open in the editor. // // Nothing here is a second implementation. The tool list, argument schemas, // descriptions and guidance are the in-app agent's own (`TOOL_ARG_SCHEMAS`, @@ -11,6 +12,11 @@ // the same revision-guarded apply, so a user edit landing mid-call is never // overwritten and every edit is one undo step. // +// A call can name another project with `projectId` (see `listProjects`). The +// project open in the editor is still only ever edited through the editor; any +// other one is read from and saved to its file, guarded against the editor +// opening it, or anything else saving it, while the call ran. +// // 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. @@ -21,6 +27,7 @@ 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 { @@ -31,6 +38,7 @@ import { TOOL_ARG_SCHEMAS, TOOL_DESCRIPTIONS, } from "../ai-edition/deep-agent/service"; +import { DocumentNotFoundError, type ProjectSummary } from "../ai-edition/document-service"; export const MCP_SERVER_NAME = "openscreen"; export const MCP_ENDPOINT_PATH = "/mcp"; @@ -58,16 +66,38 @@ export interface McpDocumentHost { apply(document: AxcutDocument, expectedRevision: number): Promise; } +/** Every project on disk — the subset of `DocumentService` the server needs. */ +export interface McpProjectStore { + listProjects(): Promise; + /** Throws `DocumentNotFoundError` for an unknown id. */ + getProject(projectId: string): Promise; + saveProject(document: AxcutDocument): Promise; +} + export interface McpToolDeps { host: McpDocumentHost; + projects: McpProjectStore; /** The "Project edits" setting — the same one the in-app agent obeys. */ editsAllowed(): boolean; cursor?: CursorTelemetryReader; version: string; } +export const LIST_PROJECTS_TOOL = "listProjects"; + +const LIST_PROJECTS_DESCRIPTION = + "List every OpenScreen project: id, title, last update, asset count, and whether it is the one open in the editor. Pass a project's id as `projectId` to any other tool to read or edit that project."; + +const PROJECT_ID_DESCRIPTION = + "The project to act on, from listProjects. Omit it for the project open in the editor."; + const NO_PROJECT_MESSAGE = - "No project is open in the OpenScreen editor. Ask the user to open one in OpenScreen, then retry."; + "No project is open in the OpenScreen editor. Call listProjects and pass a projectId, or ask the user to open one in OpenScreen."; + +// The editor would overwrite a file edit with its own copy on its next save, so a +// project it opened mid-call is left to it; the retry then goes through the editor. +const PROJECT_CHANGED_MESSAGE = + "The edit was NOT applied: the project was opened in the editor or saved elsewhere while this call ran. Call getCurrentDocument with the same projectId to re-read it, then retry."; const APPLY_FAILURE_MESSAGES: Record, string> = { conflict: @@ -88,7 +118,7 @@ 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.", + "These tools act on the user's OpenScreen projects. Without a projectId they act on the project open in the editor; call listProjects to find any other project and pass its id as projectId. Every edit is saved straight away. An edit to the open project appears in the editor, where the user can undo it with Ctrl/Cmd+Z; an edit to any other project is saved to its file and is not on the editor's undo stack. Nothing here records, exports or imports media.", "", ].join("\n"); @@ -100,6 +130,18 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function openProjectId(snapshot: McpDocumentSnapshot | null): string | null { + const project = (snapshot?.document as { project?: { id?: unknown } } | null)?.project; + return typeof project?.id === "string" ? project.id : null; +} + +/** `projectId` is the server's argument, not the tool's: the executor never sees it. */ +function splitProjectId(rawArgs: unknown): { projectId: string | undefined; args: unknown } { + if (!rawArgs || typeof rawArgs !== "object") return { projectId: undefined, args: rawArgs }; + const { projectId, ...args } = rawArgs as Record; + return { projectId: typeof projectId === "string" ? projectId : undefined, args }; +} + /** * Runs tool calls one at a time. Each call is snapshot → execute → apply, and * two of those interleaving would make the second apply against a revision the @@ -108,19 +150,28 @@ function errorMessage(error: unknown): string { export function createToolRunner(deps: McpToolDeps) { let queue: Promise = Promise.resolve(); - async function run(name: string, args: unknown): Promise { - const snapshot = await deps.host.snapshot(); - if (!snapshot) return textResult(NO_PROJECT_MESSAGE, true); + async function listProjects(): Promise { + const [projects, snapshot] = await Promise.all([ + deps.projects.listProjects(), + deps.host.snapshot(), + ]); + const openId = openProjectId(snapshot); + const listed = projects.map((project) => ({ ...project, open: project.id === openId })); + return textResult(JSON.stringify({ projects: listed }), false); + } + + /** The project open in the editor: read and written through the editor itself. */ + async function runOnEditor( + snapshot: McpDocumentSnapshot, + name: string, + args: unknown, + ): Promise { const parsed = documentSchema.safeParse(snapshot.document); if (!parsed.success) { return textResult("The project open in the editor could not be read.", true); } const document = parsed.data; - const availableByAssetId = await probeCursorTelemetry(document, deps.cursor); - const execution = await runDocumentTool(document, name, args, deps.editsAllowed(), { - cursor: deps.cursor, - availableByAssetId, - }); + const execution = await execute(document, name, args); if (execution.document) { const applied = await deps.host.apply(execution.document, snapshot.revision); if (applied !== "applied") return textResult(APPLY_FAILURE_MESSAGES[applied], true); @@ -128,6 +179,62 @@ export function createToolRunner(deps: McpToolDeps) { return textResult(execution.resultJson, !execution.ok); } + /** Any other project: read from and saved to its file. */ + async function runOnFile( + projectId: string, + name: string, + args: unknown, + ): Promise { + let document: AxcutDocument; + try { + document = await deps.projects.getProject(projectId); + } catch (error) { + if (error instanceof DocumentNotFoundError) { + return textResult( + `No project has the id "${projectId}". Call listProjects for the valid ids.`, + true, + ); + } + throw error; + } + const execution = await execute(document, name, args); + if (execution.document) { + // Checked after the tool ran, right before the save, since that is the + // window this guard is about: the editor opening the project, or any + // save (an older editor session, the in-app agent) moving its file on. + if (openProjectId(await deps.host.snapshot()) === projectId) { + return textResult(PROJECT_CHANGED_MESSAGE, true); + } + const onDisk = await deps.projects.getProject(projectId); + if (onDisk.project.updatedAt !== document.project.updatedAt) { + return textResult(PROJECT_CHANGED_MESSAGE, true); + } + await deps.projects.saveProject(execution.document); + } + return textResult(execution.resultJson, !execution.ok); + } + + async function execute(document: AxcutDocument, name: string, args: unknown) { + const availableByAssetId = await probeCursorTelemetry(document, deps.cursor); + return runDocumentTool(document, name, args, deps.editsAllowed(), { + cursor: deps.cursor, + availableByAssetId, + }); + } + + async function run(name: string, rawArgs: unknown): Promise { + if (name === LIST_PROJECTS_TOOL) return listProjects(); + const { projectId, args } = splitProjectId(rawArgs); + const snapshot = await deps.host.snapshot(); + if (projectId === undefined) { + return snapshot ? runOnEditor(snapshot, name, args) : textResult(NO_PROJECT_MESSAGE, true); + } + if (snapshot && openProjectId(snapshot) === projectId) { + return runOnEditor(snapshot, name, args); + } + return runOnFile(projectId, name, args); + } + return (name: string, args: unknown): Promise => { const next = queue.then( () => run(name, args), @@ -146,13 +253,24 @@ export function createOpenScreenMcpServer( { name: MCP_SERVER_NAME, version: deps.version }, { instructions: MCP_PREAMBLE + buildSystemPrompt({ editsAllowed: deps.editsAllowed() }) }, ); + server.registerTool( + LIST_PROJECTS_TOOL, + { + description: LIST_PROJECTS_DESCRIPTION, + inputSchema: z.object({}), + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + () => runTool(LIST_PROJECTS_TOOL, {}), + ); for (const [name, schema] of TOOL_ARG_SCHEMAS) { const mutating = isMutatingTool(name); server.registerTool( name, { description: TOOL_DESCRIPTIONS[name], - inputSchema: schema, + inputSchema: schema.extend({ + projectId: z.string().optional().describe(PROJECT_ID_DESCRIPTION), + }), annotations: { readOnlyHint: !mutating, destructiveHint: DESTRUCTIVE_TOOLS.has(name), diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 0de8bfa88..ccff61b7c 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "خادم MCP", - "description": "اسمح لعملاء MCP الذين تشغّلهم بنفسك، مثل Claude Code أو Codex، باستخدام أدوات التحرير بالذكاء الاصطناعي على المشروع المفتوح في المحرر. يسجّلون الدخول بحساباتهم الخاصة، ولا يطّلع OpenScreen عليها أبدًا.", + "description": "اسمح لعملاء MCP الذين تشغّلهم بنفسك، مثل Claude Code أو Codex، باستخدام أدوات التحرير بالذكاء الاصطناعي على أي من مشاريعك، لا على المشروع المفتوح في المحرر فقط. يسجّلون الدخول بحساباتهم الخاصة، ولا يطّلع OpenScreen عليها أبدًا.", "enableLabel": "الخادم", "enable": "تفعيل خادم MCP", "editsLabel": "تعديلات المشروع", - "allowEdits": "السماح لعملاء MCP بتعديل المشروع", - "editsHint": "متوقف افتراضيًا: يمكن للعملاء قراءة المشروع، ويُرفض كل تعديل يحاولونه. عند تفعيله، يُحفظ كل تعديل فور تطبيقه ويمكن التراجع عنه.", + "allowEdits": "السماح لعملاء MCP بتعديل مشاريعك", + "editsHint": "معطّل افتراضيًا: يمكن للعملاء قراءة مشاريعك، وتُرفض كل محاولة تعديل. عند التفعيل، يُحفظ كل تعديل فور وصوله، ويمكن التراجع عن تعديل المشروع المفتوح في المحرر من هناك.", "statusRunning": "يعمل", "statusOff": "متوقف", "statusError": "لا يعمل", diff --git a/src/i18n/locales/cs/editor.json b/src/i18n/locales/cs/editor.json index 78ed90f4f..bb63660ba 100644 --- a/src/i18n/locales/cs/editor.json +++ b/src/i18n/locales/cs/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP server", - "description": "Umožněte MCP klientům, které sami spouštíte, například Claude Code nebo Codex, používat nástroje pro úpravy s AI na projektu otevřeném v editoru. Přihlašují se vlastními účty; OpenScreen je nikdy nevidí.", + "description": "Umožněte MCP klientům, které sami spouštíte, například Claude Code nebo Codex, používat nástroje pro úpravy s AI na kterémkoli z vašich projektů, nejen na tom otevřeném v editoru. Přihlašují se vlastními účty; OpenScreen je nikdy nevidí.", "enableLabel": "Server", "enable": "Zapnout MCP server", "editsLabel": "Úpravy projektu", - "allowEdits": "Povolit MCP klientům úpravy projektu", - "editsHint": "Ve výchozím stavu vypnuto: klienti mohou projekt číst a každá úprava, o kterou se pokusí, je odmítnuta. Po zapnutí se každá úprava hned uloží a lze ji vrátit zpět.", + "allowEdits": "Povolit MCP klientům úpravy vašich projektů", + "editsHint": "Ve výchozím stavu vypnuto: klienti mohou vaše projekty číst a každá úprava, o kterou se pokusí, je odmítnuta. Po zapnutí se každá úprava hned uloží; úpravu projektu otevřeného v editoru lze tam vrátit zpět.", "statusRunning": "Běží", "statusOff": "Vypnuto", "statusError": "Neběží", diff --git a/src/i18n/locales/de/editor.json b/src/i18n/locales/de/editor.json index c5bc7453f..de02ba480 100644 --- a/src/i18n/locales/de/editor.json +++ b/src/i18n/locales/de/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP-Server", - "description": "Erlaube MCP-Clients, die du selbst ausführst, etwa Claude Code oder Codex, die KI-Bearbeitungswerkzeuge für das im Editor geöffnete Projekt zu nutzen. Sie melden sich mit ihren eigenen Konten an; OpenScreen sieht diese nie.", + "description": "Erlaube MCP-Clients, die du selbst ausführst, etwa Claude Code oder Codex, die KI-Bearbeitungswerkzeuge für jedes deiner Projekte zu nutzen, nicht nur für das im Editor geöffnete. Sie melden sich mit ihren eigenen Konten an; OpenScreen sieht diese nie.", "enableLabel": "Server", "enable": "MCP-Server aktivieren", "editsLabel": "Projektänderungen", - "allowEdits": "MCP-Clients erlauben, das Projekt zu bearbeiten", - "editsHint": "Standardmäßig aus: Clients können das Projekt lesen, jede versuchte Änderung wird abgelehnt. Wenn aktiviert, wird jede Änderung sofort gespeichert und lässt sich rückgängig machen.", + "allowEdits": "MCP-Clients erlauben, deine Projekte zu bearbeiten", + "editsHint": "Standardmäßig aus: Clients können deine Projekte lesen, und jede Bearbeitung wird abgelehnt. Wenn aktiviert, wird jede Bearbeitung sofort gespeichert; eine Bearbeitung des im Editor geöffneten Projekts lässt sich dort rückgängig machen.", "statusRunning": "Läuft", "statusOff": "Aus", "statusError": "Läuft nicht", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 8a2073355..125993aa6 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP server", - "description": "Let MCP clients you run yourself, such as Claude Code or Codex, use the AI editing tools on the project open in the editor. They sign in with their own accounts; OpenScreen never sees them.", + "description": "Let MCP clients you run yourself, such as Claude Code or Codex, use the AI editing tools on any of your projects, not only the one open in the editor. They sign in with their own accounts; OpenScreen never sees them.", "enableLabel": "Server", "enable": "Enable MCP server", "editsLabel": "Project edits", - "allowEdits": "Allow MCP clients to edit the project", - "editsHint": "Off by default: clients can read the project, and every edit they try is refused. When on, each edit is saved as it lands and can be undone.", + "allowEdits": "Allow MCP clients to edit your projects", + "editsHint": "Off by default: clients can read your projects, and every edit they try is refused. When on, each edit is saved as it lands; an edit to the project open in the editor can be undone there.", "statusRunning": "Running", "statusOff": "Off", "statusError": "Not running", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 5640049e7..dc9004105 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "Servidor MCP", - "description": "Permite que los clientes MCP que ejecutas tú mismo, como Claude Code o Codex, usen las herramientas de edición con IA en el proyecto abierto en el editor. Inician sesión con sus propias cuentas; OpenScreen nunca las ve.", + "description": "Permite que los clientes MCP que ejecutas tú mismo, como Claude Code o Codex, usen las herramientas de edición con IA en cualquiera de tus proyectos, no solo en el abierto en el editor. Inician sesión con sus propias cuentas; OpenScreen nunca las ve.", "enableLabel": "Servidor", "enable": "Activar servidor MCP", "editsLabel": "Ediciones del proyecto", - "allowEdits": "Permitir que los clientes MCP editen el proyecto", - "editsHint": "Desactivado por defecto: los clientes pueden leer el proyecto y se rechaza cualquier edición que intenten. Si se activa, cada edición se guarda al aplicarse y se puede deshacer.", + "allowEdits": "Permitir que los clientes MCP editen tus proyectos", + "editsHint": "Desactivado por defecto: los clientes pueden leer tus proyectos y se rechaza cada edición que intenten. Si se activa, cada edición se guarda al llegar; una edición del proyecto abierto en el editor se puede deshacer allí.", "statusRunning": "En ejecución", "statusOff": "Desactivado", "statusError": "Sin ejecutar", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index e7e0c5bf2..c51387915 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "Serveur MCP", - "description": "Permettez aux clients MCP que vous lancez vous-même, comme Claude Code ou Codex, d’utiliser les outils d’édition IA sur le projet ouvert dans l’éditeur. Ils se connectent avec leurs propres comptes ; OpenScreen ne les voit jamais.", + "description": "Permettez aux clients MCP que vous lancez vous-même, comme Claude Code ou Codex, d’utiliser les outils d’édition IA sur n’importe lequel de vos projets, pas seulement celui ouvert dans l’éditeur. Ils se connectent avec leurs propres comptes ; OpenScreen ne les voit jamais.", "enableLabel": "Serveur", "enable": "Activer le serveur MCP", "editsLabel": "Modifications du projet", - "allowEdits": "Autoriser les clients MCP à modifier le projet", - "editsHint": "Désactivé par défaut : les clients peuvent lire le projet, et toute modification qu’ils tentent est refusée. Une fois activé, chaque modification est enregistrée dès qu’elle est appliquée et peut être annulée.", + "allowEdits": "Autoriser les clients MCP à modifier vos projets", + "editsHint": "Désactivé par défaut : les clients peuvent lire vos projets, et chaque modification tentée est refusée. Une fois activé, chaque modification est enregistrée dès son arrivée ; une modification du projet ouvert dans l’éditeur peut y être annulée.", "statusRunning": "En cours", "statusOff": "Désactivé", "statusError": "Arrêté", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index cc2116ea3..6a7102052 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "Server MCP", - "description": "Consenti ai client MCP che esegui tu stesso, come Claude Code o Codex, di usare gli strumenti di modifica con IA sul progetto aperto nell’editor. Accedono con i propri account; OpenScreen non li vede mai.", + "description": "Consenti ai client MCP che esegui tu stesso, come Claude Code o Codex, di usare gli strumenti di modifica con IA su qualsiasi tuo progetto, non solo su quello aperto nell’editor. Accedono con i propri account; OpenScreen non li vede mai.", "enableLabel": "Server", "enable": "Attiva server MCP", "editsLabel": "Modifiche al progetto", - "allowEdits": "Consenti ai client MCP di modificare il progetto", - "editsHint": "Disattivato per impostazione predefinita: i client possono leggere il progetto e ogni modifica che tentano viene rifiutata. Se attivato, ogni modifica viene salvata subito e può essere annullata.", + "allowEdits": "Consenti ai client MCP di modificare i tuoi progetti", + "editsHint": "Disattivato per impostazione predefinita: i client possono leggere i tuoi progetti e ogni modifica tentata viene rifiutata. Se attivo, ogni modifica viene salvata appena arriva; una modifica al progetto aperto nell’editor si può annullare lì.", "statusRunning": "In esecuzione", "statusOff": "Disattivato", "statusError": "Non in esecuzione", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 2313587b6..aa08f93b9 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP サーバー", - "description": "Claude Code や Codex など、ご自身で実行する MCP クライアントが、エディターで開いているプロジェクトに AI 編集ツールを使えるようにします。クライアントは各自のアカウントでサインインし、OpenScreen がそれを見ることはありません。", + "description": "Claude Code や Codex など、ご自身で実行する MCP クライアントが、エディターで開いているプロジェクトだけでなく、すべてのプロジェクトに AI 編集ツールを使えるようにします。クライアントは各自のアカウントでサインインし、OpenScreen がそれを見ることはありません。", "enableLabel": "サーバー", "enable": "MCP サーバーを有効にする", "editsLabel": "プロジェクトの編集", "allowEdits": "MCP クライアントによるプロジェクトの編集を許可", - "editsHint": "既定ではオフです。クライアントはプロジェクトを読み取れますが、編集はすべて拒否されます。オンにすると、各編集は反映と同時に保存され、元に戻せます。", + "editsHint": "既定ではオフです。クライアントはプロジェクトを読み取れますが、編集はすべて拒否されます。オンにすると、各編集は届いた時点で保存されます。エディターで開いているプロジェクトへの編集は、エディターで元に戻せます。", "statusRunning": "実行中", "statusOff": "オフ", "statusError": "停止中", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index b75059aa3..f870af5e2 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP 서버", - "description": "Claude Code나 Codex처럼 직접 실행하는 MCP 클라이언트가 편집기에 열린 프로젝트에서 AI 편집 도구를 사용할 수 있게 합니다. 클라이언트는 자체 계정으로 로그인하며, OpenScreen은 이를 절대 보지 않습니다.", + "description": "Claude Code나 Codex처럼 직접 실행하는 MCP 클라이언트가 편집기에 열린 프로젝트뿐 아니라 모든 프로젝트에서 AI 편집 도구를 사용할 수 있게 합니다. 클라이언트는 자체 계정으로 로그인하며, OpenScreen은 이를 절대 보지 않습니다.", "enableLabel": "서버", "enable": "MCP 서버 사용", "editsLabel": "프로젝트 편집", "allowEdits": "MCP 클라이언트의 프로젝트 편집 허용", - "editsHint": "기본값은 꺼짐입니다. 클라이언트는 프로젝트를 읽을 수 있지만 모든 편집 시도는 거부됩니다. 켜면 각 편집이 적용되는 즉시 저장되며 실행 취소할 수 있습니다.", + "editsHint": "기본값은 꺼짐입니다. 클라이언트는 프로젝트를 읽을 수 있지만 모든 편집 시도는 거부됩니다. 켜면 각 편집이 도착하는 즉시 저장되며, 편집기에 열린 프로젝트의 편집은 편집기에서 실행 취소할 수 있습니다.", "statusRunning": "실행 중", "statusOff": "꺼짐", "statusError": "실행되지 않음", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 003ea563a..168305776 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "Servidor MCP", - "description": "Permita que clientes MCP que você mesmo executa, como Claude Code ou Codex, usem as ferramentas de edição com IA no projeto aberto no editor. Eles entram com as próprias contas; o OpenScreen nunca as vê.", + "description": "Permita que clientes MCP que você mesmo executa, como Claude Code ou Codex, usem as ferramentas de edição com IA em qualquer um dos seus projetos, não só no aberto no editor. Eles entram com as próprias contas; o OpenScreen nunca as vê.", "enableLabel": "Servidor", "enable": "Ativar servidor MCP", "editsLabel": "Edições do projeto", - "allowEdits": "Permitir que clientes MCP editem o projeto", - "editsHint": "Desligado por padrão: os clientes podem ler o projeto, e toda edição que tentarem é recusada. Quando ligado, cada edição é salva assim que aplicada e pode ser desfeita.", + "allowEdits": "Permitir que clientes MCP editem seus projetos", + "editsHint": "Desativado por padrão: os clientes podem ler seus projetos, e toda edição que tentarem é recusada. Quando ativado, cada edição é salva assim que chega; uma edição do projeto aberto no editor pode ser desfeita lá.", "statusRunning": "Em execução", "statusOff": "Desligado", "statusError": "Parado", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 096daf5e7..fb0045362 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP-сервер", - "description": "Разрешите MCP-клиентам, которые вы запускаете сами, например Claude Code или Codex, использовать инструменты ИИ-редактирования для проекта, открытого в редакторе. Они входят со своими учётными записями; OpenScreen их никогда не видит.", + "description": "Разрешите MCP-клиентам, которые вы запускаете сами, например Claude Code или Codex, использовать инструменты ИИ-редактирования для любого вашего проекта, а не только для открытого в редакторе. Они входят со своими учётными записями; OpenScreen их никогда не видит.", "enableLabel": "Сервер", "enable": "Включить MCP-сервер", "editsLabel": "Правки проекта", - "allowEdits": "Разрешить MCP-клиентам редактировать проект", - "editsHint": "По умолчанию выключено: клиенты могут читать проект, а любая их попытка правки отклоняется. Если включить, каждая правка сохраняется сразу и её можно отменить.", + "allowEdits": "Разрешить MCP-клиентам редактировать ваши проекты", + "editsHint": "По умолчанию выключено: клиенты могут читать ваши проекты, а любая попытка правки отклоняется. Если включено, каждая правка сохраняется сразу; правку проекта, открытого в редакторе, можно отменить там.", "statusRunning": "Работает", "statusOff": "Выключен", "statusError": "Не работает", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index f4a76714e..76784915e 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP sunucusu", - "description": "Claude Code veya Codex gibi kendi çalıştırdığınız MCP istemcilerinin, düzenleyicide açık olan projede yapay zekâ düzenleme araçlarını kullanmasına izin verin. Kendi hesaplarıyla oturum açarlar; OpenScreen bunları hiçbir zaman görmez.", + "description": "Claude Code veya Codex gibi kendi çalıştırdığınız MCP istemcilerinin, yalnızca düzenleyicide açık olan projede değil, projelerinizin herhangi birinde yapay zekâ düzenleme araçlarını kullanmasına izin verin. Kendi hesaplarıyla oturum açarlar; OpenScreen bunları hiçbir zaman görmez.", "enableLabel": "Sunucu", "enable": "MCP sunucusunu etkinleştir", "editsLabel": "Proje düzenlemeleri", - "allowEdits": "MCP istemcilerinin projeyi düzenlemesine izin ver", - "editsHint": "Varsayılan olarak kapalı: istemciler projeyi okuyabilir, denedikleri her düzenleme reddedilir. Açıkken her düzenleme uygulandığı anda kaydedilir ve geri alınabilir.", + "allowEdits": "MCP istemcilerinin projelerinizi düzenlemesine izin ver", + "editsHint": "Varsayılan olarak kapalı: istemciler projelerinizi okuyabilir ve denedikleri her düzenleme reddedilir. Açıkken her düzenleme geldiği anda kaydedilir; düzenleyicide açık olan projedeki bir düzenleme orada geri alınabilir.", "statusRunning": "Çalışıyor", "statusOff": "Kapalı", "statusError": "Çalışmıyor", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 1ab9da48c..e8d271e0f 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "Máy chủ MCP", - "description": "Cho phép các ứng dụng khách MCP do bạn tự chạy, như Claude Code hoặc Codex, dùng công cụ chỉnh sửa bằng AI trên dự án đang mở trong trình chỉnh sửa. Chúng đăng nhập bằng tài khoản riêng; OpenScreen không bao giờ thấy các tài khoản đó.", + "description": "Cho phép các ứng dụng khách MCP do bạn tự chạy, như Claude Code hoặc Codex, dùng công cụ chỉnh sửa bằng AI trên bất kỳ dự án nào của bạn, không chỉ dự án đang mở trong trình chỉnh sửa. Chúng đăng nhập bằng tài khoản riêng; OpenScreen không bao giờ thấy các tài khoản đó.", "enableLabel": "Máy chủ", "enable": "Bật máy chủ MCP", "editsLabel": "Chỉnh sửa dự án", - "allowEdits": "Cho phép ứng dụng khách MCP chỉnh sửa dự án", - "editsHint": "Mặc định tắt: ứng dụng khách có thể đọc dự án, và mọi chỉnh sửa chúng thử đều bị từ chối. Khi bật, mỗi chỉnh sửa được lưu ngay khi áp dụng và có thể hoàn tác.", + "allowEdits": "Cho phép ứng dụng khách MCP chỉnh sửa các dự án của bạn", + "editsHint": "Tắt theo mặc định: ứng dụng khách có thể đọc các dự án của bạn và mọi thao tác chỉnh sửa đều bị từ chối. Khi bật, mỗi chỉnh sửa được lưu ngay khi đến; chỉnh sửa trên dự án đang mở trong trình chỉnh sửa có thể được hoàn tác tại đó.", "statusRunning": "Đang chạy", "statusOff": "Tắt", "statusError": "Không chạy", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 98a12b084..e8cc3c529 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP 服务器", - "description": "让你自己运行的 MCP 客户端(如 Claude Code 或 Codex)在编辑器中打开的项目上使用 AI 编辑工具。它们使用各自的账号登录,OpenScreen 永远不会看到这些账号。", + "description": "让你自己运行的 MCP 客户端(如 Claude Code 或 Codex)在你的任意项目上使用 AI 编辑工具,而不只是编辑器中打开的项目。它们使用各自的账号登录,OpenScreen 永远不会看到这些账号。", "enableLabel": "服务器", "enable": "启用 MCP 服务器", "editsLabel": "项目编辑", - "allowEdits": "允许 MCP 客户端编辑项目", - "editsHint": "默认关闭:客户端可以读取项目,但它们尝试的每次编辑都会被拒绝。开启后,每次编辑在生效时即保存,并且可以撤销。", + "allowEdits": "允许 MCP 客户端编辑你的项目", + "editsHint": "默认关闭:客户端可以读取你的项目,但所有编辑都会被拒绝。开启后,每次编辑都会立即保存;对编辑器中打开的项目所做的编辑可以在编辑器中撤销。", "statusRunning": "运行中", "statusOff": "已关闭", "statusError": "未运行", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 8862c279a..c7f323417 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -405,12 +405,12 @@ }, "mcpServer": { "title": "MCP 伺服器", - "description": "讓你自行執行的 MCP 用戶端(例如 Claude Code 或 Codex)在編輯器中開啟的專案上使用 AI 編輯工具。它們使用各自的帳號登入,OpenScreen 永遠不會看到這些帳號。", + "description": "讓你自行執行的 MCP 用戶端(例如 Claude Code 或 Codex)在你的任何專案上使用 AI 編輯工具,而不只是編輯器中開啟的專案。它們使用各自的帳號登入,OpenScreen 永遠不會看到這些帳號。", "enableLabel": "伺服器", "enable": "啟用 MCP 伺服器", "editsLabel": "專案編輯", - "allowEdits": "允許 MCP 用戶端編輯專案", - "editsHint": "預設關閉:用戶端可以讀取專案,但它們嘗試的每次編輯都會被拒絕。開啟後,每次編輯在生效時即儲存,並且可以復原。", + "allowEdits": "允許 MCP 用戶端編輯你的專案", + "editsHint": "預設關閉:用戶端可以讀取你的專案,但所有編輯都會被拒絕。開啟後,每次編輯都會立即儲存;對編輯器中開啟的專案所做的編輯可以在編輯器中復原。", "statusRunning": "執行中", "statusOff": "已關閉", "statusError": "未執行", diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 4d3ca2155..5fb1f6efb 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -1,10 +1,10 @@ # MCP server -OpenScreen can offer the in-app agent's tools to MCP clients the user runs themselves — Claude Code, Codex, Cursor, anything that speaks MCP over Streamable HTTP. The client brings its own model and its own sign-in; OpenScreen never sees, stores or relays those credentials. It is off by default and turned on in **Settings → AI → MCP server**. +OpenScreen can offer the in-app agent's tools to MCP clients the user runs themselves — Claude Code, Codex, Cursor, anything that speaks MCP over Streamable HTTP — on any of the user's projects, not only the one open in the editor. The client brings its own model and its own sign-in; OpenScreen never sees, stores or relays those credentials. It is off by default and turned on in **Settings → AI → MCP server**. | File | Role | |---|---| -| [`electron/mcp/openscreen-mcp-server.ts`](../../electron/mcp/openscreen-mcp-server.ts) | The MCP server and its local HTTP guard. Registers the tools, runs each call. | +| [`electron/mcp/openscreen-mcp-server.ts`](../../electron/mcp/openscreen-mcp-server.ts) | The MCP server and its local HTTP guard. Registers the tools, routes each call to the editor or a project file, runs it. | | [`electron/mcp/editor-document-host.ts`](../../electron/mcp/editor-document-host.ts) | Main-process side of the live document: asks the editor window for a snapshot and hands edits back. | | [`electron/mcp/mcp-controller.ts`](../../electron/mcp/mcp-controller.ts) | Lifecycle: start when enabled, restart on a port or token change, status for the settings UI. | | [`electron/mcp/mcp-settings-store.ts`](../../electron/mcp/mcp-settings-store.ts) | `mcp-server.json` (enabled, port, allowEdits) and `mcp-token.enc` (bearer token, `safeStorage`). | @@ -15,12 +15,28 @@ 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 `listProjects` followed by `OPENSCREEN_TOOL_NAMES`. What the server adds: + +- **`listProjects`**, the one tool of its own: every project's id, title, `updatedAt`, asset count, and `open` for the one in the editor. +- **An optional `projectId` on every agent tool**, added to its schema with `.extend()` at registration and stripped again before the call reaches the executor, which never sees it. See [Which project a call acts on](#which-project-a-call-acts-on). +- MCP metadata: `readOnlyHint` for the reads (`!isMutatingTool`) and `listProjects`, and `destructiveHint` for `replaceTimeline` and the three `remove*` tools. **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. +## Which project a call acts on + +| `projectId` | Path | +|---|---| +| omitted | The project open in the editor, through the editor. With none open the call fails with "No project is open" and points at `listProjects`. | +| the open project's id | The same: through the editor, never its file. | +| any other id | That project's file, through the app's one `DocumentService` (`getProject` → tool → `saveProject`). Works with no editor window at all. | + +The open project is never written to disk from here because the editor holds it in memory and saves it as a whole: a file edit under it would be overwritten by the editor's next save, and the editor would never show it. + ## Where the document comes from +### The open project + The in-app agent is handed a document snapshot per chat turn. An MCP client has no turn, so each call reads the **live** document from the editor window, which owns it (`useProjectStore`, with its `revision`): 1. `EditorDocumentHost.snapshot()` sends `{op: "snapshot"}` on `ai-edition.mcp-request` to the editor; it answers `{document, revision}` (or `null` with no project open). @@ -31,6 +47,15 @@ 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). +### Any other project + +Read with `DocumentService.getProject` (migrated and relinked, like any open) and, if the tool changed it, saved with `saveProject` — the same instance every other save in the app goes through, so its per-project write queue still holds. There is no revision to guard with, so right before the save two checks run, and either one refuses the edit with "NOT applied … re-read, then retry": + +1. **The editor has not opened it meanwhile.** The editor is asked for a snapshot again; if its project is now this one, the edit is left to it. The client's retry, with the same `projectId`, then goes through the editor. +2. **Nothing else saved it meanwhile.** The file is re-read and its `project.updatedAt` must still be the one the tool ran against. + +An edit made this way is saved, but it is not on any undo stack: the editor never held it. It is in the project the next time it is opened. + ## 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. @@ -56,4 +81,6 @@ codex mcp add openscreen --url http://127.0.0.1:47821/mcp --bearer-token-env-var ## Known gaps - **Only what the agent can do.** The server exposes the agent's timeline tools. Recording, export, import and project management are not tools, for MCP or for the in-app agent. -- **An open editor is required.** With no editor window, or no project loaded, every tool answers "No project is open". +- **The file guard has a window.** The two checks run immediately before `saveProject`, not atomically with it: the editor opening the project in the few milliseconds between them and the write would load the old file, and its next save would drop the MCP edit. Closing it would need a lock the editor takes on open. +- **No undo for edits to closed projects.** See above. +- **No project management.** Projects can be listed, read and edited, not created, renamed or deleted. From 1e61fbcf51985a02e21bab6aa244174902a8531d Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Tue, 6 Oct 2026 23:54:01 +0530 Subject: [PATCH 2/2] fix(mcp): compare the whole project file before saving an MCP edit to it The guard against overwriting a closed project compared only `project.updatedAt`. Two saves inside one millisecond share that stamp, and a writer outside the app (a sync tool, a restored copy) may not change it, so an MCP edit could still land on top of either. Compare the re-read document in full instead. Raised by CodeRabbit on #1040. Co-Authored-By: Claude Opus 5.5 --- electron/mcp/openscreen-mcp-server.test.ts | 27 ++++++++++++++++++- electron/mcp/openscreen-mcp-server.ts | 5 +++- .../architecture/mcp-server.md | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts index 9e4aeb529..30232cd93 100644 --- a/electron/mcp/openscreen-mcp-server.test.ts +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -2,7 +2,7 @@ // 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 } from "node:fs"; +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"; @@ -365,6 +365,31 @@ describe("projects other than the open one", () => { 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", () => { diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts index e230228c3..4f0f7aff0 100644 --- a/electron/mcp/openscreen-mcp-server.ts +++ b/electron/mcp/openscreen-mcp-server.ts @@ -205,8 +205,11 @@ export function createToolRunner(deps: McpToolDeps) { if (openProjectId(await deps.host.snapshot()) === projectId) { return textResult(PROJECT_CHANGED_MESSAGE, true); } + // The whole document, not just `updatedAt`: two saves inside one + // millisecond share a stamp, and a writer outside the app (a sync tool, + // a restored copy) may not touch it at all. const onDisk = await deps.projects.getProject(projectId); - if (onDisk.project.updatedAt !== document.project.updatedAt) { + if (JSON.stringify(onDisk) !== JSON.stringify(document)) { return textResult(PROJECT_CHANGED_MESSAGE, true); } await deps.projects.saveProject(execution.document); diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md index 5fb1f6efb..8c1ee6c2e 100644 --- a/technical-documentation/architecture/mcp-server.md +++ b/technical-documentation/architecture/mcp-server.md @@ -52,7 +52,7 @@ Only the webContents that registered on `ai-edition.mcp-host` is asked, and only Read with `DocumentService.getProject` (migrated and relinked, like any open) and, if the tool changed it, saved with `saveProject` — the same instance every other save in the app goes through, so its per-project write queue still holds. There is no revision to guard with, so right before the save two checks run, and either one refuses the edit with "NOT applied … re-read, then retry": 1. **The editor has not opened it meanwhile.** The editor is asked for a snapshot again; if its project is now this one, the edit is left to it. The client's retry, with the same `projectId`, then goes through the editor. -2. **Nothing else saved it meanwhile.** The file is re-read and its `project.updatedAt` must still be the one the tool ran against. +2. **Nothing else changed it meanwhile.** The file is re-read and must still be, in full, the document the tool ran against. Comparing `project.updatedAt` alone is not enough: two saves inside one millisecond share a stamp, and a writer outside the app (a sync tool, a restored copy) may not touch it. An edit made this way is saved, but it is not on any undo stack: the editor never held it. It is in the project the next time it is opened.