From 40d653b9ca78f29a514e97caf81607e455e7a674 Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Tue, 29 Sep 2026 17:08:21 +0530 Subject: [PATCH 1/4] feat: expose the AI agent's tools over a local MCP server MCP clients the user runs themselves (Claude Code, Codex, Cursor...) can now drive every tool the in-app agent has, against the project open in the editor, signed in with their own accounts. OpenScreen never sees those credentials, which is the supported way to use a Claude or ChatGPT subscription here. - electron/mcp/: Streamable HTTP server on 127.0.0.1 with a bearer token (safeStorage) and a Host/Origin check against DNS rebinding. Off by default; started from main.ts only, never by the headless CLI. - Same tool surface as the agent: TOOL_ARG_SCHEMAS, TOOL_DESCRIPTIONS and the system prompt are shared, and every call runs through runDocumentTool, now extracted from documentTool so both agents execute identically. The "Project edits" switch applies unchanged. - Each call reads the live document from the editor window and applies the result through applyAgentDocumentIfCurrent, so edits are saved, are one undo step, and never overwrite a change made mid-call (revision + project id). - Settings -> AI -> MCP server: toggle, port, token, and copyable `claude mcp add` / `codex mcp add` commands. Strings in all 15 locales. Verified live against the built app: Claude Code and Codex each listed the 25 tools and read the open project; an MCP addTrim landed in the editor, was saved to disk, and Ctrl+Z reverted it. Co-Authored-By: Claude Opus 5.5 --- electron/ai-edition/deep-agent/service.ts | 110 ++- electron/electron-env.d.ts | 6 + electron/ipc/handlers.ts | 23 + electron/ipc/nativeBridge.ts | 17 + electron/main.ts | 5 +- electron/mcp/editor-document-host.test.ts | 114 +++ electron/mcp/editor-document-host.ts | 102 ++ electron/mcp/mcp-controller.test.ts | 140 +++ electron/mcp/mcp-controller.ts | 112 +++ electron/mcp/mcp-settings-store.ts | 102 ++ electron/mcp/openscreen-mcp-server.test.ts | 252 +++++ electron/mcp/openscreen-mcp-server.ts | 255 +++++ .../services/aiEditionService.ts | 25 + electron/preload.ts | 41 +- package-lock.json | 932 +++++++++++++++++- package.json | 1 + .../ai-edition/McpServerSettings.test.tsx | 87 ++ .../ai-edition/McpServerSettings.tsx | 255 +++++ src/components/ai-edition/NewEditorShell.tsx | 2 + .../ai-edition/ProviderSettings.test.tsx | 10 + .../ai-edition/ProviderSettings.tsx | 14 +- src/i18n/locales/ar/editor.json | 24 + src/i18n/locales/cs/editor.json | 24 + src/i18n/locales/de/editor.json | 24 + src/i18n/locales/en/editor.json | 24 + src/i18n/locales/es/editor.json | 24 + src/i18n/locales/fr/editor.json | 24 + src/i18n/locales/it/editor.json | 24 + src/i18n/locales/ja-JP/editor.json | 24 + src/i18n/locales/ko-KR/editor.json | 24 + src/i18n/locales/pt-BR/editor.json | 24 + src/i18n/locales/ru/editor.json | 24 + src/i18n/locales/tr/editor.json | 24 + src/i18n/locales/vi/editor.json | 24 + src/i18n/locales/zh-CN/editor.json | 24 + src/i18n/locales/zh-TW/editor.json | 24 + .../ai-edition/store/mcpDocumentHost.test.ts | 77 ++ src/lib/ai-edition/store/mcpDocumentHost.ts | 35 + src/native/browserShim.ts | 25 + src/native/client.ts | 23 + src/native/contracts.ts | 62 ++ technical-documentation/README.md | 1 + .../architecture/llm-providers.md | 4 + .../architecture/mcp-server.md | 60 ++ website/docs/ai-editing.md | 10 + 45 files changed, 3181 insertions(+), 81 deletions(-) create mode 100644 electron/mcp/editor-document-host.test.ts create mode 100644 electron/mcp/editor-document-host.ts create mode 100644 electron/mcp/mcp-controller.test.ts create mode 100644 electron/mcp/mcp-controller.ts create mode 100644 electron/mcp/mcp-settings-store.ts create mode 100644 electron/mcp/openscreen-mcp-server.test.ts create mode 100644 electron/mcp/openscreen-mcp-server.ts create mode 100644 src/components/ai-edition/McpServerSettings.test.tsx create mode 100644 src/components/ai-edition/McpServerSettings.tsx create mode 100644 src/lib/ai-edition/store/mcpDocumentHost.test.ts create mode 100644 src/lib/ai-edition/store/mcpDocumentHost.ts create mode 100644 technical-documentation/architecture/mcp-server.md diff --git a/electron/ai-edition/deep-agent/service.ts b/electron/ai-edition/deep-agent/service.ts index 64bcef03b..21f5b79a5 100644 --- a/electron/ai-edition/deep-agent/service.ts +++ b/electron/ai-edition/deep-agent/service.ts @@ -26,6 +26,7 @@ import type { AxcutDocument } from "../../../src/lib/ai-edition/schema"; // wrong at both ends of a table that actually runs 1.25× to 5.0×. import { ZOOM_DEPTH_LEGEND } from "../../../src/lib/ai-edition/timeline/zoom-scale"; import { + type AgentToolExecution, addAnnotationArgs, addAudioArgs, addCameraFullscreenArgs, @@ -218,7 +219,7 @@ export interface CursorTelemetryReader { probe?(input: { assetId: string; originalPath: string | null }): Promise; } -interface ToolRuntime { +export interface ToolRuntime { cursor?: CursorTelemetryReader; availableByAssetId?: Record; } @@ -268,18 +269,7 @@ function documentTool( return tool( async (args: z.infer) => { sink.toolStart(name, args); - // ponytail: the ONE async step the pure executor cannot take. Reading a - // sidecar is IO; `executeAgentTool` is synchronous by design (it is the - // gate every mutation passes through, and it has to stay testable - // without a filesystem). So the load happens here and its verdict — - // including "I could not look" — goes in as data. - const load = TOOLS_READING_CURSOR.has(name) - ? await loadCursorTelemetry(holder.current, args, runtime) - : undefined; - const execution = executeAgentTool(holder.current, name, JSON.stringify(args), { - editsAllowed, - cursorTelemetry: { availableByAssetId: runtime.availableByAssetId, load }, - }); + const execution = await runDocumentTool(holder.current, name, args, editsAllowed, runtime); if (execution.document) holder.current = execution.document; sink.toolEnd(name, execution.ok, execution.summary); return execution.resultJson; @@ -288,6 +278,33 @@ function documentTool( ); } +/** + * Run one tool against a document: the cursor read it may need, then the shared + * executor. The in-app agent (`documentTool`) and the MCP server both call this, + * so a tool behaves the same whichever agent is driving it. + * + * ponytail: the ONE async step the pure executor cannot take. Reading a sidecar + * is IO; `executeAgentTool` is synchronous by design (it is the gate every + * mutation passes through, and it has to stay testable without a filesystem). + * So the load happens here and its verdict — including "I could not look" — goes + * in as data. + */ +export async function runDocumentTool( + document: AxcutDocument, + name: string, + args: unknown, + editsAllowed: boolean, + runtime: ToolRuntime, +): Promise { + const load = TOOLS_READING_CURSOR.has(name) + ? await loadCursorTelemetry(document, args, runtime) + : undefined; + return executeAgentTool(document, name, JSON.stringify(args ?? {}), { + editsAllowed, + cursorTelemetry: { availableByAssetId: runtime.availableByAssetId, load }, + }); +} + /** Reads the sidecar for whichever asset the call names, defaulting to the * primary one — the same resolution the executor will use to report it. */ async function loadCursorTelemetry( @@ -331,37 +348,44 @@ export function buildTools( editsAllowed = true, runtime: ToolRuntime = {}, ) { - const build = (name: string, schema: S) => - documentTool(holder, sink, name, schema, editsAllowed, runtime); - return [ - build("getCurrentDocument", z.object({})), - build("getTranscript", getTranscriptArgs), - build("getTranscriptWords", getTranscriptWordsArgs), - build("getCursorTrack", getCursorTrackArgs), - build("setWordText", setWordTextArgs), - build("addTrim", addTrimArgs), - build("addTrims", addTrimsArgs), - build("setTrim", setTrimArgs), - build("setClipRange", setClipRangeArgs), - build("moveClip", moveClipArgs), - build("replaceTimeline", replaceTimelineArgs), - build("addZoom", addZoomArgs), - build("addZooms", addZoomsArgs), - build("setZoom", setZoomArgs), - build("addSpeed", addSpeedArgs), - build("setSpeed", setSpeedArgs), - build("addAnnotation", addAnnotationArgs), - build("setAnnotation", setAnnotationArgs), - build("addCameraFullscreen", addCameraFullscreenArgs), - build("setCameraFullscreen", setCameraFullscreenArgs), - build("addAudio", addAudioArgs), - build("setAudio", setAudioArgs), - build("removeTrim", removeTrimArgs), - build("removeModifier", removeModifierArgs), - build("removeClip", removeClipArgs), - ]; + return TOOL_ARG_SCHEMAS.map(([name, schema]) => + documentTool(holder, sink, name, schema, editsAllowed, runtime), + ); } +/** + * Every tool's name and argument schema, in the order the model is handed them. + * `buildTools` wraps these for the in-app agent and `electron/mcp/` exposes the + * same list over MCP, so the two surfaces cannot drift apart. + */ +export const TOOL_ARG_SCHEMAS: ReadonlyArray = [ + ["getCurrentDocument", z.object({})], + ["getTranscript", getTranscriptArgs], + ["getTranscriptWords", getTranscriptWordsArgs], + ["getCursorTrack", getCursorTrackArgs], + ["setWordText", setWordTextArgs], + ["addTrim", addTrimArgs], + ["addTrims", addTrimsArgs], + ["setTrim", setTrimArgs], + ["setClipRange", setClipRangeArgs], + ["moveClip", moveClipArgs], + ["replaceTimeline", replaceTimelineArgs], + ["addZoom", addZoomArgs], + ["addZooms", addZoomsArgs], + ["setZoom", setZoomArgs], + ["addSpeed", addSpeedArgs], + ["setSpeed", setSpeedArgs], + ["addAnnotation", addAnnotationArgs], + ["setAnnotation", setAnnotationArgs], + ["addCameraFullscreen", addCameraFullscreenArgs], + ["setCameraFullscreen", setCameraFullscreenArgs], + ["addAudio", addAudioArgs], + ["setAudio", setAudioArgs], + ["removeTrim", removeTrimArgs], + ["removeModifier", removeModifierArgs], + ["removeClip", removeClipArgs], +]; + /** * Prompt caching for the Anthropic-wire providers, which `createDeepAgent` * used to add for us (`isAnthropicModel` → `anthropicPromptCachingMiddleware`). @@ -401,7 +425,7 @@ export interface InvokeArgs { /** One cheap probe per asset, run before the tools are built so the very first * `getCurrentDocument` can already say whether telemetry exists. */ -async function probeCursorTelemetry( +export async function probeCursorTelemetry( document: AxcutDocument, cursor: CursorTelemetryReader | undefined, ): Promise | undefined> { diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 669e3ed25..5d0e993c1 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -71,6 +71,12 @@ interface Window { onAiEditionChatEvent: ( callback: (event: import("../src/native/contracts").AiEditionChatEvent) => void, ) => () => void; + /** Optional: absent in the browser shim and in tests that stub electronAPI. */ + onAiEditionMcpRequest?: ( + callback: ( + request: import("../src/native/contracts").AiEditionMcpHostRequest, + ) => Promise, + ) => () => void; requestCameraAccess: () => Promise<{ success: boolean; granted: boolean; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index c3bd11c01..fca1efa99 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -13,6 +13,7 @@ import { desktopCapturer, dialog, ipcMain, + safeStorage, screen, shell, systemPreferences, @@ -69,6 +70,9 @@ import { isDiagnosticModeEnabled, mainLogBuffer } from "../diagnostics/main-log- import { mainT } from "../i18n"; import { getInstallChannel } from "../install-channel"; import { RECORDINGS_DIR } from "../main"; +import { EditorDocumentHost } from "../mcp/editor-document-host"; +import { McpController } from "../mcp/mcp-controller"; +import { McpSettingsStore } from "../mcp/mcp-settings-store"; import { type AudioPeaksResult, getAudioPeaks } from "../media/audioPeaks"; import { readCursorRecordingFile as readCursorRecordingFileFrom, @@ -4836,6 +4840,22 @@ export function registerIpcHandlers( return aiEditionLlmConfigInstance; }; + // 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 + // 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 tools obey the same "Project edits" switch as the + // in-app agent, read on every call. + const mcpController = new McpController( + new McpSettingsStore(app.getPath("userData"), safeStorage), + { + host: new EditorDocumentHost(ipcMain), + editsAllowed: () => getAiEditionLlmConfig().getConfig()?.allowAgentEdits !== false, + cursor: agentCursorTelemetryReader, + version: app.getVersion(), + }, + ); + registerNativeBridgeHandlers({ getPlatform: () => process.platform, getCurrentProjectPath: () => currentProjectPath, @@ -4891,5 +4911,8 @@ export function registerIpcHandlers( renameAiEditionChatSession: (projectId, sessionId, title) => renameSession(projectId, sessionId, title), deleteAiEditionChatSession: (projectId, sessionId) => deleteSession(projectId, sessionId), + getMcpController: () => mcpController, }); + + return { mcpController }; } diff --git a/electron/ipc/nativeBridge.ts b/electron/ipc/nativeBridge.ts index e25ea048b..9ba8c293b 100644 --- a/electron/ipc/nativeBridge.ts +++ b/electron/ipc/nativeBridge.ts @@ -62,6 +62,8 @@ export interface NativeBridgeContext { /** The one shared style preset service — it serialises writes per instance. */ getStylePresets: () => StylePresetService; getAiEditionLlmConfig: () => import("../ai-edition/llm-config-store").LlmConfigStore; + /** The local MCP server's controller. Absent in the headless CLI. */ + getMcpController?: () => import("../mcp/mcp-controller").McpController; runAiEditionChat: ( projectId: string, sessionId: string, @@ -234,6 +236,7 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { // Passed uncalled on purpose — invoking it here would build the store (and // hit the macOS Keychain) while wiring the bridge at startup. llmConfig: context.getAiEditionLlmConfig, + mcp: context.getMcpController?.(), runChat: context.runAiEditionChat, undoLastToolBatch: context.undoAiEditionToolBatch, rewindToMessage: context.rewindToMessage, @@ -573,6 +576,20 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) { requestId, await aiEditionService.llmListProviderModels(request.payload.providerId), ); + case "mcp.getStatus": + return createSuccessResponse(requestId, await aiEditionService.mcpGetStatus()); + case "mcp.setEnabled": + return createSuccessResponse( + requestId, + await aiEditionService.mcpSetEnabled(request.payload.enabled), + ); + case "mcp.setPort": + return createSuccessResponse( + requestId, + await aiEditionService.mcpSetPort(request.payload.port), + ); + case "mcp.regenerateToken": + return createSuccessResponse(requestId, await aiEditionService.mcpRegenerateToken()); case "chat.run": { const sessionId = request.payload.sessionId; const sink = buildChatEventSink(event.sender, sessionId); diff --git a/electron/main.ts b/electron/main.ts index de3e3bbd7..0b2d7725b 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1360,7 +1360,7 @@ appReady?.then(async () => { showMainWindow(); } - registerIpcHandlers( + const { mcpController } = registerIpcHandlers( createEditorWindowWrapper, createSourceSelectorWindowWrapper, createCountdownOverlayWindowWrapper, @@ -1404,6 +1404,9 @@ appReady?.then(async () => { } createWindow(); + // Off unless the user turned it on in Settings → AI. Started here rather than + // in registerIpcHandlers so neither the headless CLI nor a bench run binds it. + void mcpController.startIfEnabled(); void showPermissionsWindowIfNeeded().catch((error) => console.warn("[permissions] could not read the permissions at launch:", error), ); diff --git a/electron/mcp/editor-document-host.test.ts b/electron/mcp/editor-document-host.test.ts new file mode 100644 index 000000000..6071c34d9 --- /dev/null +++ b/electron/mcp/editor-document-host.test.ts @@ -0,0 +1,114 @@ +import { EventEmitter } from "node:events"; +import type { IpcMain, WebContents } from "electron"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createEmptyDocument } from "../../src/lib/ai-edition/schema"; +import { + AI_EDITION_MCP_HOST_CHANNEL, + AI_EDITION_MCP_REQUEST_CHANNEL, + AI_EDITION_MCP_RESPONSE_CHANNEL, + type AiEditionMcpHostRequest, +} from "../../src/native/contracts"; +import { EditorDocumentHost } from "./editor-document-host"; + +/** A webContents that records what main sends it and can be destroyed. */ +class FakeWebContents extends EventEmitter { + sent: AiEditionMcpHostRequest[] = []; + destroyed = false; + send(channel: string, payload: AiEditionMcpHostRequest) { + if (channel === AI_EDITION_MCP_REQUEST_CHANNEL) this.sent.push(payload); + } + isDestroyed() { + return this.destroyed; + } + destroy() { + this.destroyed = true; + this.emit("destroyed"); + } +} + +function setup() { + const ipc = new EventEmitter(); + const host = new EditorDocumentHost(ipc as unknown as IpcMain); + const fromRenderer = (sender: FakeWebContents, channel: string, payload: unknown) => + ipc.emit(channel, { sender: sender as unknown as WebContents }, payload); + return { host, fromRenderer }; +} + +/** Answers the next request the editor receives. */ +async function answer( + editor: FakeWebContents, + fromRenderer: ReturnType["fromRenderer"], + result: unknown, + sender = editor, +) { + await vi.waitFor(() => expect(editor.sent.length).toBeGreaterThan(0)); + const request = editor.sent.shift(); + fromRenderer(sender, AI_EDITION_MCP_RESPONSE_CHANNEL, { requestId: request?.requestId, result }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("EditorDocumentHost", () => { + it("answers null while no editor has registered", async () => { + const { host } = setup(); + await expect(host.snapshot()).resolves.toBeNull(); + }); + + it("asks the registered editor for its snapshot", async () => { + const { host, fromRenderer } = setup(); + const editor = new FakeWebContents(); + fromRenderer(editor, AI_EDITION_MCP_HOST_CHANNEL, true); + const pending = host.snapshot(); + await answer(editor, fromRenderer, { document: { id: "d" }, revision: 3 }); + await expect(pending).resolves.toEqual({ document: { id: "d" }, revision: 3 }); + }); + + it("passes the apply verdict through", async () => { + const { host, fromRenderer } = setup(); + const editor = new FakeWebContents(); + fromRenderer(editor, AI_EDITION_MCP_HOST_CHANNEL, true); + const document = createEmptyDocument({ projectId: "p", title: "t" }); + const pending = host.apply(document, 7); + await vi.waitFor(() => expect(editor.sent).toHaveLength(1)); + expect(editor.sent[0]).toMatchObject({ op: "apply", expectedRevision: 7 }); + await answer(editor, fromRenderer, "conflict"); + await expect(pending).resolves.toBe("conflict"); + }); + + it("ignores a reply from any other window", async () => { + vi.useFakeTimers(); + const { host, fromRenderer } = setup(); + const editor = new FakeWebContents(); + fromRenderer(editor, AI_EDITION_MCP_HOST_CHANNEL, true); + const pending = host.snapshot(); + await answer(editor, fromRenderer, { document: {}, revision: 1 }, new FakeWebContents()); + await vi.advanceTimersByTimeAsync(30_000); + await expect(pending).resolves.toBeNull(); + }); + + it("forgets an editor that unregisters or is destroyed", async () => { + const { host, fromRenderer } = setup(); + const first = new FakeWebContents(); + fromRenderer(first, AI_EDITION_MCP_HOST_CHANNEL, true); + fromRenderer(first, AI_EDITION_MCP_HOST_CHANNEL, false); + await expect(host.snapshot()).resolves.toBeNull(); + + const second = new FakeWebContents(); + fromRenderer(second, AI_EDITION_MCP_HOST_CHANNEL, true); + second.destroy(); + await expect(host.snapshot()).resolves.toBeNull(); + expect(second.sent).toHaveLength(0); + }); + + it("reports an unanswered apply as a timeout, not as a failure", async () => { + vi.useFakeTimers(); + const { host, fromRenderer } = setup(); + const editor = new FakeWebContents(); + fromRenderer(editor, AI_EDITION_MCP_HOST_CHANNEL, true); + const pending = host.apply(createEmptyDocument({ projectId: "p", title: "t" }), 1); + await vi.advanceTimersByTimeAsync(30_000); + await expect(pending).resolves.toBe("timeout"); + }); +}); diff --git a/electron/mcp/editor-document-host.ts b/electron/mcp/editor-document-host.ts new file mode 100644 index 000000000..bce4a2d83 --- /dev/null +++ b/electron/mcp/editor-document-host.ts @@ -0,0 +1,102 @@ +// The MCP server's door onto the live document, which the editor window owns. +// +// The open project lives in the renderer's store (with the revision that guards +// agent writes), not in the main process, so each MCP call asks the editor for +// a snapshot and hands the result back to the editor's own revision-guarded +// apply — the same `applyAgentDocumentIfCurrent` an in-app chat turn uses. +// +// The editor announces itself on AI_EDITION_MCP_HOST_CHANNEL when it starts +// answering, and again with `false` when it stops; a destroyed window counts as +// stopped. Only that one webContents is ever asked, and only its replies count. + +import { randomUUID } from "node:crypto"; +import type { IpcMain, IpcMainEvent, WebContents } from "electron"; +import type { AxcutDocument } from "../../src/lib/ai-edition/schema"; +import { + AI_EDITION_MCP_HOST_CHANNEL, + AI_EDITION_MCP_REQUEST_CHANNEL, + AI_EDITION_MCP_RESPONSE_CHANNEL, + type AiEditionMcpHostRequest, + type AiEditionMcpHostResponse, + type AiEditionMcpHostSnapshot, +} from "../../src/native/contracts"; +import type { McpApplyResult, McpDocumentHost } from "./openscreen-mcp-server"; + +// Long enough for a save of a large project to land, short enough that a hung +// renderer does not hold the client's call open indefinitely. +const REQUEST_TIMEOUT_MS = 30_000; + +const TIMED_OUT = Symbol("timed-out"); + +interface Pending { + resolve: (value: unknown) => void; + timer: ReturnType; +} + +type RequestBody = + | { op: "snapshot" } + | { op: "apply"; document: unknown; expectedRevision: number }; + +export class EditorDocumentHost implements McpDocumentHost { + private editor: WebContents | null = null; + private readonly pending = new Map(); + + constructor(ipcMain: IpcMain) { + ipcMain.on(AI_EDITION_MCP_HOST_CHANNEL, (event: IpcMainEvent, active: unknown) => { + if (active === true) { + this.editor = event.sender; + event.sender.once("destroyed", () => { + if (this.editor === event.sender) this.editor = null; + }); + } else if (this.editor === event.sender) { + this.editor = null; + } + }); + ipcMain.on(AI_EDITION_MCP_RESPONSE_CHANNEL, (event: IpcMainEvent, message: unknown) => { + if (event.sender !== this.editor) return; + const response = message as AiEditionMcpHostResponse | null; + if (!response || typeof response.requestId !== "string") return; + const pending = this.pending.get(response.requestId); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(response.requestId); + pending.resolve(response.result); + }); + } + + private request(body: RequestBody): Promise { + const editor = this.editor; + if (!editor || editor.isDestroyed()) return Promise.resolve(undefined); + const requestId = randomUUID(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.pending.delete(requestId); + resolve(TIMED_OUT); + }, REQUEST_TIMEOUT_MS); + this.pending.set(requestId, { resolve, timer }); + try { + editor.send(AI_EDITION_MCP_REQUEST_CHANNEL, { + requestId, + ...body, + } satisfies AiEditionMcpHostRequest); + } catch { + clearTimeout(timer); + this.pending.delete(requestId); + resolve(undefined); + } + }); + } + + async snapshot(): Promise { + const result = await this.request({ op: "snapshot" }); + if (!result || result === TIMED_OUT || typeof result !== "object") return null; + return result as AiEditionMcpHostSnapshot; + } + + async apply(document: AxcutDocument, expectedRevision: number): Promise { + const result = await this.request({ op: "apply", document, expectedRevision }); + if (result === TIMED_OUT) return "timeout"; + if (typeof result !== "string") return "no-editor"; + return result as McpApplyResult; + } +} diff --git a/electron/mcp/mcp-controller.test.ts b/electron/mcp/mcp-controller.test.ts new file mode 100644 index 000000000..d7d0d426a --- /dev/null +++ b/electron/mcp/mcp-controller.test.ts @@ -0,0 +1,140 @@ +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +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 { McpController } from "./mcp-controller"; +import { DEFAULT_MCP_PORT, McpSettingsStore, type McpTokenCrypto } from "./mcp-settings-store"; +import type { McpToolDeps } from "./openscreen-mcp-server"; + +// Reversible and visibly not plaintext, so a test can tell the token was encrypted. +const fakeCrypto: McpTokenCrypto = { + isEncryptionAvailable: () => true, + encryptString: (plain) => Buffer.from(`enc:${plain}`), + decryptString: (encrypted) => encrypted.toString().replace(/^enc:/, ""), +}; + +const deps: McpToolDeps = { + host: { snapshot: async () => null, apply: async () => "no-editor" }, + editsAllowed: () => true, + version: "0.0.0", +}; + +let dir: string; +let controller: McpController | null = null; +let blocker: Server | null = null; + +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "openscreen-mcp-")); +}); + +afterEach(async () => { + await controller?.stop(); + controller = null; + await new Promise((resolve) => (blocker ? blocker.close(() => resolve()) : resolve())); + blocker = null; + rmSync(dir, { recursive: true, force: true }); +}); + +/** A free port, found by binding one and letting it go. */ +async function freePort(): Promise { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const { port } = server.address() as { port: number }; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function initializeStatus(url: string, token: string) { + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "t", version: "0" }, + }, + }), + }); + return response.status; +} + +describe("McpSettingsStore", () => { + it("is off by default, on the default port", () => { + const store = new McpSettingsStore(dir, fakeCrypto); + expect(store.getSettings()).toEqual({ enabled: false, port: DEFAULT_MCP_PORT }); + }); + + it("keeps the token encrypted on disk and stable across instances", async () => { + const token = await new McpSettingsStore(dir, fakeCrypto).getToken(); + expect(readFileSync(path.join(dir, "mcp-token.enc"), "utf8")).toBe(`enc:${token}`); + expect(await new McpSettingsStore(dir, fakeCrypto).getToken()).toBe(token); + }); + + it("refuses a port outside the unprivileged range", async () => { + const store = new McpSettingsStore(dir, fakeCrypto); + await expect(store.setSettings({ port: 80 })).rejects.toThrow(/1024/); + }); + + it("will not mint a token without OS encryption", async () => { + const store = new McpSettingsStore(dir, { ...fakeCrypto, isEncryptionAvailable: () => false }); + await expect(store.getToken()).rejects.toThrow(/safeStorage/); + }); +}); + +describe("McpController", () => { + it("does not listen until enabled", async () => { + controller = new McpController(new McpSettingsStore(dir, fakeCrypto), deps); + await controller.startIfEnabled(); + const status = await controller.getStatus(); + expect(status.running).toBe(false); + expect(status.token).toBeNull(); + }); + + it("starts on enable and stops on disable", async () => { + const store = new McpSettingsStore(dir, fakeCrypto); + await store.setSettings({ port: await freePort() }); + controller = new McpController(store, deps); + + const on = await controller.setEnabled(true); + expect(on.running).toBe(true); + expect(await initializeStatus(on.url, on.token ?? "")).toBe(200); + + const off = await controller.setEnabled(false); + expect(off.running).toBe(false); + await expect(initializeStatus(on.url, on.token ?? "")).rejects.toThrow(); + }); + + it("explains a port that is already taken", async () => { + const port = await freePort(); + blocker = createServer(); + await new Promise((resolve) => blocker?.listen(port, "127.0.0.1", resolve)); + const store = new McpSettingsStore(dir, fakeCrypto); + await store.setSettings({ port }); + controller = new McpController(store, deps); + + const status = await controller.setEnabled(true); + expect(status.running).toBe(false); + expect(status.error).toContain(String(port)); + }); + + it("locks out the old token once it is regenerated", async () => { + const store = new McpSettingsStore(dir, fakeCrypto); + await store.setSettings({ port: await freePort() }); + controller = new McpController(store, deps); + const before = await controller.setEnabled(true); + const after = await controller.regenerateToken(); + + expect(after.token).not.toBe(before.token); + expect(await initializeStatus(after.url, before.token ?? "")).toBe(401); + expect(await initializeStatus(after.url, after.token ?? "")).toBe(200); + }); +}); diff --git a/electron/mcp/mcp-controller.ts b/electron/mcp/mcp-controller.ts new file mode 100644 index 000000000..5e424553d --- /dev/null +++ b/electron/mcp/mcp-controller.ts @@ -0,0 +1,112 @@ +// Owns the MCP server's lifecycle: start it when enabled, restart it when its +// port or token changes, stop it when disabled, and report status to settings. +// +// Off by default. While it is off nothing listens and the token is never read, +// so a user who never turns it on never meets a Keychain prompt for it. + +import type { AiEditionMcpStatus } from "../../src/native/contracts"; +import type { McpSettingsStore } from "./mcp-settings-store"; +import { + MCP_ENDPOINT_PATH, + type McpToolDeps, + type RunningMcpServer, + startMcpHttpServer, +} from "./openscreen-mcp-server"; + +export class McpController { + private running: RunningMcpServer | null = null; + private error: string | null = null; + /** Serialises start/stop so a quick toggle cannot leave two servers bound. */ + private transition: Promise = Promise.resolve(); + + constructor( + private readonly store: McpSettingsStore, + private readonly deps: McpToolDeps, + ) {} + + private serially(task: () => Promise): Promise { + const next = this.transition.then(task, task); + this.transition = next.catch(() => undefined); + return next; + } + + private async stopNow(): Promise { + const running = this.running; + this.running = null; + await running?.close(); + } + + /** (Re)start from the stored settings — or stop, if they say disabled. */ + private async applySettings(): Promise { + await this.stopNow(); + this.error = null; + const { enabled, port } = this.store.getSettings(); + if (!enabled) return; + try { + const token = await this.store.getToken(); + this.running = await startMcpHttpServer({ port, token, deps: this.deps }); + } catch (error) { + const code = (error as NodeJS.ErrnoException | null)?.code; + this.error = + code === "EADDRINUSE" + ? `Port ${port} is already in use. Choose another port.` + : error instanceof Error + ? error.message + : String(error); + } + } + + startIfEnabled(): Promise { + return this.serially(() => this.applySettings()); + } + + stop(): Promise { + return this.serially(() => this.stopNow()); + } + + async getStatus(): Promise { + await this.transition; + const { enabled, port } = this.store.getSettings(); + let token: string | null = null; + if (enabled) { + try { + token = await this.store.getToken(); + } catch { + // Surfaced through `error` already, from the failed start. + } + } + return { + enabled, + port, + running: this.running !== null, + url: `http://127.0.0.1:${port}${MCP_ENDPOINT_PATH}`, + token, + error: this.error, + }; + } + + async setEnabled(enabled: boolean): Promise { + await this.serially(async () => { + await this.store.setSettings({ enabled }); + await this.applySettings(); + }); + return this.getStatus(); + } + + async setPort(port: number): Promise { + await this.serially(async () => { + await this.store.setSettings({ port }); + await this.applySettings(); + }); + return this.getStatus(); + } + + /** New token; the running server is restarted so the old one stops working at once. */ + async regenerateToken(): Promise { + await this.serially(async () => { + await this.store.regenerateToken(); + await this.applySettings(); + }); + return this.getStatus(); + } +} diff --git a/electron/mcp/mcp-settings-store.ts b/electron/mcp/mcp-settings-store.ts new file mode 100644 index 000000000..7c794d50c --- /dev/null +++ b/electron/mcp/mcp-settings-store.ts @@ -0,0 +1,102 @@ +// Settings for the local MCP server: whether it runs, on which port, and the +// bearer token a client must present. +// +// Same split as LlmConfigStore: the non-secret part is plain JSON, the token is +// a credential and goes through the OS keychain (`safeStorage`), never plain +// JSON. The encryption is injected rather than imported so this file stays +// testable without Electron. + +import { randomBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; + +export const DEFAULT_MCP_PORT = 47821; + +export interface McpSettings { + enabled: boolean; + port: number; +} + +export interface McpTokenCrypto { + isEncryptionAvailable(): boolean; + encryptString(plain: string): Buffer; + decryptString(encrypted: Buffer): string; +} + +export function isValidMcpPort(port: unknown): port is number { + return typeof port === "number" && Number.isInteger(port) && port >= 1024 && port <= 65535; +} + +export class McpSettingsStore { + private readonly settingsPath: string; + private readonly tokenPath: string; + private settings: McpSettings; + private token: string | null = null; + + constructor( + userDataPath: string, + private readonly crypto: McpTokenCrypto, + ) { + this.settingsPath = path.join(userDataPath, "mcp-server.json"); + this.tokenPath = path.join(userDataPath, "mcp-token.enc"); + this.settings = this.readSettings(); + } + + private readSettings(): McpSettings { + try { + const raw = JSON.parse(readFileSync(this.settingsPath, "utf8")) as Partial; + return { + enabled: raw.enabled === true, + port: isValidMcpPort(raw.port) ? raw.port : DEFAULT_MCP_PORT, + }; + } catch { + return { enabled: false, port: DEFAULT_MCP_PORT }; + } + } + + getSettings(): McpSettings { + return { ...this.settings }; + } + + async setSettings(patch: Partial): Promise { + if (patch.port !== undefined && !isValidMcpPort(patch.port)) { + throw new Error("Port must be a whole number between 1024 and 65535."); + } + this.settings = { ...this.settings, ...patch }; + await fs.writeFile(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf8"); + return this.getSettings(); + } + + /** + * The token, created on first use. Read lazily: on macOS the decrypt is a + * Keychain access, which must not happen for users who never turn MCP on. + */ + async getToken(): Promise { + if (this.token) return this.token; + try { + const encrypted = readFileSync(this.tokenPath); + if (this.crypto.isEncryptionAvailable()) { + const token = this.crypto.decryptString(encrypted); + if (token) { + this.token = token; + return token; + } + } + } catch { + // No token yet, or unreadable — mint a fresh one below. + } + return this.regenerateToken(); + } + + /** Replace the token. Every client configured with the old one stops working. */ + async regenerateToken(): Promise { + if (!this.crypto.isEncryptionAvailable()) { + throw new Error("safeStorage is not available on this platform."); + } + const token = randomBytes(32).toString("base64url"); + await fs.writeFile(this.tokenPath, this.crypto.encryptString(token)); + this.token = token; + return token; + } +} diff --git a/electron/mcp/openscreen-mcp-server.test.ts b/electron/mcp/openscreen-mcp-server.test.ts new file mode 100644 index 000000000..f57b22c98 --- /dev/null +++ b/electron/mcp/openscreen-mcp-server.test.ts @@ -0,0 +1,252 @@ +// 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. + +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { afterEach, 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 { + MCP_ENDPOINT_PATH, + type McpApplyResult, + type McpDocumentHost, + type RunningMcpServer, + startMcpHttpServer, +} from "./openscreen-mcp-server"; + +const TOKEN = "test-token-0123456789"; + +function fixtureDocument(): AxcutDocument { + const base = createEmptyDocument({ + title: "Test", + projectId: "proj_1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + return documentSchema.parse({ + ...base, + project: { ...base.project, primaryAssetId: "asset_1" }, + assets: [ + { + id: "asset_1", + kind: "video", + label: "Recording", + originalPath: "/tmp/rec.mp4", + durationSec: 30, + }, + ], + timeline: { + ...base.timeline, + clips: [ + { + id: "clip_1", + assetId: "asset_1", + sourceStartSec: 0, + sourceEndSec: 30, + timelineStartSec: 0, + timelineEndSec: 30, + wordRefs: [], + origin: "user", + reason: "", + }, + ], + }, + }); +} + +/** The editor window, reduced to a document and the revision guarding it. */ +class FakeEditor implements McpDocumentHost { + document: AxcutDocument | null = fixtureDocument(); + revision = 1; + applied: AxcutDocument[] = []; + /** Runs between the snapshot and the apply — where a user edit would land. */ + onSnapshot?: () => void; + + async snapshot() { + if (!this.document) return null; + const snapshot = { document: structuredClone(this.document), revision: this.revision }; + this.onSnapshot?.(); + return snapshot; + } + + async apply(document: AxcutDocument, expectedRevision: number): Promise { + if (!this.document) return "no-live-document"; + if (expectedRevision !== this.revision) return "conflict"; + this.document = document; + this.revision += 1; + this.applied.push(document); + return "applied"; + } +} + +let running: RunningMcpServer | null = null; +let client: Client | null = null; + +afterEach(async () => { + await client?.close(); + await running?.close(); + client = null; + running = null; +}); + +async function connect( + editor: FakeEditor, + options: { editsAllowed?: boolean; headers?: Record } = {}, +) { + running = await startMcpHttpServer({ + port: 0, + token: TOKEN, + deps: { host: editor, 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}`); + await client.connect( + new StreamableHTTPClientTransport(url, { + requestInit: { headers: options.headers ?? { Authorization: `Bearer ${TOKEN}` } }, + }), + ); + return client; +} + +function resultText(result: Awaited>): string { + const content = result.content as Array<{ type: string; text?: string }>; + return content.map((part) => part.text ?? "").join(""); +} + +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(tool.description).toBe(TOOL_DESCRIPTIONS[tool.name]); + expect(tool.inputSchema.type).toBe("object"); + } + // The zod schemas survive the trip to JSON Schema with their fields intact. + const addTrim = tools.find((t) => t.name === "addTrim"); + expect(Object.keys(addTrim?.inputSchema.properties ?? {})).toEqual( + expect.arrayContaining(["startSec", "endSec"]), + ); + }); + + it("marks reads read-only and deletions destructive", async () => { + const mcp = await connect(new FakeEditor()); + 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("addTrim")?.readOnlyHint).toBe(false); + expect(byName.get("removeClip")?.destructiveHint).toBe(true); + }); + + it("hands the client the in-app agent's guidance as server instructions", async () => { + const mcp = await connect(new FakeEditor()); + expect(mcp.getInstructions()).toContain("Time-bases (do not mix them up)"); + }); +}); + +describe("calling a tool", () => { + it("reads the live editor document", async () => { + const mcp = await connect(new FakeEditor()); + const result = await mcp.callTool({ name: "getCurrentDocument", arguments: {} }); + expect(result.isError).toBeFalsy(); + expect(resultText(result)).toContain("clip_1"); + }); + + it("applies an edit to the editor, against the revision it read", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor); + const result = await mcp.callTool({ + name: "addTrim", + arguments: { assetId: "asset_1", startSec: 5, endSec: 6 }, + }); + expect(result.isError).toBeFalsy(); + expect(editor.applied).toHaveLength(1); + expect(editor.document?.timeline.trimRanges).toHaveLength(1); + expect(editor.revision).toBe(2); + }); + + it("does not touch the editor for a read", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor); + await mcp.callTool({ name: "getTranscript", arguments: {} }); + expect(editor.applied).toHaveLength(0); + }); + + it("refuses a write when the user has turned project edits off", async () => { + const editor = new FakeEditor(); + const mcp = await connect(editor, { editsAllowed: false }); + const result = await mcp.callTool({ + name: "addTrim", + arguments: { assetId: "asset_1", startSec: 5, endSec: 6 }, + }); + expect(result.isError).toBe(true); + expect(editor.applied).toHaveLength(0); + }); + + it("reports a conflict instead of overwriting a user edit made mid-call", async () => { + const editor = new FakeEditor(); + editor.onSnapshot = () => { + editor.revision += 1; + }; + const mcp = await connect(editor); + const result = await mcp.callTool({ + name: "addTrim", + arguments: { assetId: "asset_1", startSec: 5, endSec: 6 }, + }); + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("NOT applied"); + expect(editor.applied).toHaveLength(0); + }); + + it("says so when no project is open", async () => { + const editor = new FakeEditor(); + editor.document = null; + const mcp = await connect(editor); + const result = await mcp.callTool({ name: "getCurrentDocument", arguments: {} }); + expect(result.isError).toBe(true); + expect(resultText(result)).toContain("No project is open"); + }); +}); + +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" }, + }); + return fetch(`http://127.0.0.1:${running.port}${path}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...headers, + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }), + }); + } + + it("rejects a request without the token", async () => { + expect((await post({})).status).toBe(401); + }); + + it("rejects a wrong token", async () => { + expect((await post({ Authorization: "Bearer nope" })).status).toBe(401); + }); + + it("rejects a browser origin", async () => { + const response = await post({ + Authorization: `Bearer ${TOKEN}`, + Origin: "https://evil.example", + }); + expect(response.status).toBe(403); + }); + + it("rejects any path but the endpoint", async () => { + expect((await post({ Authorization: `Bearer ${TOKEN}` }, "/other")).status).toBe(404); + }); +}); diff --git a/electron/mcp/openscreen-mcp-server.ts b/electron/mcp/openscreen-mcp-server.ts new file mode 100644 index 000000000..1e24700b9 --- /dev/null +++ b/electron/mcp/openscreen-mcp-server.ts @@ -0,0 +1,255 @@ +// The OpenScreen MCP server: the in-app agent's tools, offered to any MCP client +// (Claude Code, Codex, Cursor…) that the user runs themselves. +// +// Nothing here is a second implementation. The tool list, argument schemas, +// descriptions and guidance are the in-app agent's own (`TOOL_ARG_SCHEMAS`, +// `TOOL_DESCRIPTIONS`, `buildSystemPrompt`), and every call runs through +// `runDocumentTool` — the same executor, consent gate and cursor read the +// in-app agent uses. The one difference is where the document comes from: the +// in-app agent is handed a snapshot per chat turn, while here each call reads +// the live document from the editor window and writes the result back through +// the same revision-guarded apply, so a user edit landing mid-call is never +// overwritten and every edit is one undo step. +// +// 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 { 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 { type AxcutDocument, documentSchema } from "../../src/lib/ai-edition/schema"; +import { isMutatingTool } from "../ai-edition/agent-tools"; +import { + buildSystemPrompt, + type CursorTelemetryReader, + probeCursorTelemetry, + runDocumentTool, + TOOL_ARG_SCHEMAS, + TOOL_DESCRIPTIONS, +} from "../ai-edition/deep-agent/service"; + +export const MCP_SERVER_NAME = "openscreen"; +export const MCP_ENDPOINT_PATH = "/mcp"; + +export interface McpDocumentSnapshot { + document: unknown; + /** The editor's revision when the snapshot was taken — the apply guard. */ + revision: number; +} + +/** Mirrors the renderer's `AgentDocumentApplyResult`, plus the editor having gone + * away or not answering in time. */ +export type McpApplyResult = + | "applied" + | "conflict" + | "save-failed" + | "no-live-document" + | "no-editor" + | "timeout"; + +/** The live document, as held by the editor window. */ +export interface McpDocumentHost { + /** `null` when no editor window is open or it has no project loaded. */ + snapshot(): Promise; + apply(document: AxcutDocument, expectedRevision: number): Promise; +} + +export interface McpToolDeps { + host: McpDocumentHost; + /** The "Project edits" setting — the same one the in-app agent obeys. */ + editsAllowed(): boolean; + cursor?: CursorTelemetryReader; + version: string; +} + +const NO_PROJECT_MESSAGE = + "No project is open in the OpenScreen editor. Ask the user to open one in OpenScreen, then retry."; + +const APPLY_FAILURE_MESSAGES: Record, string> = { + conflict: + "The edit was NOT applied: the project changed in the editor while this call ran. Call getCurrentDocument to re-read it, then retry.", + "save-failed": "The edit was NOT applied: OpenScreen could not save the project.", + "no-live-document": NO_PROJECT_MESSAGE, + "no-editor": NO_PROJECT_MESSAGE, + // Not "NOT applied": the editor may have saved it and only the answer was lost. + timeout: + "OpenScreen did not confirm this edit in time. Call getCurrentDocument to see whether it landed before retrying.", +}; + +const DESTRUCTIVE_TOOLS: ReadonlySet = new Set([ + "replaceTimeline", + "removeTrim", + "removeModifier", + "removeClip", +]); + +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.", + "", +].join("\n"); + +function textResult(text: string, isError: boolean): CallToolResult { + return { content: [{ type: "text", text }], ...(isError ? { isError: true } : {}) }; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * 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 + * first just moved — a spurious conflict at best. + */ +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); + 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, + }); + if (execution.document) { + const applied = await deps.host.apply(execution.document, snapshot.revision); + if (applied !== "applied") return textResult(APPLY_FAILURE_MESSAGES[applied], true); + } + return textResult(execution.resultJson, !execution.ok); + } + + return (name: string, args: unknown): Promise => { + const next = queue.then( + () => run(name, args), + () => run(name, args), + ); + queue = next.catch(() => undefined); + return next.catch((error) => textResult(`Tool failed: ${errorMessage(error)}`, true)); + }; +} + +export function createOpenScreenMcpServer( + deps: McpToolDeps, + runTool: (name: string, args: unknown) => Promise, +): McpServer { + const server = new McpServer( + { name: MCP_SERVER_NAME, version: deps.version }, + { instructions: MCP_PREAMBLE + buildSystemPrompt({ editsAllowed: deps.editsAllowed() }) }, + ); + for (const [name, schema] of TOOL_ARG_SCHEMAS) { + const mutating = isMutatingTool(name); + server.registerTool( + name, + { + description: TOOL_DESCRIPTIONS[name], + inputSchema: schema, + annotations: { + readOnlyHint: !mutating, + destructiveHint: DESTRUCTIVE_TOOLS.has(name), + openWorldHint: false, + }, + }, + (args: unknown) => runTool(name, args), + ); + } + return server; +} + +function isAllowedHost(hostHeader: string | undefined, port: number): boolean { + return hostHeader === `127.0.0.1:${port}` || hostHeader === `localhost:${port}`; +} + +function isAllowedOrigin(origin: string | undefined, port: number): boolean { + // MCP clients are not browsers and send no Origin; a browser always does. + if (origin === undefined) return true; + return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`; +} + +function hasValidToken(authorization: string | undefined, token: string): boolean { + const presented = authorization?.startsWith("Bearer ") ? authorization.slice(7).trim() : ""; + const a = Buffer.from(presented); + const b = Buffer.from(token); + return a.length === b.length && timingSafeEqual(a, b); +} + +function reject(res: ServerResponse, status: number, message: string, headers = {}): void { + res.writeHead(status, { "Content-Type": "application/json", ...headers }); + res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null })); +} + +export interface RunningMcpServer { + port: number; + close(): Promise; +} + +export async function startMcpHttpServer(options: { + port: number; + token: string; + deps: McpToolDeps; +}): Promise { + const { token, deps } = options; + const runTool = createToolRunner(deps); + let boundPort = options.port; + + const handle = async (req: IncomingMessage, res: ServerResponse) => { + const pathname = new URL(req.url ?? "/", "http://127.0.0.1").pathname; + if (pathname !== MCP_ENDPOINT_PATH) return reject(res, 404, "Not found"); + if (!isAllowedHost(req.headers.host, boundPort)) return reject(res, 403, "Forbidden host"); + if (!isAllowedOrigin(req.headers.origin, boundPort)) { + return reject(res, 403, "Forbidden origin"); + } + if (!hasValidToken(req.headers.authorization, token)) { + return reject(res, 401, "Unauthorized", { "WWW-Authenticate": "Bearer" }); + } + + // Stateless: one server + transport per request, the SDK's documented shape + // for a server that keeps no per-session state. Tools read the live editor + // on every call, so there is nothing a session would need to remember. + const server = createOpenScreenMcpServer(deps, runTool); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + res.on("close", () => { + void transport.close(); + void server.close(); + }); + try { + await server.connect(transport); + await transport.handleRequest(req, res); + } catch (error) { + if (!res.headersSent) reject(res, 500, errorMessage(error)); + } + }; + + const httpServer: Server = createServer((req, res) => { + void handle(req, res); + }); + await new Promise((resolve, reject) => { + httpServer.once("error", reject); + httpServer.listen(options.port, "127.0.0.1", () => { + httpServer.off("error", reject); + resolve(); + }); + }); + boundPort = (httpServer.address() as AddressInfo).port; + + return { + port: boundPort, + close: () => + new Promise((resolve) => { + httpServer.closeAllConnections(); + httpServer.close(() => resolve()); + }), + }; +} diff --git a/electron/native-bridge/services/aiEditionService.ts b/electron/native-bridge/services/aiEditionService.ts index 90088781a..fdcb2b565 100644 --- a/electron/native-bridge/services/aiEditionService.ts +++ b/electron/native-bridge/services/aiEditionService.ts @@ -13,6 +13,7 @@ import type { AiEditionLlmConfig, AiEditionLlmDisconnectResult, AiEditionLlmSnapshot, + AiEditionMcpStatus, AiEditionProjectSummary, } from "../../../src/native/contracts"; import { @@ -31,6 +32,7 @@ import { probeMiniMaxModels, } from "../../ai-edition/llm-provider-auth"; import { PROVIDER_DEFINITIONS } from "../../ai-edition/provider-registry"; +import type { McpController } from "../../mcp/mcp-controller"; export interface AiEditionServiceOptions { documents: DocumentService; @@ -80,6 +82,8 @@ export interface AiEditionServiceOptions { title: string, ) => AiEditionChatSessionSummary | null; deleteSession: (projectId: string, sessionId: string) => boolean; + /** The local MCP server. Absent where no server is wired (tests, headless CLI). */ + mcp?: McpController; } export class AiEditionService { @@ -271,6 +275,27 @@ export class AiEditionService { } } + private get mcp(): McpController { + if (!this.options.mcp) throw new Error("The MCP server is not available in this build."); + return this.options.mcp; + } + + mcpGetStatus(): Promise { + return this.mcp.getStatus(); + } + + mcpSetEnabled(enabled: boolean): Promise { + return this.mcp.setEnabled(enabled); + } + + mcpSetPort(port: number): Promise { + return this.mcp.setPort(port); + } + + mcpRegenerateToken(): Promise { + return this.mcp.regenerateToken(); + } + async chatRun( projectId: string, sessionId: string, diff --git a/electron/preload.ts b/electron/preload.ts index 59d54c217..7a553a3fd 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -4,8 +4,18 @@ import type { NativeMacRecordingRequest } from "../src/lib/nativeMacRecording"; import type { NativeWindowsRecordingRequest } from "../src/lib/nativeWindowsRecording"; import type { RecordingSession, StoreRecordedSessionInput } from "../src/lib/recordingSession"; import type { ShortcutBinding } from "../src/lib/shortcuts"; -import type { AiEditionChatEvent } from "../src/native/contracts"; -import { NATIVE_BRIDGE_CHANNEL, type NativeBridgeRequest } from "../src/native/contracts"; +import type { + AiEditionChatEvent, + AiEditionMcpHostRequest, + AiEditionMcpHostResponse, +} from "../src/native/contracts"; +import { + AI_EDITION_MCP_HOST_CHANNEL, + AI_EDITION_MCP_REQUEST_CHANNEL, + AI_EDITION_MCP_RESPONSE_CHANNEL, + NATIVE_BRIDGE_CHANNEL, + type NativeBridgeRequest, +} from "../src/native/contracts"; import type { RecordingPrefs } from "./ipc/handlers"; import type { SttStatusEvent, @@ -522,6 +532,33 @@ contextBridge.exposeInMainWorld("electronAPI", { ipcRenderer.on("ai-edition.chat-event", listener); return () => ipcRenderer.removeListener("ai-edition.chat-event", listener); }, + // The editor answers the MCP server's reads and writes of the live document. + // Subscribing is what makes this window the one the server asks; the + // returned unsubscribe withdraws it. See electron/mcp/editor-document-host.ts. + onAiEditionMcpRequest: ( + callback: (request: AiEditionMcpHostRequest) => Promise, + ) => { + const listener = (_e: unknown, request: AiEditionMcpHostRequest) => { + void callback(request).then( + (result) => + ipcRenderer.send(AI_EDITION_MCP_RESPONSE_CHANNEL, { + requestId: request.requestId, + result, + }), + () => + ipcRenderer.send(AI_EDITION_MCP_RESPONSE_CHANNEL, { + requestId: request.requestId, + result: null, + }), + ); + }; + ipcRenderer.on(AI_EDITION_MCP_REQUEST_CHANNEL, listener); + ipcRenderer.send(AI_EDITION_MCP_HOST_CHANNEL, true); + return () => { + ipcRenderer.removeListener(AI_EDITION_MCP_REQUEST_CHANNEL, listener); + ipcRenderer.send(AI_EDITION_MCP_HOST_CHANNEL, false); + }; + }, stt: { transcribe: (request: SttTranscribeRequest): Promise => { return ipcRenderer.invoke("stt:transcribe", request) as Promise; diff --git a/package-lock.json b/package-lock.json index c6bd96f2e..468e5537f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@langchain/core": "^1.1.44", "@langchain/mistralai": "^1.0.8", "@langchain/openai": "^1.4.2", + "@modelcontextprotocol/sdk": "^1.31.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -1824,6 +1825,18 @@ "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, + "node_modules/@hono/node-server": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", + "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2153,6 +2166,55 @@ "zod-to-json-schema": "^3.25.0" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", + "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/@noble/hashes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", @@ -4964,6 +5026,44 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -4991,7 +5091,6 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -5004,6 +5103,23 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -5464,6 +5580,30 @@ "dev": true, "license": "MIT" }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/boolean": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", @@ -5626,6 +5766,15 @@ "node": ">= 10.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/bytestreamjs": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", @@ -5669,7 +5818,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -5679,6 +5827,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -5968,11 +6132,23 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { "node": ">=18" @@ -5989,6 +6165,24 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -5996,6 +6190,23 @@ "dev": true, "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-dirname": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", @@ -6009,7 +6220,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6024,14 +6234,12 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/cross-spawn/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -6207,6 +6415,15 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -6377,7 +6594,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -6398,6 +6614,12 @@ "readable-stream": "^2.0.2" } }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/ejs": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", @@ -6698,6 +6920,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6755,7 +6986,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6781,7 +7011,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6866,6 +7095,12 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/escape-string-regexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", @@ -6890,12 +7125,42 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -6913,6 +7178,102 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express/node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -6961,7 +7322,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, "license": "MIT" }, "node_modules/fast-equals": { @@ -7011,7 +7371,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, "funding": [ { "type": "github", @@ -7112,6 +7471,27 @@ "node": ">=8" } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -7129,6 +7509,15 @@ "node": ">= 6" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -7170,6 +7559,15 @@ } } }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fs-extra": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", @@ -7252,7 +7650,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -7286,7 +7683,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -7432,7 +7828,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7501,7 +7896,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7538,6 +7932,15 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.13.11", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.11.tgz", + "integrity": "sha512-/SMX/RQNJn7oNmFwH6DtwDqcrzZU2otm1FD6OJe2cWF6cfy/F8hq0XVBv7xW3FTJshRQwuBnXHDq2kNsdZnshg==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, "node_modules/hosted-git-info": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", @@ -7591,9 +7994,29 @@ "dev": true, "license": "BSD-2-Clause" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, "license": "MIT", @@ -7672,6 +8095,22 @@ "@babel/runtime": "^7.23.2" } }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -7698,9 +8137,26 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.2", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz", + "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -7787,6 +8243,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -7845,6 +8307,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tiktoken": { "version": "1.0.21", "resolved": "https://registry.npmjs.org/js-tiktoken/-/js-tiktoken-1.0.21.tgz", @@ -7970,9 +8441,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, "license": "MIT" }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -8407,7 +8883,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8420,6 +8895,19 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/mediabunny": { "version": "1.40.1", "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.40.1.tgz", @@ -8437,6 +8925,18 @@ "url": "https://github.com/sponsors/Vanilagy" } }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -8697,6 +9197,22 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/node-abi": { "version": "4.28.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.28.0.tgz", @@ -8890,6 +9406,18 @@ "node": ">= 6" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -8912,11 +9440,22 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -9065,6 +9604,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -9079,7 +9627,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9091,6 +9638,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "license": "MIT" }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -9156,6 +9713,15 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkijs": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", @@ -9659,6 +10225,23 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -9717,6 +10300,22 @@ "node": ">=16.0.0" } }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -9750,6 +10349,34 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/re-resizable": { "version": "6.11.2", "resolved": "https://registry.npmjs.org/re-resizable/-/re-resizable-6.11.2.tgz", @@ -10016,7 +10643,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10223,6 +10849,22 @@ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", "license": "MIT" }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -10253,6 +10895,12 @@ "dev": true, "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/sanitize-filename": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/sanitize-filename/-/sanitize-filename-1.6.4.tgz", @@ -10312,6 +10960,57 @@ "license": "MIT", "optional": true }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/serialize-error": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", @@ -10329,11 +11028,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -10346,12 +11069,83 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -10467,6 +11261,15 @@ "node": ">= 6" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", @@ -10964,6 +11767,15 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -11042,6 +11854,49 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -11090,6 +11945,15 @@ "node": ">= 4.0.0" } }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/unzipper": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.5.tgz", @@ -11238,6 +12102,15 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -11610,7 +12483,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { diff --git a/package.json b/package.json index e80168645..6e6995f1f 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@langchain/core": "^1.1.44", "@langchain/mistralai": "^1.0.8", "@langchain/openai": "^1.4.2", + "@modelcontextprotocol/sdk": "^1.31.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/src/components/ai-edition/McpServerSettings.test.tsx b/src/components/ai-edition/McpServerSettings.test.tsx new file mode 100644 index 000000000..e14105a94 --- /dev/null +++ b/src/components/ai-edition/McpServerSettings.test.tsx @@ -0,0 +1,87 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "@/contexts/I18nContext"; +import type { AiEditionMcpStatus } from "@/native/contracts"; + +const TOKEN = "secret-token"; +const URL = "http://127.0.0.1:47821/mcp"; + +function status(enabled: boolean): AiEditionMcpStatus { + return { + enabled, + port: 47821, + running: enabled, + url: URL, + token: enabled ? TOKEN : null, + error: null, + }; +} + +const bridge = vi.hoisted(() => ({ + mcpGetStatus: vi.fn(), + mcpSetEnabled: vi.fn(), + mcpSetPort: vi.fn(), + mcpRegenerateToken: vi.fn(), +})); + +vi.mock("@/native/client", () => ({ nativeBridgeClient: { aiEdition: bridge } })); + +import { claudeCodeCommand, codexCommand, McpServerSettings } from "./McpServerSettings"; + +const copyToClipboard = vi.fn(() => Promise.resolve()); + +function renderSection() { + return render( + + + , + ); +} + +describe("McpServerSettings", () => { + beforeEach(() => { + for (const fn of Object.values(bridge)) fn.mockReset(); + copyToClipboard.mockClear(); + (window as unknown as { electronAPI: unknown }).electronAPI = { copyToClipboard }; + }); + afterEach(cleanup); + + it("shows the server as off, with no token or commands", async () => { + bridge.mcpGetStatus.mockResolvedValue(status(false)); + renderSection(); + expect(await screen.findByTestId("mcp-server-settings")).toBeInTheDocument(); + expect(screen.getByTestId("mcp-server-toggle")).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByText(/claude mcp add/)).not.toBeInTheDocument(); + }); + + it("turns the server on and shows how to connect, token masked", async () => { + bridge.mcpGetStatus.mockResolvedValue(status(false)); + bridge.mcpSetEnabled.mockResolvedValue(status(true)); + renderSection(); + fireEvent.click(await screen.findByTestId("mcp-server-toggle")); + await waitFor(() => expect(bridge.mcpSetEnabled).toHaveBeenCalledWith(true)); + expect(await screen.findByText(codexCommand(URL))).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(TOKEN))).not.toBeInTheDocument(); + }); + + it("copies the real token into the Claude Code command", async () => { + bridge.mcpGetStatus.mockResolvedValue(status(true)); + renderSection(); + await screen.findByText(codexCommand(URL)); + const copyButtons = screen.getAllByRole("button", { name: "Copy" }); + // Token, Claude Code, Codex — in that order. + fireEvent.click(copyButtons[1]); + expect(copyToClipboard).toHaveBeenCalledWith(claudeCodeCommand(URL, TOKEN)); + }); + + it("stays out of the way when the status cannot be read", async () => { + bridge.mcpGetStatus.mockImplementation(() => { + throw new Error("no bridge"); + }); + renderSection(); + await waitFor(() => expect(bridge.mcpGetStatus).toHaveBeenCalled()); + expect(screen.queryByTestId("mcp-server-settings")).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/ai-edition/McpServerSettings.tsx b/src/components/ai-edition/McpServerSettings.tsx new file mode 100644 index 000000000..754445007 --- /dev/null +++ b/src/components/ai-edition/McpServerSettings.tsx @@ -0,0 +1,255 @@ +// The MCP server section of AI settings: turn the local server on, pick its +// port, and copy what an MCP client needs to connect. The server itself lives +// in electron/mcp/; this only drives it through the native bridge. + +import { AlertCircle, Check, Copy, Eye, EyeOff, Loader2, RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { useScopedT } from "@/contexts/I18nContext"; +import { nativeBridgeClient } from "@/native/client"; +import type { AiEditionMcpStatus } from "@/native/contracts"; +import styles from "./NewEditorShell.module.css"; + +const CODEX_TOKEN_ENV = "OPENSCREEN_MCP_TOKEN"; +const MASKED_TOKEN = "••••••••"; + +export function claudeCodeCommand(url: string, token: string): string { + return `claude mcp add --transport http openscreen ${url} --header "Authorization: Bearer ${token}"`; +} + +export function codexCommand(url: string): string { + return `codex mcp add openscreen --url ${url} --bearer-token-env-var ${CODEX_TOKEN_ENV}`; +} + +const codeStyle: React.CSSProperties = { + flex: 1, + minWidth: 0, + overflowX: "auto", + whiteSpace: "nowrap", + padding: "6px 8px", + borderRadius: 6, + background: "var(--bg-2, rgba(127,127,127,0.12))", + font: "12px var(--font-mono)", + color: "var(--fg-2)", +}; + +const rowStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8 }; + +export function McpServerSettings({ open }: { open: boolean }) { + const te = useScopedT("editor"); + const [status, setStatus] = useState(null); + const [portDraft, setPortDraft] = useState(""); + const [busy, setBusy] = useState(false); + const [showToken, setShowToken] = useState(false); + + const adopt = useCallback((next: AiEditionMcpStatus) => { + setStatus(next); + setPortDraft(String(next.port)); + }, []); + + useEffect(() => { + if (!open) { + setShowToken(false); + return; + } + // Any failure, even a synchronous one, just hides the section: it must never + // take the provider settings around it down with it. + void Promise.resolve() + .then(() => nativeBridgeClient.aiEdition.mcpGetStatus()) + .then(adopt, () => setStatus(null)); + }, [open, adopt]); + + const run = async (task: () => Promise) => { + setBusy(true); + try { + adopt(await task()); + } catch (err) { + toast.error(te("mcpServer.updateFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + } finally { + setBusy(false); + } + }; + + const copy = (text: string) => { + const bridge = window.electronAPI?.copyToClipboard; + const write = bridge ? bridge(text) : navigator.clipboard.writeText(text); + void write.then( + () => toast.success(te("mcpServer.copied")), + () => toast.error(te("mcpServer.copyFailed")), + ); + }; + + if (!status) return null; + + const port = Number(portDraft); + const portChanged = portDraft !== String(status.port); + const token = status.token; + const shownToken = token && showToken ? token : MASKED_TOKEN; + + return ( +
+
+
+

{te("mcpServer.title")}

+ {status.running ? ( + + + {te("mcpServer.statusRunning")} + + ) : ( + + {status.enabled ? te("mcpServer.statusError") : te("mcpServer.statusOff")} + + )} +
+

{te("mcpServer.description")}

+ +
+ +
+ +
+ +
+ setPortDraft(e.target.value)} + disabled={busy} + style={{ width: 120 }} + /> + {portChanged ? ( + + ) : null} +
+
+ + {status.error ? ( +

+ + {status.error} +

+ ) : null} + + {status.enabled && token ? ( + <> +
+ +
+ {shownToken} + setShowToken((v) => !v)} + > + {showToken ? : } + + copy(token)}> + + + run(() => nativeBridgeClient.aiEdition.mcpRegenerateToken())} + > + {busy ? : } + +
+

{te("mcpServer.tokenHint")}

+
+ +
+ +
+ {claudeCodeCommand(status.url, shownToken)} + copy(claudeCodeCommand(status.url, token))} + > + + +
+
+ +
+ +
+ {codexCommand(status.url)} + copy(codexCommand(status.url))} + > + + +
+

{te("mcpServer.codexHint", { env: CODEX_TOKEN_ENV })}

+
+ + ) : null} +
+
+ ); +} + +function IconButton({ + label, + onClick, + disabled, + children, +}: { + label: string; + onClick: () => void; + disabled?: boolean; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx index 1ab5054e1..ee12161bd 100644 --- a/src/components/ai-edition/NewEditorShell.tsx +++ b/src/components/ai-edition/NewEditorShell.tsx @@ -31,6 +31,7 @@ import { type AxcutDocument, documentSchema, } from "@/lib/ai-edition/schema"; +import { useMcpDocumentHost } from "@/lib/ai-edition/store/mcpDocumentHost"; import { saveWithDeadline, useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -191,6 +192,7 @@ export async function runLoadedMetadataWrite( export function NewEditorShell() { const te = useScopedT("editor"); + useMcpDocumentHost(); const document = useProjectStore((s) => s.document); const projectId = useProjectStore((s) => s.projectId); const dirty = useProjectStore((s) => s.dirty); diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx index 1ed6f377e..57b27cd1c 100644 --- a/src/components/ai-edition/ProviderSettings.test.tsx +++ b/src/components/ai-edition/ProviderSettings.test.tsx @@ -28,6 +28,16 @@ vi.mock("@/native/client", () => ({ credentialSummary: [], }), llmListProviderModels: () => Promise.resolve({ models: [] }), + // The list screen also shows the MCP server section, which reads its status on open. + mcpGetStatus: () => + Promise.resolve({ + enabled: false, + port: 47821, + running: false, + url: "http://127.0.0.1:47821/mcp", + token: null, + error: null, + }), }, }, })); diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx index e27cf82b1..e227ec940 100644 --- a/src/components/ai-edition/ProviderSettings.tsx +++ b/src/components/ai-edition/ProviderSettings.tsx @@ -29,6 +29,7 @@ import { PROVIDER_DEFINITIONS, type ProviderDefinition, } from "../../../electron/ai-edition/provider-registry"; +import { McpServerSettings } from "./McpServerSettings"; import { ModalShell } from "./Modals"; import styles from "./NewEditorShell.module.css"; @@ -177,11 +178,14 @@ function ProviderSettings({ open, onClose }: ProviderSettingsProps) { wide > {mode === "list" ? ( - + <> + + + ) : active ? ( vi.fn()); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { save: saveMock }, + }, +})); + +function openProject(revision: number) { + const document = createEmptyDocument({ projectId: "project_1", title: "Before" }); + useProjectStore.setState({ projectId: "project_1", document, revision }); + return document; +} + +describe("answerMcpHostRequest", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + clearHistory(); + saveMock.mockReset(); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + }); + + it("snapshots the live document with its revision", async () => { + const document = openProject(4); + await expect(answerMcpHostRequest({ requestId: "r", op: "snapshot" })).resolves.toEqual({ + document, + revision: 4, + }); + }); + + it("answers null when no project is open", async () => { + await expect(answerMcpHostRequest({ requestId: "r", op: "snapshot" })).resolves.toBeNull(); + }); + + it("applies an edit made against the current revision, as one undo step", async () => { + const before = openProject(4); + const edited = { ...before, project: { ...before.project, title: "MCP edit" } }; + await expect( + answerMcpHostRequest({ requestId: "r", op: "apply", document: edited, expectedRevision: 4 }), + ).resolves.toBe("applied"); + expect(useProjectStore.getState().document?.project.title).toBe("MCP edit"); + undo(); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + }); + + it("refuses an edit made against a stale revision", async () => { + const before = openProject(5); + const edited = { ...before, project: { ...before.project, title: "MCP edit" } }; + await expect( + answerMcpHostRequest({ requestId: "r", op: "apply", document: edited, expectedRevision: 4 }), + ).resolves.toBe("conflict"); + expect(saveMock).not.toHaveBeenCalled(); + }); + + it("refuses another project's document even at a matching revision", async () => { + openProject(1); + const other = createEmptyDocument({ projectId: "project_2", title: "Other" }); + await expect( + answerMcpHostRequest({ requestId: "r", op: "apply", document: other, expectedRevision: 1 }), + ).resolves.toBe("conflict"); + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + }); + + it("reports no live document when the project was closed", async () => { + const other = createEmptyDocument({ projectId: "project_1", title: "x" }); + await expect( + answerMcpHostRequest({ requestId: "r", op: "apply", document: other, expectedRevision: 0 }), + ).resolves.toBe("no-live-document"); + }); +}); diff --git a/src/lib/ai-edition/store/mcpDocumentHost.ts b/src/lib/ai-edition/store/mcpDocumentHost.ts new file mode 100644 index 000000000..d4874354d --- /dev/null +++ b/src/lib/ai-edition/store/mcpDocumentHost.ts @@ -0,0 +1,35 @@ +import { useEffect } from "react"; +import type { AiEditionMcpHostRequest, AiEditionMcpHostResponse } from "@/native/contracts"; +import { applyAgentDocumentIfCurrent } from "./agentDocumentApply"; +import { useProjectStore } from "./projectStore"; + +/** + * The editor's side of the MCP server (electron/mcp/): hand it the live document + * and apply what its tools return, through the same revision-guarded apply an + * in-app chat turn uses — so an MCP edit is saved, is one undo step, and never + * lands on top of a change the user made while the call was running. + */ +export async function answerMcpHostRequest( + request: AiEditionMcpHostRequest, +): Promise { + const { document, revision } = useProjectStore.getState(); + if (request.op === "snapshot") { + return document ? { document, revision } : null; + } + if (!document) return "no-live-document"; + // The revision counter restarts when a project is closed, so a quick close + // and reopen of ANOTHER project can land on the revision the call was given. + // The id is what tells the two apart. + const incoming = request.document as { project?: { id?: unknown } } | null; + if (incoming?.project?.id !== document.project.id) return "conflict"; + try { + return await applyAgentDocumentIfCurrent(request.document, request.expectedRevision); + } catch { + return "save-failed"; + } +} + +/** Makes this editor window the one the MCP server reads and writes, while mounted. */ +export function useMcpDocumentHost(): void { + useEffect(() => window.electronAPI?.onAiEditionMcpRequest?.(answerMcpHostRequest), []); +} diff --git a/src/native/browserShim.ts b/src/native/browserShim.ts index f8029d5e8..ec7bcec35 100644 --- a/src/native/browserShim.ts +++ b/src/native/browserShim.ts @@ -462,6 +462,16 @@ function createShimBridgeClient() { if (taken) throw presetError("NAME_TAKEN", `A style preset named "${name}" already exists.`); }; + const shimMcp = { enabled: false, port: 47821, token: "shim-token" }; + const shimMcpStatus = () => ({ + enabled: shimMcp.enabled, + port: shimMcp.port, + running: false, + url: `http://127.0.0.1:${shimMcp.port}/mcp`, + token: shimMcp.enabled ? shimMcp.token : null, + error: shimMcp.enabled ? "The MCP server only runs in the desktop app." : null, + }); + const summarize = (s: ShimSession) => ({ id: s.id, projectId: s.projectId, @@ -592,6 +602,21 @@ function createShimBridgeClient() { saveLlmState(); return Promise.resolve({ success: true, snapshot: buildLlmSnapshot() }); }, + // No MCP server runs in a browser: the settings keep their state so the + // section can be exercised, and say plainly that nothing is listening. + mcpGetStatus: () => Promise.resolve(shimMcpStatus()), + mcpSetEnabled: (enabled: boolean) => { + shimMcp.enabled = enabled; + return Promise.resolve(shimMcpStatus()); + }, + mcpSetPort: (port: number) => { + shimMcp.port = port; + return Promise.resolve(shimMcpStatus()); + }, + mcpRegenerateToken: () => { + shimMcp.token = `shim-token-${Date.now()}`; + return Promise.resolve(shimMcpStatus()); + }, llmListProviderModels: (providerId: string) => Promise.resolve({ models: [`${providerId}-demo-model-1`, `${providerId}-demo-model-2`], diff --git a/src/native/client.ts b/src/native/client.ts index 4845b9511..30ef66288 100644 --- a/src/native/client.ts +++ b/src/native/client.ts @@ -12,6 +12,7 @@ import { type AiEditionLlmDisconnectResult, type AiEditionLlmProviderModelsResult, type AiEditionLlmSnapshot, + type AiEditionMcpStatus, type AiEditionProjectSummary, type CursorCapabilities, type CursorRecordingData, @@ -256,6 +257,28 @@ export const nativeBridgeClient = { action: "llm.listProviderModels", payload: { providerId }, }), + mcpGetStatus: () => + requireNativeBridgeData({ + domain: "aiEdition", + action: "mcp.getStatus", + }), + mcpSetEnabled: (enabled: boolean) => + requireNativeBridgeData({ + domain: "aiEdition", + action: "mcp.setEnabled", + payload: { enabled }, + }), + mcpSetPort: (port: number) => + requireNativeBridgeData({ + domain: "aiEdition", + action: "mcp.setPort", + payload: { port }, + }), + mcpRegenerateToken: () => + requireNativeBridgeData({ + domain: "aiEdition", + action: "mcp.regenerateToken", + }), chatRun: ( projectId: string, sessionId: string, diff --git a/src/native/contracts.ts b/src/native/contracts.ts index 10164ad18..d028a7fa0 100644 --- a/src/native/contracts.ts +++ b/src/native/contracts.ts @@ -286,6 +286,44 @@ export interface AiEditionLlmProviderModelsResult { error?: string; } +/** The local MCP server that offers the agent's tools to external MCP clients. */ +export interface AiEditionMcpStatus { + enabled: boolean; + port: number; + running: boolean; + /** `http://127.0.0.1:/mcp` — what a client is pointed at. */ + url: string; + /** Bearer token a client must send. Only filled in while the server is enabled. */ + token: string | null; + /** Why the server is enabled but not running (port taken, keychain unavailable…). */ + error: string | null; +} + +/** + * Main → editor window: the MCP server reading or writing the live document. + * The editor answers every request on `AI_EDITION_MCP_RESPONSE_CHANNEL`. + */ +export type AiEditionMcpHostRequest = + | { requestId: string; op: "snapshot" } + | { requestId: string; op: "apply"; document: unknown; expectedRevision: number }; + +export interface AiEditionMcpHostSnapshot { + document: unknown; + revision: number; +} + +export type AiEditionMcpApplyResult = "applied" | "conflict" | "save-failed" | "no-live-document"; + +export interface AiEditionMcpHostResponse { + requestId: string; + result: AiEditionMcpHostSnapshot | null | AiEditionMcpApplyResult; +} + +/** Editor → main: `true` when an editor starts answering MCP requests, `false` when it stops. */ +export const AI_EDITION_MCP_HOST_CHANNEL = "ai-edition.mcp-host"; +export const AI_EDITION_MCP_REQUEST_CHANNEL = "ai-edition.mcp-request"; +export const AI_EDITION_MCP_RESPONSE_CHANNEL = "ai-edition.mcp-response"; + /** One executed agent tool call, rendered as a compact "applied: …" line in * the chat panel (P1.7). */ export interface AiEditionToolCallSummary { @@ -601,6 +639,30 @@ export type NativeBridgeRequest = payload: { providerId: string }; requestId?: string; } + | { + domain: "aiEdition"; + action: "mcp.getStatus"; + payload?: EmptyPayload; + requestId?: string; + } + | { + domain: "aiEdition"; + action: "mcp.setEnabled"; + payload: { enabled: boolean }; + requestId?: string; + } + | { + domain: "aiEdition"; + action: "mcp.setPort"; + payload: { port: number }; + requestId?: string; + } + | { + domain: "aiEdition"; + action: "mcp.regenerateToken"; + payload?: EmptyPayload; + requestId?: string; + } | { domain: "aiEdition"; action: "chat.run"; diff --git a/technical-documentation/README.md b/technical-documentation/README.md index 5e528085e..d939892d7 100644 --- a/technical-documentation/README.md +++ b/technical-documentation/README.md @@ -28,6 +28,7 @@ who reads and writes the project document. | [transcription-and-captions.md](architecture/transcription-and-captions.md) | On-device speech to text, and the caption layer derived from it | | [ai-agent.md](architecture/ai-agent.md) | The optional agent: tool loop, checkpoints, context management | | [llm-providers.md](architecture/llm-providers.md) | Provider registry, auth modes, credential storage | +| [mcp-server.md](architecture/mcp-server.md) | The local MCP server that offers the agent's tools to Claude Code, Codex and other MCP clients | | [native-bridge.md](architecture/native-bridge.md) | The renderer ↔ main-process contract every native capability goes through | | [decisions.md](architecture/decisions.md) | **The decision ledger** — what is settled, what was rejected and why | diff --git a/technical-documentation/architecture/llm-providers.md b/technical-documentation/architecture/llm-providers.md index dc132e234..451fc3943 100644 --- a/technical-documentation/architecture/llm-providers.md +++ b/technical-documentation/architecture/llm-providers.md @@ -102,6 +102,10 @@ Three call sites share that factory: MiniMax's `thinking` block is binary (`{type: "adaptive"}` or absent), so `getReasoningEffortOptions` shows it only `none` / `medium` and `getReasoningEffortLabel` renders that `medium` as **On** — advertising six tiers would imply a granularity it doesn't have. Both helpers are the SSOT shared by `ProviderSettings.tsx` and the in-chat quick-pick in `LeftPanel.tsx`. +## Using a subscription through MCP instead + +Claude.ai (Pro/Max) and ChatGPT sign-ins are not providers here and are not meant to become ones: Anthropic's terms forbid third-party apps offering Claude.ai login or routing requests through a user's plan credentials, and the ChatGPT path is the one removed in 1.8.0. The supported route runs the other way round — the user runs their own Claude Code or Codex, signed in as themselves, and connects it to OpenScreen's local MCP server, which exposes the same tools. See [mcp-server.md](mcp-server.md). + ## Model discovery `aiEditionService.llmListProviderModels(providerId)` resolves the credential, then dispatches per provider: diff --git a/technical-documentation/architecture/mcp-server.md b/technical-documentation/architecture/mcp-server.md new file mode 100644 index 000000000..665e3cb7f --- /dev/null +++ b/technical-documentation/architecture/mcp-server.md @@ -0,0 +1,60 @@ +# 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**. + +| 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/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) and `mcp-token.enc` (bearer token, `safeStorage`). | +| [`src/lib/ai-edition/store/mcpDocumentHost.ts`](../../src/lib/ai-edition/store/mcpDocumentHost.ts) | Renderer side: answers snapshot / apply requests from the project store. Mounted by `NewEditorShell`. | +| [`src/components/ai-edition/McpServerSettings.tsx`](../../src/components/ai-edition/McpServerSettings.tsx) | The settings section: toggle, port, token, copyable `claude mcp add` / `codex mcp add` commands. | + +## One tool surface, two agents + +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. + +The "Project edits" switch (`allowAgentEdits`) applies unchanged: it is read on every call and passed to the executor, which refuses writes when it is off, and the consent block of the system prompt is in the instructions. + +## Where the document comes from + +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). +2. `runDocumentTool` runs against that document. +3. If the tool changed it, `apply(document, revision)` sends it back; the editor applies it through `applyAgentDocumentIfCurrent` — the same revision-guarded apply an in-app chat turn uses — so it is saved and becomes **one undo step**. + +A user edit that lands between 1 and 3 moves the revision and the apply is refused as a conflict; the client is told to re-read. The renderer also compares the project id, because the revision counter restarts at 0 when a project closes and a quick switch to another project could otherwise match. Calls are serialised in the main process so two of them never interleave. + +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). + +## 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. +- Bound to `127.0.0.1` only, endpoint `/mcp`, default port 47821 (configurable, 1024–65535). +- Every request needs `Authorization: Bearer `, compared in constant time. The token is 32 random bytes, stored with `safeStorage`, and read only once the server is enabled, so users who never enable it never meet a Keychain prompt for it. Regenerating it restarts the server, so the old one stops working at once. +- The `Host` must be `127.0.0.1:` or `localhost:`, and a request carrying any other `Origin` is refused — this blocks a web page from reaching the server through DNS rebinding. + +## Lifecycle + +`registerIpcHandlers` builds the `McpController` (it is where the agent's own dependencies — the cursor reader, the LLM config — live) and returns it; **`main.ts` starts it**. The headless CLI shares `registerIpcHandlers` and must never bind the port a running app is listening on, and a bench run has no use for it, so neither calls `startIfEnabled`. + +## Connecting a client + +The settings section shows both commands with the real URL, and copies them with the real token: + +```sh +claude mcp add --transport http openscreen http://127.0.0.1:47821/mcp --header "Authorization: Bearer " + +export OPENSCREEN_MCP_TOKEN= +codex mcp add openscreen --url http://127.0.0.1:47821/mcp --bearer-token-env-var OPENSCREEN_MCP_TOKEN +``` + +## 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 edits switch lives in the provider form.** `allowAgentEdits` is part of `LlmConfig`, so a user with no provider configured cannot turn edits off for MCP clients; it defaults to allowed. diff --git a/website/docs/ai-editing.md b/website/docs/ai-editing.md index a669e395b..46cf26f5e 100644 --- a/website/docs/ai-editing.md +++ b/website/docs/ai-editing.md @@ -58,3 +58,13 @@ The **Smart cuts** entry (marked *With AI*) in the timeline's auto-enhance menu ## What else uses your provider [Caption translation](./captions.md#translation) is a single text-transform call against the same model — it doesn't run the agent loop and can't touch your document. Transcription and caption rendering stay entirely on-device either way. + +## Using Claude Code, Codex or another MCP client + +If you already use an AI coding agent such as Claude Code or Codex, it can drive the same editing tools, signed in with its own account. Nothing about that account passes through OpenScreen. + +1. **AI settings** → **MCP server** → turn it on. It listens only on your own machine (`127.0.0.1`), on the port shown. +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. Each edit is saved as it lands and undone with `Ctrl/Cmd + Z`. The **Project edits** switch applies to MCP clients too. **Regenerate** the token to disconnect every client set up with the old one. From 164e26de383772a1f3564cf8eeee0e3d140c214c Mon Sep 17 00:00:00 2001 From: Davidjayan Date: Tue, 29 Sep 2026 17:44:29 +0530 Subject: [PATCH 2/4] fix(mcp): wrap the connect commands instead of widening the settings dialog The command and token boxes were `white-space: nowrap`, so the field grid's `1fr` column grew to the longest command: the AI settings dialog gained a horizontal scrollbar, the copy buttons moved out of view and the Codex hint was clipped. They now wrap inside the column. Ligatures and contextual alternates are off in those boxes too: Geist Mono drew " --header" without its leading space, so the command on screen did not read like the one that gets copied. Co-Authored-By: Claude Opus 5.5 --- .../ai-edition/McpServerSettings.tsx | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/ai-edition/McpServerSettings.tsx b/src/components/ai-edition/McpServerSettings.tsx index 754445007..f2c59b67d 100644 --- a/src/components/ai-edition/McpServerSettings.tsx +++ b/src/components/ai-edition/McpServerSettings.tsx @@ -21,19 +21,32 @@ export function codexCommand(url: string): string { return `codex mcp add openscreen --url ${url} --bearer-token-env-var ${CODEX_TOKEN_ENV}`; } +// Commands and the token wrap onto as many lines as they need. `nowrap` here made +// the `1fr` grid column as wide as the longest command, which pushed the copy +// buttons out of the dialog and gave the whole modal a horizontal scrollbar. const codeStyle: React.CSSProperties = { flex: 1, minWidth: 0, - overflowX: "auto", - whiteSpace: "nowrap", + whiteSpace: "pre-wrap", + overflowWrap: "anywhere", padding: "6px 8px", borderRadius: 6, background: "var(--bg-2, rgba(127,127,127,0.12))", - font: "12px var(--font-mono)", + font: "12px/1.5 var(--font-mono)", color: "var(--fg-2)", + // A command is copied and typed as-is, so it has to render as-is: Geist Mono's + // contextual alternates drew " --header" as "--header", hiding the space. + fontVariantLigatures: "none", + fontFeatureSettings: '"liga" 0, "calt" 0', + userSelect: "text", }; -const rowStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8 }; +const rowStyle: React.CSSProperties = { + display: "flex", + alignItems: "flex-start", + gap: 8, + minWidth: 0, +}; export function McpServerSettings({ open }: { open: boolean }) { const te = useScopedT("editor"); @@ -244,6 +257,7 @@ function IconButton({