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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 67 additions & 43 deletions electron/ai-edition/deep-agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -218,7 +219,7 @@ export interface CursorTelemetryReader {
probe?(input: { assetId: string; originalPath: string | null }): Promise<boolean>;
}

interface ToolRuntime {
export interface ToolRuntime {
cursor?: CursorTelemetryReader;
availableByAssetId?: Record<string, boolean>;
}
Expand Down Expand Up @@ -268,18 +269,7 @@ function documentTool<S extends z.ZodType>(
return tool(
async (args: z.infer<S>) => {
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;
Expand All @@ -288,6 +278,33 @@ function documentTool<S extends z.ZodType>(
);
}

/**
* 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<AgentToolExecution> {
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(
Expand Down Expand Up @@ -331,37 +348,44 @@ export function buildTools(
editsAllowed = true,
runtime: ToolRuntime = {},
) {
const build = <S extends z.ZodType>(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<readonly [string, z.ZodObject]> = [
["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`).
Expand Down Expand Up @@ -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<Record<string, boolean> | undefined> {
Expand Down
6 changes: 6 additions & 0 deletions electron/electron-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<import("../src/native/contracts").AiEditionMcpHostResponse["result"]>,
) => () => void;
requestCameraAccess: () => Promise<{
success: boolean;
granted: boolean;
Expand Down
22 changes: 22 additions & 0 deletions electron/ipc/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
desktopCapturer,
dialog,
ipcMain,
safeStorage,
screen,
shell,
systemPreferences,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -4836,6 +4840,21 @@ 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 writes have their own switch, off by default and
// read on every call — separate from the in-app agent's "Project edits", so
// turning the server on grants a client read access and nothing more.
const mcpSettings = new McpSettingsStore(app.getPath("userData"), safeStorage);
const mcpController = new McpController(mcpSettings, {
host: new EditorDocumentHost(ipcMain),
editsAllowed: () => mcpSettings.getSettings().allowEdits,
cursor: agentCursorTelemetryReader,
version: app.getVersion(),
});

registerNativeBridgeHandlers({
getPlatform: () => process.platform,
getCurrentProjectPath: () => currentProjectPath,
Expand Down Expand Up @@ -4891,5 +4910,8 @@ export function registerIpcHandlers(
renameAiEditionChatSession: (projectId, sessionId, title) =>
renameSession(projectId, sessionId, title),
deleteAiEditionChatSession: (projectId, sessionId) => deleteSession(projectId, sessionId),
getMcpController: () => mcpController,
});

return { mcpController };
}
82 changes: 82 additions & 0 deletions electron/ipc/nativeBridge.mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// The MCP settings actions take renderer input, so the bridge checks its types
// before anything reaches the settings store: a bad value is an INVALID_REQUEST,
// not an INTERNAL_ERROR thrown further in.

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NativeBridgeResponse } from "../../src/native/contracts";
import { type NativeBridgeContext, registerNativeBridgeHandlers } from "./nativeBridge";

const electron = vi.hoisted(() => ({ handle: vi.fn() }));
vi.mock("electron", () => ({
app: { getAppPath: () => "", isPackaged: false },
ipcMain: { handle: electron.handle, removeHandler: vi.fn() },
shell: {},
}));
vi.mock("../native-bridge/services/compositorViewService", () => ({
CompositorViewService: class {},
}));

const service = vi.hoisted(() => ({
mcpSetEnabled: vi.fn(async () => ({ ok: "enabled" })),
mcpSetPort: vi.fn(async () => ({ ok: "port" })),
mcpSetAllowEdits: vi.fn(async () => ({ ok: "allowEdits" })),
}));
vi.mock("../native-bridge/services/aiEditionService", () => ({
AiEditionService: class {
mcpSetEnabled = service.mcpSetEnabled;
mcpSetPort = service.mcpSetPort;
mcpSetAllowEdits = service.mcpSetAllowEdits;
},
}));

let invoke: (action: string, payload: unknown) => Promise<NativeBridgeResponse>;

beforeEach(() => {
electron.handle.mockClear();
for (const fn of Object.values(service)) fn.mockClear();
registerNativeBridgeHandlers({
getPlatform: () => "linux",
getAiEditionDocuments: () => ({}),
getAiEditionLlmConfig: () => ({}),
} as unknown as NativeBridgeContext);
const handler = electron.handle.mock.calls[0]?.[1] as (
event: unknown,
request: unknown,
) => Promise<NativeBridgeResponse>;
invoke = (action, payload) =>
handler({ sender: {} }, { domain: "aiEdition", action, payload, requestId: "r" });
});

function errorCode(response: NativeBridgeResponse): string | undefined {
return response.ok ? undefined : response.error?.code;
}

describe("native bridge MCP settings", () => {
it.each([80, 70000, 4.5, "47821", null])("rejects port %s as a bad request", async (port) => {
expect(errorCode(await invoke("mcp.setPort", { port }))).toBe("INVALID_REQUEST");
expect(service.mcpSetPort).not.toHaveBeenCalled();
});

it("passes a valid port on", async () => {
const response = await invoke("mcp.setPort", { port: 47821 });
expect(response.ok).toBe(true);
expect(service.mcpSetPort).toHaveBeenCalledWith(47821);
});

it.each(["true", 1, undefined])("rejects enabled=%s as a bad request", async (enabled) => {
expect(errorCode(await invoke("mcp.setEnabled", { enabled }))).toBe("INVALID_REQUEST");
expect(service.mcpSetEnabled).not.toHaveBeenCalled();
});

it.each(["yes", 0, undefined])("rejects allowEdits=%s as a bad request", async (allowEdits) => {
expect(errorCode(await invoke("mcp.setAllowEdits", { allowEdits }))).toBe("INVALID_REQUEST");
expect(service.mcpSetAllowEdits).not.toHaveBeenCalled();
});

it("passes valid flags on", async () => {
await invoke("mcp.setEnabled", { enabled: true });
await invoke("mcp.setAllowEdits", { allowEdits: false });
expect(service.mcpSetEnabled).toHaveBeenCalledWith(true);
expect(service.mcpSetAllowEdits).toHaveBeenCalledWith(false);
});
});
46 changes: 46 additions & 0 deletions electron/ipc/nativeBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import type { ChatEventSink } from "../ai-edition/chat-service";
import type { DocumentService } from "../ai-edition/document-service";
import { StylePresetError, type StylePresetService } from "../ai-edition/style-preset-service";
import { isValidMcpPort } from "../mcp/mcp-settings-store";
import {
type CursorTelemetryLoadResult,
TelemetryCursorAdapter,
Expand Down Expand Up @@ -62,6 +63,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,
Expand Down Expand Up @@ -234,6 +237,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,
Expand Down Expand Up @@ -573,6 +577,48 @@ export function registerNativeBridgeHandlers(context: NativeBridgeContext) {
requestId,
await aiEditionService.llmListProviderModels(request.payload.providerId),
);
case "mcp.getStatus":
return createSuccessResponse(requestId, await aiEditionService.mcpGetStatus());
// Renderer input: check the types here, so a bad value is reported as
// the bad request it is rather than failing later as an internal error.
case "mcp.setEnabled":
if (typeof request.payload?.enabled !== "boolean") {
return createErrorResponse(
requestId,
"INVALID_REQUEST",
"Invalid MCP enabled flag.",
);
}
return createSuccessResponse(
requestId,
await aiEditionService.mcpSetEnabled(request.payload.enabled),
);
case "mcp.setPort":
if (!isValidMcpPort(request.payload?.port)) {
return createErrorResponse(
requestId,
"INVALID_REQUEST",
"MCP port must be a whole number between 1024 and 65535.",
);
}
return createSuccessResponse(
requestId,
await aiEditionService.mcpSetPort(request.payload.port),
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
case "mcp.setAllowEdits":
if (typeof request.payload?.allowEdits !== "boolean") {
return createErrorResponse(
requestId,
"INVALID_REQUEST",
"Invalid MCP edit permission.",
);
}
return createSuccessResponse(
requestId,
await aiEditionService.mcpSetAllowEdits(request.payload.allowEdits),
);
case "mcp.regenerateToken":
return createSuccessResponse(requestId, await aiEditionService.mcpRegenerateToken());
case "chat.run": {
const sessionId = request.payload.sessionId;
const sink = buildChatEventSink(event.sender, sessionId);
Expand Down
5 changes: 4 additions & 1 deletion electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1360,7 +1360,7 @@ appReady?.then(async () => {
showMainWindow();
}

registerIpcHandlers(
const { mcpController } = registerIpcHandlers(
createEditorWindowWrapper,
createSourceSelectorWindowWrapper,
createCountdownOverlayWindowWrapper,
Expand Down Expand Up @@ -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),
);
Expand Down
Loading
Loading