From 570a49fb86c44405878f6915228c3c2abc55022e Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 22:42:37 +0800
Subject: [PATCH 1/6] refactor(app): remove builtin computer control
---
.../ai/providers/codex-mcp-server.test.ts | 4 +-
.../src/electron/mcp/runtime-catalog.test.ts | 1 -
.../electron/tools/computer-control.test.ts | 125 ------
.../src/electron/tools/computer-control.ts | 387 ------------------
packages/app/src/electron/tools/index.ts | 8 -
5 files changed, 2 insertions(+), 523 deletions(-)
delete mode 100644 packages/app/src/electron/tools/computer-control.test.ts
delete mode 100644 packages/app/src/electron/tools/computer-control.ts
diff --git a/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts b/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts
index 19693cc9..1bce6ab8 100644
--- a/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts
+++ b/packages/app/src/electron/ai/providers/codex-mcp-server.test.ts
@@ -15,8 +15,8 @@ describe("Codex image-capable MCP server", () => {
],
}));
const definition: AgentTool = {
- name: "builtin__computer_control",
- qualifiedName: "builtin:computer_control",
+ name: "cua__screenshot",
+ qualifiedName: "cua:screenshot",
description: "Capture the screen",
inputSchema: {
type: "object",
diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts
index a49f1051..c5e77b07 100644
--- a/packages/app/src/electron/mcp/runtime-catalog.test.ts
+++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts
@@ -56,7 +56,6 @@ describe("main-process agent tool catalog", () => {
const context = createModel.mock.calls[0]?.[2];
expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([
"builtin:ask_user_input",
- "builtin:computer_control",
"builtin:execute_command",
"builtin:web_fetch",
]);
diff --git a/packages/app/src/electron/tools/computer-control.test.ts b/packages/app/src/electron/tools/computer-control.test.ts
deleted file mode 100644
index 7584b91b..00000000
--- a/packages/app/src/electron/tools/computer-control.test.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- createComputerControl,
- type ComputerBitmap,
- type ComputerControlDependencies,
- type ComputerRobot,
-} from "./computer-control";
-
-function testRig(
- permissionOverrides: Partial<
- Awaited>
- > = {},
-) {
- const bitmap: ComputerBitmap = {
- width: 1440,
- height: 900,
- image: Buffer.alloc(1440 * 900 * 4),
- byteWidth: 1440 * 4,
- bitsPerPixel: 32,
- bytesPerPixel: 4,
- };
- const robot: ComputerRobot = {
- screen: { capture: vi.fn(() => bitmap) },
- getScreenSize: vi.fn(() => ({ width: 1440, height: 900 })),
- getMousePos: vi.fn(() => ({ x: 100, y: 200 })),
- moveMouse: vi.fn(),
- moveMouseSmooth: vi.fn(),
- mouseClick: vi.fn(),
- mouseToggle: vi.fn(),
- scrollMouse: vi.fn(),
- keyTap: vi.fn(),
- typeString: vi.fn(),
- };
- const dependencies: ComputerControlDependencies = {
- getRobot: vi.fn(async () => robot),
- bitmapToPng: vi.fn(async () => Buffer.from("png")),
- getPermissions: vi.fn(async () => ({
- accessibility: true,
- screenRecording: "granted",
- ...permissionOverrides,
- })),
- wait: vi.fn(async () => undefined),
- };
- const computer = createComputerControl(dependencies);
- const execute = (
- input: Parameters>[0],
- ) =>
- computer.execute!(input, {
- toolCallId: "computer-control-test",
- messages: [],
- });
-
- return { bitmap, computer, dependencies, execute, robot };
-}
-
-describe("computer_control", () => {
- it("returns screenshots as MCP image content with coordinate metadata", async () => {
- const { execute } = testRig();
-
- await expect(execute({ action: "screenshot" })).resolves.toEqual({
- content: [
- {
- type: "text",
- text: JSON.stringify({
- success: true,
- action: "screenshot",
- screen: { width: 1440, height: 900 },
- cursor: { x: 100, y: 200 },
- coordinateSpace: "Screenshot top-left is [0, 0].",
- }),
- },
- {
- type: "image",
- data: Buffer.from("png").toString("base64"),
- mimeType: "image/png",
- },
- ],
- });
- });
-
- it("executes clicks at validated screenshot coordinates", async () => {
- const { execute, robot } = testRig();
-
- await execute({ action: "double_click", coordinate: [320, 240] });
-
- expect(robot.moveMouse).toHaveBeenCalledWith(320, 240);
- expect(robot.mouseClick).toHaveBeenCalledWith("left", true);
- });
-
- it("parses portable key shortcuts", async () => {
- const { execute, robot } = testRig();
-
- await execute({ action: "key", key: "CMD+SHIFT+P" });
-
- expect(robot.keyTap).toHaveBeenCalledWith("p", ["command", "shift"]);
- });
-
- it("releases the mouse if a drag fails", async () => {
- const { execute, robot } = testRig();
- vi.mocked(robot.moveMouseSmooth).mockImplementation(() => {
- throw new Error("drag failed");
- });
-
- await expect(
- execute({
- action: "left_click_drag",
- start_coordinate: [10, 20],
- coordinate: [30, 40],
- }),
- ).rejects.toThrow("drag failed");
- expect(robot.mouseToggle).toHaveBeenNthCalledWith(1, "down", "left");
- expect(robot.mouseToggle).toHaveBeenNthCalledWith(2, "up", "left");
- });
-
- it("returns actionable validation and permission errors", async () => {
- const { execute } = testRig({ accessibility: false });
-
- await expect(execute({ action: "left_click" })).rejects.toThrow(
- "requires 'coordinate'",
- );
- await expect(
- execute({ action: "left_click", coordinate: [2000, 10] }),
- ).rejects.toThrow("ACCESSIBILITY_PERMISSION_REQUIRED");
- });
-});
diff --git a/packages/app/src/electron/tools/computer-control.ts b/packages/app/src/electron/tools/computer-control.ts
deleted file mode 100644
index 2964fa4c..00000000
--- a/packages/app/src/electron/tools/computer-control.ts
+++ /dev/null
@@ -1,387 +0,0 @@
-import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
-import { tool } from "ai";
-import { z } from "zod";
-
-type MouseButton = "left" | "right" | "middle";
-type KeyModifier = "alt" | "command" | "control" | "shift";
-
-export interface ComputerBitmap {
- width: number;
- height: number;
- image: Buffer;
- byteWidth: number;
- bitsPerPixel: number;
- bytesPerPixel: number;
-}
-
-export interface ComputerRobot {
- screen: {
- capture(
- x?: number,
- y?: number,
- width?: number,
- height?: number,
- ): ComputerBitmap;
- };
- getScreenSize(): { width: number; height: number };
- getMousePos(): { x: number; y: number };
- moveMouse(x: number, y: number): void;
- moveMouseSmooth(x: number, y: number): void;
- mouseClick(button?: MouseButton, double?: boolean): void;
- mouseToggle(state: "up" | "down", button?: MouseButton): void;
- scrollMouse(x: number, y: number): void;
- keyTap(key: string, modifier?: KeyModifier | KeyModifier[]): void;
- typeString(text: string): void;
-}
-
-export interface ComputerControlDependencies {
- getRobot(): Promise;
- bitmapToPng(bitmap: ComputerBitmap): Promise;
- getPermissions(): Promise<{
- accessibility: boolean | "unsupported";
- screenRecording: string | "unsupported";
- }>;
- wait(durationMs: number): Promise;
-}
-
-const coordinateSchema = z
- .tuple([z.number().int().nonnegative(), z.number().int().nonnegative()])
- .describe("Absolute [x, y] coordinates in the screenshot coordinate space");
-
-const computerControlInput = z.object({
- action: z
- .enum([
- "screenshot",
- "mouse_move",
- "left_click",
- "right_click",
- "middle_click",
- "double_click",
- "left_click_drag",
- "type",
- "key",
- "scroll",
- "wait",
- ])
- .describe("One atomic computer action to execute"),
- coordinate: coordinateSchema.optional(),
- start_coordinate: coordinateSchema
- .optional()
- .describe("Drag start [x, y]; required for left_click_drag"),
- text: z
- .string()
- .max(20000)
- .optional()
- .describe("Text to type; required for the type action"),
- key: z
- .string()
- .max(100)
- .optional()
- .describe("Key or shortcut such as ENTER, CMD+L, or CTRL+SHIFT+P"),
- scroll_x: z
- .number()
- .int()
- .min(-10000)
- .max(10000)
- .optional()
- .describe("Horizontal scroll amount; negative scrolls left"),
- scroll_y: z
- .number()
- .int()
- .min(-10000)
- .max(10000)
- .optional()
- .describe("Vertical scroll amount; negative scrolls down"),
- duration_ms: z
- .number()
- .int()
- .min(0)
- .max(10000)
- .optional()
- .describe("Wait duration in milliseconds; required for wait"),
-});
-
-type ComputerControlInput = z.infer;
-
-const actionRequirements: Partial<
- Record>
-> = {
- mouse_move: ["coordinate"],
- left_click: ["coordinate"],
- right_click: ["coordinate"],
- middle_click: ["coordinate"],
- double_click: ["coordinate"],
- left_click_drag: ["start_coordinate", "coordinate"],
- type: ["text"],
- key: ["key"],
- wait: ["duration_ms"],
-};
-
-function validateActionInput(input: ComputerControlInput): void {
- for (const field of actionRequirements[input.action] ?? []) {
- if (input[field] === undefined) {
- throw new Error(
- `INVALID_COMPUTER_ACTION: '${input.action}' requires '${field}'. Add the missing field and retry.`,
- );
- }
- }
-
- if (
- input.action === "scroll" &&
- input.scroll_x === undefined &&
- input.scroll_y === undefined
- ) {
- throw new Error(
- "INVALID_COMPUTER_ACTION: 'scroll' requires scroll_x or scroll_y. Add a non-zero scroll amount and retry.",
- );
- }
-}
-
-function validateCoordinate(
- coordinate: [number, number],
- screenSize: { width: number; height: number },
-): void {
- const [x, y] = coordinate;
- if (x >= screenSize.width || y >= screenSize.height) {
- throw new Error(
- `COORDINATE_OUT_OF_BOUNDS: [${x}, ${y}] is outside ${screenSize.width}x${screenSize.height}. Take a new screenshot and retry with coordinates inside it.`,
- );
- }
-}
-
-function parseKeyShortcut(value: string): {
- key: string;
- modifiers?: KeyModifier[];
-} {
- const parts = value
- .split("+")
- .map((part) => part.trim().toLowerCase())
- .filter(Boolean);
- const key = parts.pop();
- if (!key) {
- throw new Error(
- "INVALID_KEY: provide a key such as ENTER, CMD+L, or CTRL+SHIFT+P.",
- );
- }
-
- const aliases: Record = {
- alt: "alt",
- option: "alt",
- cmd: "command",
- command: "command",
- meta: "command",
- ctrl: "control",
- control: "control",
- shift: "shift",
- };
- const modifiers = parts.map((part) => aliases[part]);
- const invalidModifier = parts.find((_, index) => !modifiers[index]);
- if (invalidModifier) {
- throw new Error(
- `INVALID_KEY_MODIFIER: '${invalidModifier}' is unsupported. Use CMD, CTRL, ALT/OPTION, or SHIFT.`,
- );
- }
-
- return {
- key,
- modifiers: modifiers.length ? modifiers : undefined,
- };
-}
-
-function textResult(
- input: ComputerControlInput,
- robot: ComputerRobot,
-): CallToolResult {
- const screen = robot.getScreenSize();
- const cursor = robot.getMousePos();
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- success: true,
- action: input.action,
- screen,
- cursor,
- }),
- },
- ],
- };
-}
-
-async function screenshotResult(
- robot: ComputerRobot,
- bitmapToPng: ComputerControlDependencies["bitmapToPng"],
-): Promise {
- const bitmap = robot.screen.capture();
- const png = await bitmapToPng(bitmap);
- const cursor = robot.getMousePos();
- return {
- content: [
- {
- type: "text",
- text: JSON.stringify({
- success: true,
- action: "screenshot",
- screen: { width: bitmap.width, height: bitmap.height },
- cursor,
- coordinateSpace: "Screenshot top-left is [0, 0].",
- }),
- },
- {
- type: "image",
- data: png.toString("base64"),
- mimeType: "image/png",
- },
- ],
- };
-}
-
-function assertPermission(
- action: ComputerControlInput["action"],
- permissions: Awaited<
- ReturnType
- >,
-): void {
- if (action === "screenshot") {
- if (
- permissions.screenRecording !== "granted" &&
- permissions.screenRecording !== "unsupported"
- ) {
- throw new Error(
- `SCREEN_RECORDING_PERMISSION_REQUIRED: current status is '${permissions.screenRecording}'. Grant Convera Screen Recording permission in system settings, restart Convera, and retry.`,
- );
- }
- return;
- }
-
- if (action !== "wait" && permissions.accessibility === false) {
- throw new Error(
- "ACCESSIBILITY_PERMISSION_REQUIRED: grant Convera Accessibility permission in system settings, restart Convera, and retry.",
- );
- }
-}
-
-async function defaultDependencies(): Promise {
- return {
- getRobot: async () =>
- (await import("../../shared/robot.js"))
- .default as unknown as ComputerRobot,
- bitmapToPng: async (bitmap) => {
- if (
- bitmap.bitsPerPixel !== 32 ||
- bitmap.bytesPerPixel !== 4 ||
- bitmap.byteWidth !== bitmap.width * 4
- ) {
- throw new Error(
- `UNSUPPORTED_SCREEN_BITMAP: expected packed 32-bit pixels, received ${bitmap.bitsPerPixel}-bit with byte width ${bitmap.byteWidth}.`,
- );
- }
- const { nativeImage } = await import("electron");
- const image = nativeImage.createFromBitmap(Buffer.from(bitmap.image), {
- width: bitmap.width,
- height: bitmap.height,
- scaleFactor: 1,
- });
- const png = image.toPNG();
- if (png.length === 0) {
- throw new Error(
- "SCREENSHOT_ENCODING_FAILED: Electron could not encode the captured display. Retry after checking Screen Recording permission.",
- );
- }
- return png;
- },
- getPermissions: async () => {
- if (process.platform !== "darwin") {
- return {
- accessibility: "unsupported",
- screenRecording: "unsupported",
- };
- }
- const { systemPreferences } = await import("electron");
- return {
- accessibility: systemPreferences.isTrustedAccessibilityClient(false),
- screenRecording: systemPreferences.getMediaAccessStatus("screen"),
- };
- },
- wait: (durationMs) =>
- new Promise((resolve) => setTimeout(resolve, durationMs)),
- };
-}
-
-export function createComputerControl(
- dependencies?: ComputerControlDependencies,
-) {
- return tool({
- description:
- "Observe and control the user's real desktop with one atomic action. Use screenshot first and again after actions when visual confirmation is needed. Coordinates use the latest screenshot's top-left as [0, 0]. Supported actions are screenshot, mouse_move, left_click, right_click, middle_click, double_click, left_click_drag, type, key, scroll, and wait. Returns JSON state for actions and an MCP image plus screen dimensions for screenshots. Desktop contents may be sensitive and every call requires user approval.",
- inputSchema: computerControlInput,
- execute: async (input) => {
- validateActionInput(input);
- const resolvedDependencies =
- dependencies ?? (await defaultDependencies());
- const permissions = await resolvedDependencies.getPermissions();
- assertPermission(input.action, permissions);
- const robot = await resolvedDependencies.getRobot();
-
- if (input.action === "screenshot") {
- return screenshotResult(robot, resolvedDependencies.bitmapToPng);
- }
-
- const screenSize = robot.getScreenSize();
- if (input.coordinate) {
- validateCoordinate(input.coordinate, screenSize);
- }
- if (input.start_coordinate) {
- validateCoordinate(input.start_coordinate, screenSize);
- }
-
- switch (input.action) {
- case "mouse_move":
- robot.moveMouse(...input.coordinate!);
- break;
- case "left_click":
- case "right_click":
- case "middle_click":
- case "double_click": {
- robot.moveMouse(...input.coordinate!);
- const button =
- input.action === "right_click"
- ? "right"
- : input.action === "middle_click"
- ? "middle"
- : "left";
- robot.mouseClick(button, input.action === "double_click");
- break;
- }
- case "left_click_drag":
- robot.moveMouse(...input.start_coordinate!);
- robot.mouseToggle("down", "left");
- try {
- robot.moveMouseSmooth(...input.coordinate!);
- } finally {
- robot.mouseToggle("up", "left");
- }
- break;
- case "type":
- robot.typeString(input.text!);
- break;
- case "key": {
- const shortcut = parseKeyShortcut(input.key!);
- robot.keyTap(shortcut.key, shortcut.modifiers);
- break;
- }
- case "scroll":
- robot.scrollMouse(input.scroll_x ?? 0, input.scroll_y ?? 0);
- break;
- case "wait":
- await resolvedDependencies.wait(input.duration_ms!);
- break;
- }
-
- return textResult(input, robot);
- },
- });
-}
-
-export const computerControl = createComputerControl();
diff --git a/packages/app/src/electron/tools/index.ts b/packages/app/src/electron/tools/index.ts
index 330864a1..40e1051c 100644
--- a/packages/app/src/electron/tools/index.ts
+++ b/packages/app/src/electron/tools/index.ts
@@ -5,13 +5,11 @@
*/
import { askUserInput } from "./ask-user-input";
-import { computerControl } from "./computer-control";
import { executeCommand } from "./execute-command";
import { webFetch } from "./web-fetch";
export const builtinTools = {
askUserInput,
- computerControl,
executeCommand,
webFetch,
};
@@ -22,7 +20,6 @@ export const builtinTools = {
*/
export const BUILTIN_TOOLS_REGISTRY = {
ask_user_input: askUserInput,
- computer_control: computerControl,
execute_command: executeCommand,
web_fetch: webFetch,
} as const;
@@ -33,11 +30,6 @@ export const BUILTIN_TOOL_ANNOTATIONS = {
destructiveHint: false,
openWorldHint: false,
},
- computer_control: {
- readOnlyHint: false,
- destructiveHint: true,
- openWorldHint: true,
- },
execute_command: {
readOnlyHint: false,
destructiveHint: true,
From e4f8eef730e66051f758b54b0629f95bb086a17f Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 22:49:04 +0800
Subject: [PATCH 2/6] feat(app): manage Cua as the default MCP server
---
packages/app/src/electron/mcp/connection.ts | 95 ++++++++++++++++--
packages/app/src/electron/mcp/hub.ts | 96 ++++++++++++-------
.../app/src/electron/mcp/managed-servers.ts | 49 ++++++++++
.../components/settings/pages/mcp-page.tsx | 81 ++++++++++++----
.../app/src/renderer/libs/stores/mcp-store.ts | 4 +
packages/app/src/shared/types/mcp.ts | 2 +
6 files changed, 273 insertions(+), 54 deletions(-)
create mode 100644 packages/app/src/electron/mcp/managed-servers.ts
diff --git a/packages/app/src/electron/mcp/connection.ts b/packages/app/src/electron/mcp/connection.ts
index c14f4344..00286898 100644
--- a/packages/app/src/electron/mcp/connection.ts
+++ b/packages/app/src/electron/mcp/connection.ts
@@ -26,6 +26,7 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { app } from "electron";
import { EventEmitter } from "events";
+import * as fs from "fs";
import { z } from "zod";
import * as path from "path";
import * as os from "os";
@@ -244,6 +245,13 @@ export class MCPConnection extends EventEmitter {
return typeof entry[1] === "string";
}),
);
+ if (this.config.managed && actualCommand === "cua-driver") {
+ actualCommand = this.resolveStdioExecutable(
+ actualCommand,
+ resolvedConfig.cwd || app.getPath("userData"),
+ environment,
+ );
+ }
const transport = new StdioClientTransport({
command: actualCommand,
args: resolvedConfig.args || [],
@@ -273,12 +281,11 @@ export class MCPConnection extends EventEmitter {
console.log(`MCP server '${this.name}' connected successfully`);
} catch (error) {
console.error(`Failed to connect MCP server '${this.name}':`, error);
- await this.disconnect(
- error instanceof Error ? error.message : String(error),
- );
+ const errorMessage = this.connectionErrorMessage(error);
+ await this.disconnect(errorMessage);
const err = new Error(
- `Failed to connect to "${this.name}" MCP server: ${error}`,
+ `Failed to connect to "${this.name}" MCP server: ${errorMessage}`,
) as ConnectionError;
err.code = "CONNECTION_ERROR";
err.data = { server: this.name, error: String(error) };
@@ -314,7 +321,9 @@ export class MCPConnection extends EventEmitter {
this.resourceTemplates = [];
this.status = this.disabled
? ConnectionStatus.DISABLED
- : ConnectionStatus.DISCONNECTED;
+ : errorMessage
+ ? ConnectionStatus.ERROR
+ : ConnectionStatus.DISCONNECTED;
this.error = errorMessage || null;
this.startTime = null;
this.authorizationUrl = undefined;
@@ -337,7 +346,7 @@ export class MCPConnection extends EventEmitter {
*/
private handleTransportError(error: Error): void {
console.debug(`MCP transport error for ${this.name}:`, error.message);
- this.emit("error", { server: this.name, error });
+ this.emit("connectionError", { server: this.name, error });
}
/**
@@ -665,9 +674,83 @@ export class MCPConnection extends EventEmitter {
lastStarted: this.lastStarted || undefined,
authorizationUrl: this.authorizationUrl,
isApp: this.config.isApp,
+ managed: this.config.managed,
};
}
+ private connectionErrorMessage(error: unknown): string {
+ const message = error instanceof Error ? error.message : String(error);
+ if (
+ this.config.managed &&
+ (message.includes("ENOENT") || message.includes("not found"))
+ ) {
+ return `Managed Cua MCP is unavailable: ${message} Install Cua Driver from https://cua.ai/docs/how-to-guides/driver/installation or disable the 'cua' server.`;
+ }
+
+ return message;
+ }
+
+ private resolveStdioExecutable(
+ command: string,
+ cwd: string,
+ environment: Record,
+ ): string {
+ const hasPathSeparator =
+ command.includes(path.sep) ||
+ (path.sep === "\\" && command.includes("/"));
+ const searchDirectories = hasPathSeparator
+ ? [cwd]
+ : [
+ ...(environment.PATH || environment.Path || "")
+ .split(path.delimiter)
+ .filter(Boolean),
+ ...(command === "cua-driver"
+ ? [
+ path.join(os.homedir(), ".local", "bin"),
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ ]
+ : []),
+ ];
+ const extensions =
+ process.platform === "win32" && !path.extname(command)
+ ? (environment.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
+ : [""];
+ const candidates = hasPathSeparator
+ ? [
+ path.isAbsolute(command) ? command : path.resolve(cwd, command),
+ ...extensions
+ .filter(Boolean)
+ .map(
+ (extension) =>
+ `${path.isAbsolute(command) ? command : path.resolve(cwd, command)}${extension.toLowerCase()}`,
+ ),
+ ]
+ : searchDirectories.flatMap((directory) =>
+ extensions.map((extension) =>
+ path.join(directory, `${command}${extension.toLowerCase()}`),
+ ),
+ );
+
+ const executable = candidates.find((candidate) => {
+ try {
+ fs.accessSync(candidate, fs.constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+ if (!executable) {
+ throw new Error(
+ hasPathSeparator
+ ? `executable '${command}' was not found`
+ : `executable '${command}' was not found on PATH`,
+ );
+ }
+
+ return executable;
+ }
+
/**
* Resolve environment variables in configuration (Electron-optimized)
*/
diff --git a/packages/app/src/electron/mcp/hub.ts b/packages/app/src/electron/mcp/hub.ts
index 4e2b8ad7..c07e7bee 100644
--- a/packages/app/src/electron/mcp/hub.ts
+++ b/packages/app/src/electron/mcp/hub.ts
@@ -11,6 +11,11 @@ import * as path from "path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { BUILTIN_TOOL_ANNOTATIONS, BUILTIN_TOOLS_REGISTRY } from "../tools";
import { MCPConnection } from "./connection";
+import {
+ CUA_SERVER_ID,
+ normalizeManagedServer,
+ withManagedServers,
+} from "./managed-servers";
/**
* Generic wrapper for builtin tool calls
@@ -53,7 +58,8 @@ export class MCPHub extends EventEmitter {
}
// Load configuration
- this.config = this.loadConfig();
+ this.config = withManagedServers(this.loadConfig());
+ this.saveConfig();
}
/**
@@ -182,13 +188,11 @@ export class MCPHub extends EventEmitter {
// Start all connections concurrently without waiting for completion
// This avoids blocking initialization on slow/failing connections
- servers
- .filter(([, serverConfig]) => serverConfig.disabled !== true)
- .forEach(([name, serverConfig]) => {
- this.connectServer(name, serverConfig).catch((error) => {
- console.error(`✗ Failed to connect MCP server ${name}:`, error);
- });
+ servers.forEach(([name, serverConfig]) => {
+ this.connectServer(name, serverConfig).catch((error) => {
+ console.error(`✗ Failed to connect MCP server ${name}:`, error);
});
+ });
console.log(`MCP initialization started for ${servers.length} servers`);
}
@@ -207,14 +211,7 @@ export class MCPHub extends EventEmitter {
const connection = new MCPConnection(name, config);
- // Forward events
- connection.on("toolsChanged", (data) => this.emit("toolsChanged", data));
- connection.on("resourcesChanged", (data) =>
- this.emit("resourcesChanged", data),
- );
- connection.on("promptsChanged", (data) =>
- this.emit("promptsChanged", data),
- );
+ this.attachConnectionEvents(connection);
this.connections.set(name, connection);
await connection.connect();
@@ -287,14 +284,22 @@ export class MCPHub extends EventEmitter {
* Add new server
*/
async addServer(name: string, config: MCPServerConfig): Promise {
- this.config.mcpServers[name] = config;
+ if (name === CUA_SERVER_ID) {
+ throw new Error(
+ `Server '${CUA_SERVER_ID}' is managed by Convera and cannot be replaced`,
+ );
+ }
+
+ const normalizedConfig = normalizeManagedServer(name, config);
+ this.config.mcpServers[name] = normalizedConfig;
this.saveConfig();
- if (config.disabled !== true) {
- return await this.connectServer(name, config);
+ if (normalizedConfig.disabled !== true) {
+ return await this.connectServer(name, normalizedConfig);
} else {
// Create connection but don't start
- const connection = new MCPConnection(name, config);
+ const connection = new MCPConnection(name, normalizedConfig);
+ this.attachConnectionEvents(connection);
this.connections.set(name, connection);
return connection.getServerInfo();
}
@@ -304,6 +309,12 @@ export class MCPHub extends EventEmitter {
* Remove server
*/
async removeServer(name: string): Promise {
+ if (name === CUA_SERVER_ID) {
+ throw new Error(
+ `Server '${name}' is managed by Convera and cannot be removed; disable it instead`,
+ );
+ }
+
await this.disconnectServer(name);
delete this.config.mcpServers[name];
this.saveConfig();
@@ -316,23 +327,31 @@ export class MCPHub extends EventEmitter {
name: string,
config: MCPServerConfig,
): Promise {
- this.config.mcpServers[name] = config;
+ const normalizedConfig = normalizeManagedServer(name, config);
+ this.config.mcpServers[name] = normalizedConfig;
this.saveConfig();
- const connection = this.connections.get(name);
- if (connection) {
+ await this.disconnectServer(name);
+ const connection = new MCPConnection(name, normalizedConfig);
+ this.attachConnectionEvents(connection);
+ this.connections.set(name, connection);
+
+ if (normalizedConfig.disabled === true) {
+ await connection.connect();
return connection.getServerInfo();
- } else {
- // If no connection exists, create one if enabled
- if (config.disabled !== true) {
- return await this.connectServer(name, config);
- } else {
- // Create connection but don't start
- const connection = new MCPConnection(name, config);
- this.connections.set(name, connection);
- return connection.getServerInfo();
+ }
+
+ try {
+ await connection.connect();
+ } catch (error) {
+ if (!normalizedConfig.managed) {
+ throw error;
}
+ return connection.getServerInfo();
}
+
+ this.updateToolCache();
+ return connection.getServerInfo();
}
/**
@@ -483,7 +502,7 @@ export class MCPHub extends EventEmitter {
* Update configuration
*/
async updateConfig(newConfig: MCPConfig): Promise {
- this.config = newConfig;
+ this.config = withManagedServers(newConfig);
this.saveConfig();
}
@@ -527,6 +546,19 @@ export class MCPHub extends EventEmitter {
return this.connections.has(name);
}
+ private attachConnectionEvents(connection: MCPConnection): void {
+ connection.on("toolsChanged", (data) => this.emit("toolsChanged", data));
+ connection.on("resourcesChanged", (data) =>
+ this.emit("resourcesChanged", data),
+ );
+ connection.on("promptsChanged", (data) =>
+ this.emit("promptsChanged", data),
+ );
+ connection.on("connectionError", (data) =>
+ this.emit("connectionError", data),
+ );
+ }
+
/**
* Get the number of connected servers
*/
diff --git a/packages/app/src/electron/mcp/managed-servers.ts b/packages/app/src/electron/mcp/managed-servers.ts
new file mode 100644
index 00000000..c78fe0c3
--- /dev/null
+++ b/packages/app/src/electron/mcp/managed-servers.ts
@@ -0,0 +1,49 @@
+import type { MCPConfig, MCPServerConfig } from "@/shared/types/mcp";
+
+export const CUA_SERVER_ID = "cua";
+
+export const CUA_SERVER_CONFIG: Readonly = {
+ name: "Cua",
+ description: "Convera-managed Cua Driver computer-use tools",
+ command: "cua-driver",
+ args: ["mcp"],
+ managed: true,
+};
+
+export function withManagedServers(config: MCPConfig): MCPConfig {
+ const configuredServers = config.mcpServers ?? {};
+ const existingCua = configuredServers[CUA_SERVER_ID];
+ const normalizedServers = Object.fromEntries(
+ Object.entries(configuredServers).map(([name, serverConfig]) => [
+ name,
+ normalizeManagedServer(name, serverConfig),
+ ]),
+ );
+
+ return {
+ ...config,
+ mcpServers: {
+ ...normalizedServers,
+ [CUA_SERVER_ID]: {
+ ...CUA_SERVER_CONFIG,
+ disabled: existingCua?.disabled === true,
+ },
+ },
+ };
+}
+
+export function normalizeManagedServer(
+ name: string,
+ config: MCPServerConfig,
+): MCPServerConfig {
+ if (name !== CUA_SERVER_ID) {
+ const userConfig = { ...config };
+ delete userConfig.managed;
+ return userConfig;
+ }
+
+ return {
+ ...CUA_SERVER_CONFIG,
+ disabled: config.disabled === true,
+ };
+}
diff --git a/packages/app/src/renderer/components/settings/pages/mcp-page.tsx b/packages/app/src/renderer/components/settings/pages/mcp-page.tsx
index e116c0f9..8bc97906 100644
--- a/packages/app/src/renderer/components/settings/pages/mcp-page.tsx
+++ b/packages/app/src/renderer/components/settings/pages/mcp-page.tsx
@@ -35,6 +35,7 @@ export function McpSettingsPage() {
mcpServers,
loadingMcpServers,
handleManualInstallMcp,
+ handleMcpConfigChange,
handleRemoveServer,
refreshAll: refreshMcpData,
subscribeMcpChanges,
@@ -49,6 +50,9 @@ export function McpSettingsPage() {
const [restartingServers, setRestartingServers] = useState>(
new Set(),
);
+ const [updatingServers, setUpdatingServers] = useState>(
+ new Set(),
+ );
useEffect(() => {
const mcpStore = useMcpStore.getState();
@@ -60,7 +64,7 @@ export function McpSettingsPage() {
return () => {
unsubscribe();
};
- }, []);
+ }, [subscribeMcpChanges]);
const handleOpenManualDialog = () => {
setShowManualConfigDialog(true);
@@ -112,8 +116,11 @@ export function McpSettingsPage() {
const handleRestartServer = async (serverId: string) => {
setRestartingServers((prev) => new Set([...prev, serverId]));
try {
- await new Promise((resolve) => setTimeout(resolve, 2000));
- refreshMcpData();
+ const response = await window.mcpAPI.startServer(serverId);
+ if (!response.success) {
+ throw new Error(response.error || "Failed to restart server");
+ }
+ await refreshMcpData();
} catch (error) {
console.error("Failed to restart server:", error);
} finally {
@@ -125,6 +132,22 @@ export function McpSettingsPage() {
}
};
+ const handleManagedServerToggle = async (
+ serverId: string,
+ disabled: boolean,
+ ) => {
+ setUpdatingServers((prev) => new Set([...prev, serverId]));
+ try {
+ await handleMcpConfigChange(serverId, "disabled", disabled);
+ } finally {
+ setUpdatingServers((prev) => {
+ const newSet = new Set(prev);
+ newSet.delete(serverId);
+ return newSet;
+ });
+ }
+ };
+
const StatusIndicator = ({ status }: { status: string }) => {
const getStatusConfig = () => {
switch (status) {
@@ -271,9 +294,33 @@ export function McpSettingsPage() {
{server.description}
)}
+ {server.error && (
+
+ {server.error}
+
+ )}
+ {server.managed && (
+
+ )}
{(server.status === ConnectionStatus.ERROR ||
server.status === ConnectionStatus.DISCONNECTED) &&
!isRestarting && (
@@ -298,19 +345,21 @@ export function McpSettingsPage() {
Restarting
)}
-
+ {!server.managed && (
+
+ )}
diff --git a/packages/app/src/renderer/libs/stores/mcp-store.ts b/packages/app/src/renderer/libs/stores/mcp-store.ts
index 2f90e02d..64f168c4 100644
--- a/packages/app/src/renderer/libs/stores/mcp-store.ts
+++ b/packages/app/src/renderer/libs/stores/mcp-store.ts
@@ -158,6 +158,10 @@ export const useMcpStore = create()(
toast.success(
`Configuration for ${currentConfig.name || id} saved`,
);
+ await Promise.all([
+ get().fetchMcpConfigurations(),
+ get().fetchAllMcpServers(),
+ ]);
} catch (error) {
console.error(
`Error updating MCP server configuration ${id}:`,
diff --git a/packages/app/src/shared/types/mcp.ts b/packages/app/src/shared/types/mcp.ts
index a1c3f156..234f2286 100644
--- a/packages/app/src/shared/types/mcp.ts
+++ b/packages/app/src/shared/types/mcp.ts
@@ -27,6 +27,7 @@ export interface MCPServerConfig {
apiKey?: string;
description?: string;
isApp?: boolean;
+ managed?: boolean;
}
export interface MCPConfig {
@@ -84,6 +85,7 @@ export interface ServerInfo {
lastStarted?: string;
authorizationUrl?: string;
isApp?: boolean;
+ managed?: boolean;
}
export interface ConnectionError extends Error {
From 6f5f151eddbb5847a43b4623c329e90e4d1e9b2b Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 22:49:12 +0800
Subject: [PATCH 3/6] test(app): cover managed Cua MCP lifecycle
---
.../electron/mcp/__fixtures__/cua-driver.mjs | 22 +++
packages/app/src/electron/mcp/hub.test.ts | 185 ++++++++++++++++++
.../src/electron/mcp/runtime-catalog.test.ts | 125 +++++++-----
3 files changed, 286 insertions(+), 46 deletions(-)
create mode 100644 packages/app/src/electron/mcp/__fixtures__/cua-driver.mjs
create mode 100644 packages/app/src/electron/mcp/hub.test.ts
diff --git a/packages/app/src/electron/mcp/__fixtures__/cua-driver.mjs b/packages/app/src/electron/mcp/__fixtures__/cua-driver.mjs
new file mode 100644
index 00000000..2d8c4005
--- /dev/null
+++ b/packages/app/src/electron/mcp/__fixtures__/cua-driver.mjs
@@ -0,0 +1,22 @@
+#!/usr/bin/env node
+
+import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
+import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
+import { z } from "zod";
+
+const server = new McpServer({ name: "cua-driver-test", version: "1.0.0" });
+
+server.registerTool(
+ "screenshot",
+ {
+ description: "Capture the current screen",
+ inputSchema: {
+ label: z.string().optional(),
+ },
+ },
+ async ({ label }) => ({
+ content: [{ type: "text", text: label || "screen" }],
+ }),
+);
+
+await server.connect(new StdioServerTransport());
diff --git a/packages/app/src/electron/mcp/hub.test.ts b/packages/app/src/electron/mcp/hub.test.ts
new file mode 100644
index 00000000..e4b94e0a
--- /dev/null
+++ b/packages/app/src/electron/mcp/hub.test.ts
@@ -0,0 +1,185 @@
+import { ConnectionStatus } from "@/shared/types/mcp";
+import {
+ chmodSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { MCPHub } from "./hub";
+
+vi.mock("electron", () => ({
+ app: {
+ getAppPath: () => "/test/convera",
+ getPath: () => tmpdir(),
+ getVersion: () => "0.0.0-test",
+ },
+}));
+
+const tempDirectories: string[] = [];
+
+function configPath(): string {
+ const directory = mkdtempSync(join(tmpdir(), "convera-cua-mcp-"));
+ tempDirectories.push(directory);
+ return join(directory, "mcp.json");
+}
+
+afterEach(() => {
+ for (const directory of tempDirectories.splice(0)) {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+describe("managed Cua MCP server", () => {
+ it("adds the official cua-driver stdio server to the default config", () => {
+ const path = configPath();
+ const hub = new MCPHub(path);
+
+ expect(hub.getConfig().mcpServers.cua).toEqual({
+ name: "Cua",
+ description: "Convera-managed Cua Driver computer-use tools",
+ command: "cua-driver",
+ args: ["mcp"],
+ managed: true,
+ disabled: false,
+ });
+ expect(JSON.parse(readFileSync(path, "utf8"))).toEqual(hub.getConfig());
+ });
+
+ it("preserves only the user's disabled choice for the managed server", async () => {
+ const path = configPath();
+ writeFileSync(
+ path,
+ JSON.stringify({
+ mcpServers: {
+ cua: {
+ command: "legacy-computer-control",
+ args: ["fallback"],
+ disabled: true,
+ },
+ },
+ }),
+ );
+ const hub = new MCPHub(path);
+
+ expect(hub.getConfig().mcpServers.cua).toMatchObject({
+ command: "cua-driver",
+ args: ["mcp"],
+ managed: true,
+ disabled: true,
+ });
+
+ const status = await hub.updateServer("cua", {
+ command: "another-driver",
+ disabled: true,
+ });
+ expect(status).toMatchObject({
+ name: "cua",
+ status: ConnectionStatus.DISABLED,
+ managed: true,
+ });
+ expect(hub.getConfig().mcpServers.cua.command).toBe("cua-driver");
+ });
+
+ it("protects the managed server from replacement and removal", async () => {
+ const hub = new MCPHub(configPath());
+
+ await expect(
+ hub.addServer("cua", { command: "replacement" }),
+ ).rejects.toThrow("managed by Convera");
+ await expect(hub.removeServer("cua")).rejects.toThrow(
+ "cannot be removed; disable it instead",
+ );
+ expect(hub.getConfig().mcpServers.cua).toBeDefined();
+
+ await hub.addServer("custom", {
+ command: "custom-server",
+ disabled: true,
+ managed: true,
+ });
+ expect(hub.getConfig().mcpServers.custom.managed).toBeUndefined();
+ await expect(hub.removeServer("custom")).resolves.toBeUndefined();
+ });
+
+ it("does not block startup and reports a clear error when cua-driver is missing", async () => {
+ const hub = new MCPHub(configPath());
+ const originalPath = process.env.PATH;
+ process.env.PATH = mkdtempSync(join(tmpdir(), "convera-empty-path-"));
+ tempDirectories.push(process.env.PATH);
+
+ try {
+ await expect(hub.initialize()).resolves.toBeUndefined();
+ await vi.waitFor(() => {
+ expect(hub.getServerStatus("cua")).toMatchObject({
+ status: ConnectionStatus.ERROR,
+ error: expect.stringContaining(
+ "Managed Cua MCP is unavailable: executable 'cua-driver' was not found on PATH",
+ ),
+ managed: true,
+ });
+ });
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ await hub.cleanup();
+ }
+ });
+
+ it("connects to cua-driver mcp and exposes its native MCP tools", async () => {
+ const hub = new MCPHub(configPath());
+ const binDirectory = mkdtempSync(join(tmpdir(), "convera-cua-bin-"));
+ tempDirectories.push(binDirectory);
+ const executable = join(binDirectory, "cua-driver");
+ const fixture = resolve(
+ process.cwd(),
+ "src/electron/mcp/__fixtures__/cua-driver.mjs",
+ );
+ writeFileSync(
+ executable,
+ `#!/bin/sh\nexec '${process.execPath}' '${fixture}' "$@"\n`,
+ );
+ chmodSync(executable, 0o755);
+ const originalPath = process.env.PATH;
+ process.env.PATH = binDirectory;
+
+ try {
+ await hub.initialize();
+ await vi.waitFor(() => {
+ expect(hub.getServerStatus("cua")).toMatchObject({
+ status: ConnectionStatus.CONNECTED,
+ capabilities: {
+ tools: [
+ expect.objectContaining({
+ name: "screenshot",
+ inputSchema: expect.objectContaining({ type: "object" }),
+ }),
+ ],
+ },
+ });
+ });
+
+ await expect(
+ hub.callTool("cua", "screenshot", { label: "native MCP result" }),
+ ).resolves.toMatchObject({
+ content: [{ type: "text", text: "native MCP result" }],
+ });
+ expect(hub.getConfig().mcpServers.cua).toMatchObject({
+ command: "cua-driver",
+ args: ["mcp"],
+ });
+ } finally {
+ if (originalPath === undefined) {
+ delete process.env.PATH;
+ } else {
+ process.env.PATH = originalPath;
+ }
+ await hub.cleanup();
+ }
+ });
+});
diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts
index c5e77b07..00acc2b4 100644
--- a/packages/app/src/electron/mcp/runtime-catalog.test.ts
+++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts
@@ -5,61 +5,94 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { LocalAiProviderAdapter } from "../ai/provider-adapter";
import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../ai/provider-descriptors";
import { LocalAiRuntime } from "../ai/runtime";
+import { MCPConnection } from "./connection";
import { cleanupMCPHub, getAllTools, initializeMCPHub } from "./index";
describe("main-process agent tool catalog", () => {
afterEach(async () => {
+ vi.restoreAllMocks();
await cleanupMCPHub();
});
- it("provides every builtin tool to startChat after MCP initialization", async () => {
- const createModel = vi.fn(
- async () => ({}) as LanguageModel,
- );
- const adapter: LocalAiProviderAdapter = {
- id: "codex-cli",
- getStatus: vi.fn(async () => ({
- ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"],
- available: true,
- authenticated: true,
- checkedAt: new Date(0).toISOString(),
- })),
- createModel,
- dispose: vi.fn(async () => undefined),
- };
- const configPath = join(
- tmpdir(),
- `convera-mcp-runtime-catalog-${process.pid}.json`,
- );
- const runtime = new LocalAiRuntime({
- adapters: [adapter],
- getToolGroups: async () => {
- await initializeMCPHub(configPath);
- return getAllTools();
- },
- streamInvoker: () => ({
- toUIMessageStream: async function* () {
- yield { type: "finish" as const, finishReason: "stop" as const };
+ it.each(["codex-cli", "claude-code"] as const)(
+ "provides connected Cua MCP tools and builtins to %s",
+ async (providerId) => {
+ vi.spyOn(MCPConnection.prototype, "connect").mockResolvedValue();
+ vi.spyOn(MCPConnection.prototype, "disconnect").mockResolvedValue();
+ vi.spyOn(MCPConnection.prototype, "getServerInfo").mockReturnValue({
+ name: "cua",
+ displayName: "Cua",
+ description: "Convera-managed Cua Driver computer-use tools",
+ transportType: "stdio",
+ status: "connected",
+ capabilities: {
+ tools: [
+ {
+ name: "screenshot",
+ description: "Capture the current desktop",
+ inputSchema: { type: "object", properties: {} },
+ },
+ ],
+ resources: [],
+ resourceTemplates: [],
+ prompts: [],
},
- }),
- });
+ uptime: 0,
+ managed: true,
+ });
- await runtime.startChat(
- {
- requestId: "runtime-catalog",
- providerId: "codex-cli",
- messages: [{ role: "user", content: "List available tools." }],
- },
- vi.fn(),
- );
+ const createModel = vi.fn(
+ async () => ({}) as LanguageModel,
+ );
+ const adapter: LocalAiProviderAdapter = {
+ id: providerId,
+ getStatus: vi.fn(async () => ({
+ ...LOCAL_AI_PROVIDER_DESCRIPTORS[providerId],
+ available: true,
+ authenticated: true,
+ checkedAt: new Date(0).toISOString(),
+ })),
+ createModel,
+ dispose: vi.fn(async () => undefined),
+ };
+ const configPath = join(
+ tmpdir(),
+ `convera-mcp-runtime-catalog-${process.pid}.json`,
+ );
+ const runtime = new LocalAiRuntime({
+ adapters: [adapter],
+ getToolGroups: async () => {
+ await initializeMCPHub(configPath);
+ return getAllTools();
+ },
+ streamInvoker: () => ({
+ toUIMessageStream: async function* () {
+ yield { type: "finish" as const, finishReason: "stop" as const };
+ },
+ }),
+ });
+
+ await runtime.startChat(
+ {
+ requestId: "runtime-catalog",
+ providerId,
+ messages: [{ role: "user", content: "List available tools." }],
+ },
+ vi.fn(),
+ );
- const context = createModel.mock.calls[0]?.[2];
- expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([
- "builtin:ask_user_input",
- "builtin:execute_command",
- "builtin:web_fetch",
- ]);
+ const context = createModel.mock.calls[0]?.[2];
+ expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([
+ "cua:screenshot",
+ "builtin:ask_user_input",
+ "builtin:execute_command",
+ "builtin:web_fetch",
+ ]);
+ expect(context?.tools.map((tool) => tool.qualifiedName)).not.toContain(
+ "builtin:computer_control",
+ );
- await runtime.dispose();
- });
+ await runtime.dispose();
+ },
+ );
});
From 807463667d89c64d48ec3f5cacedd11229d1cb77 Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 23:24:37 +0800
Subject: [PATCH 4/6] fix(app): expose managed Cua as native MCP
---
.../ai/__tests__/claude-code-mcp.test.ts | 76 +++++++++++++++++++
.../ai/__tests__/codex-cli-mcp.test.ts | 56 +++++++++++++-
.../electron/ai/__tests__/codex-cli.test.ts | 1 +
packages/app/src/electron/ai/agent-tools.ts | 9 +++
.../app/src/electron/ai/provider-adapter.ts | 10 ++-
.../src/electron/ai/providers/claude-code.ts | 30 +++++++-
.../src/electron/ai/providers/codex-cli.ts | 18 ++++-
packages/app/src/electron/ai/runtime.ts | 18 ++++-
packages/app/src/electron/main.ts | 4 +-
packages/app/src/electron/mcp/connection.ts | 65 +---------------
packages/app/src/electron/mcp/index.ts | 67 +++++++++++++++-
.../app/src/electron/mcp/managed-servers.ts | 57 ++++++++++++++
.../src/electron/mcp/runtime-catalog.test.ts | 43 ++++++++++-
13 files changed, 376 insertions(+), 78 deletions(-)
create mode 100644 packages/app/src/electron/ai/__tests__/claude-code-mcp.test.ts
diff --git a/packages/app/src/electron/ai/__tests__/claude-code-mcp.test.ts b/packages/app/src/electron/ai/__tests__/claude-code-mcp.test.ts
new file mode 100644
index 00000000..2a7c8286
--- /dev/null
+++ b/packages/app/src/electron/ai/__tests__/claude-code-mcp.test.ts
@@ -0,0 +1,76 @@
+import type { LocalAIChatRequest } from "@/shared/types/local-ai";
+import type { LanguageModel } from "ai";
+import { describe, expect, it, vi } from "vitest";
+import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../provider-descriptors";
+import { ClaudeCodeAdapter } from "../providers/claude-code";
+import type { LocalAiProviderStatus } from "../types";
+
+const mocks = vi.hoisted(() => {
+ const model = {} as LanguageModel;
+ const provider = vi.fn(() => model);
+
+ return {
+ model,
+ provider,
+ createClaudeCode: vi.fn(() => provider),
+ createSdkMcpServer: vi.fn(),
+ tool: vi.fn(),
+ };
+});
+
+vi.mock("ai-sdk-provider-claude-code", () => ({
+ createClaudeCode: mocks.createClaudeCode,
+ createSdkMcpServer: mocks.createSdkMcpServer,
+ tool: mocks.tool,
+}));
+
+describe("ClaudeCodeAdapter MCP transport", () => {
+ it("passes connected managed MCP servers to Claude without converting their tools", async () => {
+ const adapter = new ClaudeCodeAdapter();
+ const request: LocalAIChatRequest = {
+ requestId: "native-mcp",
+ providerId: "claude-code",
+ modelId: "claude-test",
+ messages: [{ role: "user", content: "use cua" }],
+ options: { cwd: "/tmp/convera-test" },
+ };
+ const status: LocalAiProviderStatus = {
+ ...LOCAL_AI_PROVIDER_DESCRIPTORS["claude-code"],
+ available: true,
+ authenticated: true,
+ executablePath: "/test/claude",
+ defaultModel: "claude-test",
+ models: ["claude-test"],
+ checkedAt: new Date(0).toISOString(),
+ };
+
+ const model = await adapter.createModel(request, status, {
+ tools: [],
+ nativeMcpServers: {
+ cua: {
+ transport: "stdio",
+ command: "cua-driver",
+ args: ["mcp"],
+ toolNames: ["screenshot"],
+ },
+ },
+ requestInteraction: vi.fn(async () => ({ approved: false })),
+ });
+
+ expect(model).toBe(mocks.model);
+ expect(mocks.provider).toHaveBeenCalledWith("claude-test", {
+ pathToClaudeCodeExecutable: "/test/claude",
+ cwd: "/tmp/convera-test",
+ mcpServers: {
+ cua: {
+ type: "stdio",
+ command: "cua-driver",
+ args: ["mcp"],
+ },
+ },
+ allowedTools: ["mcp__cua__screenshot"],
+ });
+ expect(mocks.createSdkMcpServer).not.toHaveBeenCalled();
+ expect(mocks.tool).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts
index 46ed56e6..9dd653b9 100644
--- a/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts
+++ b/packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts
@@ -36,7 +36,14 @@ function providerSettings() {
[
string,
{
- mcpServers?: { convera?: unknown };
+ mcpServers?: {
+ convera?: unknown;
+ cua?: {
+ transport: "stdio";
+ command: string;
+ args?: string[];
+ };
+ };
serverRequests?: {
onMcpElicitation?: (request: {
id: number;
@@ -85,6 +92,7 @@ describe("CodexCliAdapter MCP transport", () => {
execute: vi.fn(async () => "PROBE_OK"),
},
],
+ nativeMcpServers: {},
requestInteraction: vi.fn(async () => ({ approved: false })),
});
@@ -138,6 +146,7 @@ describe("CodexCliAdapter MCP transport", () => {
execute: vi.fn(async () => "PROBE_OK"),
},
],
+ nativeMcpServers: {},
requestInteraction: vi.fn(async () => ({ approved: false })),
});
@@ -158,4 +167,49 @@ describe("CodexCliAdapter MCP transport", () => {
await adapter.dispose();
});
+
+ it("passes connected managed MCP servers to Codex without converting their tools", async () => {
+ const adapter = new CodexCliAdapter();
+ const request: LocalAIChatRequest = {
+ requestId: "native-mcp",
+ providerId: "codex-cli",
+ modelId: "gpt-test",
+ messages: [{ role: "user", content: "use cua" }],
+ };
+ const status: LocalAiProviderStatus = {
+ ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"],
+ available: true,
+ authenticated: true,
+ executablePath: "/test/codex",
+ defaultModel: "gpt-test",
+ models: ["gpt-test"],
+ checkedAt: new Date(0).toISOString(),
+ };
+
+ await adapter.createModel(request, status, {
+ tools: [],
+ nativeMcpServers: {
+ cua: {
+ transport: "stdio",
+ command: "cua-driver",
+ args: ["mcp"],
+ toolNames: ["screenshot"],
+ },
+ },
+ requestInteraction: vi.fn(async () => ({ approved: false })),
+ });
+
+ expect(providerSettings()?.mcpServers).toEqual({
+ cua: {
+ transport: "stdio",
+ command: "cua-driver",
+ args: ["mcp"],
+ },
+ });
+ expect(mocks.tool).not.toHaveBeenCalledWith(
+ expect.objectContaining({ name: "cua__screenshot" }),
+ );
+
+ await adapter.dispose();
+ });
});
diff --git a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts
index a4c67618..12fb75df 100644
--- a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts
+++ b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts
@@ -29,6 +29,7 @@ describe("CodexCliAdapter", () => {
const model = await adapter.createModel(request, status, {
tools: [],
+ nativeMcpServers: {},
requestInteraction: async () => ({ approved: false }),
});
diff --git a/packages/app/src/electron/ai/agent-tools.ts b/packages/app/src/electron/ai/agent-tools.ts
index 2c0bbcab..c95d8521 100644
--- a/packages/app/src/electron/ai/agent-tools.ts
+++ b/packages/app/src/electron/ai/agent-tools.ts
@@ -1,9 +1,18 @@
import type { ToolDefinition } from "@/shared/types/mcp";
import { z, type ZodRawShape, type ZodTypeAny } from "zod";
+export interface NativeMcpServer {
+ transport: "stdio";
+ command: string;
+ args?: string[];
+ cwd?: string;
+ env?: Record;
+}
+
export interface AgentToolGroup {
serverName: string;
tools: ToolDefinition[];
+ nativeMcpServer?: NativeMcpServer;
}
export interface AgentToolInteraction {
diff --git a/packages/app/src/electron/ai/provider-adapter.ts b/packages/app/src/electron/ai/provider-adapter.ts
index 2bd5a646..5a50e248 100644
--- a/packages/app/src/electron/ai/provider-adapter.ts
+++ b/packages/app/src/electron/ai/provider-adapter.ts
@@ -1,6 +1,10 @@
import type { LocalAIChatRequest } from "@/shared/types/local-ai";
import type { LanguageModel } from "ai";
-import type { AgentTool, AgentToolInteraction } from "./agent-tools";
+import type {
+ AgentTool,
+ AgentToolInteraction,
+ NativeMcpServer,
+} from "./agent-tools";
import type { LocalAiProviderId, LocalAiProviderStatus } from "./types";
export function resolveLocalModelId(
@@ -19,6 +23,10 @@ export interface LocalAiProviderAdapter {
status: LocalAiProviderStatus,
context: {
tools: AgentTool[];
+ nativeMcpServers: Record<
+ string,
+ NativeMcpServer & { toolNames: string[] }
+ >;
requestInteraction(
interaction: AgentToolInteraction,
): Promise<{ approved?: boolean; value?: string }>;
diff --git a/packages/app/src/electron/ai/providers/claude-code.ts b/packages/app/src/electron/ai/providers/claude-code.ts
index 742acfad..aee7f807 100644
--- a/packages/app/src/electron/ai/providers/claude-code.ts
+++ b/packages/app/src/electron/ai/providers/claude-code.ts
@@ -72,16 +72,38 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter {
tools.length > 0
? createSdkMcpServer({ name: "convera", tools })
: undefined;
+ const nativeMcpServers = Object.fromEntries(
+ Object.entries(context.nativeMcpServers).map(([serverName, server]) => [
+ serverName,
+ {
+ type: "stdio" as const,
+ command: server.command,
+ args: server.args,
+ ...(server.env ? { env: server.env } : {}),
+ },
+ ]),
+ );
+ const mcpServers = {
+ ...nativeMcpServers,
+ ...(mcpServer ? { convera: mcpServer } : {}),
+ };
+ const nativeAllowedTools = Object.entries(context.nativeMcpServers).flatMap(
+ ([serverName, server]) =>
+ server.toolNames.map((toolName) => `mcp__${serverName}__${toolName}`),
+ );
return this.provider(
resolveLocalModelId(request.modelId, status.defaultModel),
{
pathToClaudeCodeExecutable: status.executablePath,
cwd: request.options?.cwd,
- mcpServers: mcpServer ? { convera: mcpServer } : undefined,
- allowedTools: context.tools.map(
- (definition) => `mcp__convera__${definition.name}`,
- ),
+ mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
+ allowedTools: [
+ ...context.tools.map(
+ (definition) => `mcp__convera__${definition.name}`,
+ ),
+ ...nativeAllowedTools,
+ ],
},
);
}
diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts
index 0de6e12d..51369212 100644
--- a/packages/app/src/electron/ai/providers/codex-cli.ts
+++ b/packages/app/src/electron/ai/providers/codex-cli.ts
@@ -75,6 +75,22 @@ export class CodexCliAdapter implements LocalAiProviderAdapter {
definitions: context.tools,
})
: undefined;
+ const nativeMcpServers = Object.fromEntries(
+ Object.entries(context.nativeMcpServers).map(([serverName, server]) => [
+ serverName,
+ {
+ transport: server.transport,
+ command: server.command,
+ args: server.args,
+ ...(server.cwd ? { cwd: server.cwd } : {}),
+ ...(server.env ? { env: server.env } : {}),
+ },
+ ]),
+ );
+ const mcpServers = {
+ ...nativeMcpServers,
+ ...(mcpServer ? { convera: mcpServer } : {}),
+ };
const requestApproval = async (
name: string,
prompt: string,
@@ -120,7 +136,7 @@ export class CodexCliAdapter implements LocalAiProviderAdapter {
resolveLocalModelId(request.modelId, status.defaultModel),
{
cwd,
- mcpServers: mcpServer ? { convera: mcpServer } : undefined,
+ mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
serverRequests,
approvalPolicy: "on-request",
sandboxPolicy: {
diff --git a/packages/app/src/electron/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts
index 918c4508..2c081cf0 100644
--- a/packages/app/src/electron/ai/runtime.ts
+++ b/packages/app/src/electron/ai/runtime.ts
@@ -358,13 +358,29 @@ export class LocalAiRuntime implements LocalAIRuntimeService {
);
const toolGroups = await this.getToolGroups();
controller.signal.throwIfAborted();
+ const nativeMcpServers = Object.fromEntries(
+ toolGroups.flatMap((group) =>
+ group.nativeMcpServer
+ ? [
+ [
+ group.serverName,
+ {
+ ...group.nativeMcpServer,
+ toolNames: group.tools.map((tool) => tool.name),
+ },
+ ],
+ ]
+ : [],
+ ),
+ );
const tools = createAgentToolCatalog({
- groups: toolGroups,
+ groups: toolGroups.filter((group) => !group.nativeMcpServer),
executeTool: this.executeTool,
requestInteraction,
});
const model = await adapter.createModel(trustedRequest, probeStatus, {
tools,
+ nativeMcpServers,
requestInteraction,
});
controller.signal.throwIfAborted();
diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts
index e623b600..70afd810 100644
--- a/packages/app/src/electron/main.ts
+++ b/packages/app/src/electron/main.ts
@@ -3,7 +3,7 @@ import { app, BrowserWindow, globalShortcut } from "electron";
import { getLogger, initializeLogger } from "@/electron/logger";
import {
callTool,
- getAllTools,
+ getAgentToolGroups,
getMCPHub,
initializeMCPHub,
mcpToolCall,
@@ -29,7 +29,7 @@ const logger = getLogger("main-process");
const localAIRuntime = new LocalAiRuntime({
getToolGroups: async () => {
await initializeMCPHub();
- return getAllTools();
+ return getAgentToolGroups();
},
executeTool: (serverName, toolName, input) =>
serverName.toLowerCase() === "builtin"
diff --git a/packages/app/src/electron/mcp/connection.ts b/packages/app/src/electron/mcp/connection.ts
index 00286898..ae4d8e1e 100644
--- a/packages/app/src/electron/mcp/connection.ts
+++ b/packages/app/src/electron/mcp/connection.ts
@@ -26,13 +26,13 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { app } from "electron";
import { EventEmitter } from "events";
-import * as fs from "fs";
import { z } from "zod";
import * as path from "path";
import * as os from "os";
import { zodToJsonSchema } from "zod-to-json-schema";
import { getLogger } from "../logger";
+import { resolveManagedStdioExecutable } from "./managed-servers";
const logger = getLogger("MCPConnectionAI");
// Define proper MCP tool types
@@ -246,7 +246,7 @@ export class MCPConnection extends EventEmitter {
}),
);
if (this.config.managed && actualCommand === "cua-driver") {
- actualCommand = this.resolveStdioExecutable(
+ actualCommand = resolveManagedStdioExecutable(
actualCommand,
resolvedConfig.cwd || app.getPath("userData"),
environment,
@@ -690,67 +690,6 @@ export class MCPConnection extends EventEmitter {
return message;
}
- private resolveStdioExecutable(
- command: string,
- cwd: string,
- environment: Record,
- ): string {
- const hasPathSeparator =
- command.includes(path.sep) ||
- (path.sep === "\\" && command.includes("/"));
- const searchDirectories = hasPathSeparator
- ? [cwd]
- : [
- ...(environment.PATH || environment.Path || "")
- .split(path.delimiter)
- .filter(Boolean),
- ...(command === "cua-driver"
- ? [
- path.join(os.homedir(), ".local", "bin"),
- "/opt/homebrew/bin",
- "/usr/local/bin",
- ]
- : []),
- ];
- const extensions =
- process.platform === "win32" && !path.extname(command)
- ? (environment.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
- : [""];
- const candidates = hasPathSeparator
- ? [
- path.isAbsolute(command) ? command : path.resolve(cwd, command),
- ...extensions
- .filter(Boolean)
- .map(
- (extension) =>
- `${path.isAbsolute(command) ? command : path.resolve(cwd, command)}${extension.toLowerCase()}`,
- ),
- ]
- : searchDirectories.flatMap((directory) =>
- extensions.map((extension) =>
- path.join(directory, `${command}${extension.toLowerCase()}`),
- ),
- );
-
- const executable = candidates.find((candidate) => {
- try {
- fs.accessSync(candidate, fs.constants.X_OK);
- return true;
- } catch {
- return false;
- }
- });
- if (!executable) {
- throw new Error(
- hasPathSeparator
- ? `executable '${command}' was not found`
- : `executable '${command}' was not found on PATH`,
- );
- }
-
- return executable;
- }
-
/**
* Resolve environment variables in configuration (Electron-optimized)
*/
diff --git a/packages/app/src/electron/mcp/index.ts b/packages/app/src/electron/mcp/index.ts
index 820cdfab..2c7f4b8b 100644
--- a/packages/app/src/electron/mcp/index.ts
+++ b/packages/app/src/electron/mcp/index.ts
@@ -18,9 +18,13 @@ export {
} from "@/shared/types/mcp";
// Import types for internal use
+import type { AgentToolGroup } from "@/electron/ai/agent-tools";
import type { ServerInfo, ToolDefinition } from "@/shared/types/mcp";
import { ConnectionStatus } from "@/shared/types/mcp";
+import { homedir } from "node:os";
+import { delimiter, join } from "node:path";
import { MCPHub } from "./hub";
+import { resolveManagedStdioExecutable } from "./managed-servers";
// Global hub instance
let globalHub: MCPHub | null = null;
@@ -31,9 +35,9 @@ let globalHub: MCPHub | null = null;
export async function initializeMCPHub(configPath?: string): Promise {
if (!globalHub) {
globalHub = new MCPHub(configPath);
+ await globalHub.initialize();
}
- await globalHub.initialize();
return globalHub;
}
@@ -130,6 +134,67 @@ export function getAllTools(): Array<{
return [...serverTools, builtinTools];
}
+/**
+ * Get the tool groups used by local AI providers. Managed stdio servers keep
+ * their native MCP transport so providers consume the original MCP schemas
+ * instead of receiving converted Convera tools.
+ */
+export function getAgentToolGroups(): AgentToolGroup[] {
+ if (!globalHub) {
+ return [];
+ }
+
+ const config = globalHub.getConfig();
+ const serverTools = globalHub
+ .getAllServerStatuses()
+ .filter(
+ (server: ServerInfo) => server.status === ConnectionStatus.CONNECTED,
+ )
+ .map((server: ServerInfo): AgentToolGroup => {
+ const serverConfig = config.mcpServers[server.name];
+ const environment = {
+ ...serverConfig?.env,
+ PATH: [
+ serverConfig?.env?.PATH,
+ process.env.PATH,
+ join(homedir(), ".local", "bin"),
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ ]
+ .filter(Boolean)
+ .join(delimiter),
+ };
+ const nativeMcpServer =
+ server.managed && serverConfig?.command
+ ? {
+ transport: "stdio" as const,
+ command: resolveManagedStdioExecutable(
+ serverConfig.command,
+ serverConfig.cwd ?? process.cwd(),
+ environment,
+ ),
+ args: serverConfig.args,
+ cwd: serverConfig.cwd,
+ env: environment,
+ }
+ : undefined;
+
+ return {
+ serverName: server.name,
+ tools: server.capabilities.tools,
+ nativeMcpServer,
+ };
+ });
+
+ return [
+ ...serverTools,
+ {
+ serverName: "builtin",
+ tools: globalHub.getBuiltinToolsDefinition(),
+ },
+ ];
+}
+
/**
* Get all tools that don't require input parameters
* Uses cached results for better performance
diff --git a/packages/app/src/electron/mcp/managed-servers.ts b/packages/app/src/electron/mcp/managed-servers.ts
index c78fe0c3..02838932 100644
--- a/packages/app/src/electron/mcp/managed-servers.ts
+++ b/packages/app/src/electron/mcp/managed-servers.ts
@@ -1,4 +1,7 @@
import type { MCPConfig, MCPServerConfig } from "@/shared/types/mcp";
+import { accessSync, constants } from "node:fs";
+import { homedir } from "node:os";
+import { delimiter, extname, isAbsolute, join, resolve, sep } from "node:path";
export const CUA_SERVER_ID = "cua";
@@ -47,3 +50,57 @@ export function normalizeManagedServer(
disabled: config.disabled === true,
};
}
+
+export function resolveManagedStdioExecutable(
+ command: string,
+ cwd: string,
+ environment: Record,
+): string {
+ const hasPathSeparator =
+ command.includes(sep) || (sep === "\\" && command.includes("/"));
+ const searchDirectories = hasPathSeparator
+ ? [cwd]
+ : [
+ ...(environment.PATH || environment.Path || "")
+ .split(delimiter)
+ .filter(Boolean),
+ join(homedir(), ".local", "bin"),
+ "/opt/homebrew/bin",
+ "/usr/local/bin",
+ ];
+ const extensions =
+ process.platform === "win32" && !extname(command)
+ ? (environment.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
+ : [""];
+ const resolvedCommand = isAbsolute(command) ? command : resolve(cwd, command);
+ const candidates = hasPathSeparator
+ ? [
+ resolvedCommand,
+ ...extensions
+ .filter(Boolean)
+ .map((extension) => `${resolvedCommand}${extension.toLowerCase()}`),
+ ]
+ : searchDirectories.flatMap((directory) =>
+ extensions.map((extension) =>
+ join(directory, `${command}${extension.toLowerCase()}`),
+ ),
+ );
+
+ const executable = candidates.find((candidate) => {
+ try {
+ accessSync(candidate, constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ });
+ if (!executable) {
+ throw new Error(
+ hasPathSeparator
+ ? `executable '${command}' was not found`
+ : `executable '${command}' was not found on PATH`,
+ );
+ }
+
+ return executable;
+}
diff --git a/packages/app/src/electron/mcp/runtime-catalog.test.ts b/packages/app/src/electron/mcp/runtime-catalog.test.ts
index 00acc2b4..d5a73fe6 100644
--- a/packages/app/src/electron/mcp/runtime-catalog.test.ts
+++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts
@@ -1,12 +1,18 @@
import type { LanguageModel } from "ai";
-import { tmpdir } from "node:os";
+import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { LocalAiProviderAdapter } from "../ai/provider-adapter";
import { LOCAL_AI_PROVIDER_DESCRIPTORS } from "../ai/provider-descriptors";
import { LocalAiRuntime } from "../ai/runtime";
import { MCPConnection } from "./connection";
-import { cleanupMCPHub, getAllTools, initializeMCPHub } from "./index";
+import { MCPHub } from "./hub";
+import { cleanupMCPHub, getAgentToolGroups, initializeMCPHub } from "./index";
+
+vi.mock("./managed-servers", async (importOriginal) => ({
+ ...(await importOriginal()),
+ resolveManagedStdioExecutable: vi.fn(() => "/test/cua-driver"),
+}));
describe("main-process agent tool catalog", () => {
afterEach(async () => {
@@ -14,6 +20,22 @@ describe("main-process agent tool catalog", () => {
await cleanupMCPHub();
});
+ it("does not restart managed MCP connections when the catalog is requested again", async () => {
+ const initialize = vi
+ .spyOn(MCPHub.prototype, "initialize")
+ .mockResolvedValue();
+ const configPath = join(
+ tmpdir(),
+ `convera-mcp-idempotent-${process.pid}.json`,
+ );
+
+ const first = await initializeMCPHub(configPath);
+ const second = await initializeMCPHub(configPath);
+
+ expect(second).toBe(first);
+ expect(initialize).toHaveBeenCalledTimes(1);
+ });
+
it.each(["codex-cli", "claude-code"] as const)(
"provides connected Cua MCP tools and builtins to %s",
async (providerId) => {
@@ -63,7 +85,7 @@ describe("main-process agent tool catalog", () => {
adapters: [adapter],
getToolGroups: async () => {
await initializeMCPHub(configPath);
- return getAllTools();
+ return getAgentToolGroups();
},
streamInvoker: () => ({
toUIMessageStream: async function* () {
@@ -83,14 +105,27 @@ describe("main-process agent tool catalog", () => {
const context = createModel.mock.calls[0]?.[2];
expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([
- "cua:screenshot",
"builtin:ask_user_input",
"builtin:execute_command",
"builtin:web_fetch",
]);
+ expect(context?.nativeMcpServers).toMatchObject({
+ cua: {
+ transport: "stdio",
+ command: "/test/cua-driver",
+ args: ["mcp"],
+ toolNames: ["screenshot"],
+ },
+ });
+ expect(context?.nativeMcpServers.cua.env?.PATH).toContain(
+ join(homedir(), ".local", "bin"),
+ );
expect(context?.tools.map((tool) => tool.qualifiedName)).not.toContain(
"builtin:computer_control",
);
+ expect(context?.tools.map((tool) => tool.qualifiedName)).not.toContain(
+ "cua:screenshot",
+ );
await runtime.dispose();
},
From 126719ddf76405ff146f909aa349368f70e3aefe Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 23:27:37 +0800
Subject: [PATCH 5/6] docs: require real app automation verification
---
AGENTS.md | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 AGENTS.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..58bf1f7b
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,33 @@
+# Convera Agent Instructions
+
+## Real Application Verification
+
+For changes that affect application behavior, Electron main/preload/renderer
+integration, local AI providers, MCP servers or tools, settings, or
+user-visible flows, unit tests, lint, and typecheck are not sufficient by
+themselves.
+
+Before reporting the work complete:
+
+1. Read `packages/app/automation/README.md` completely.
+2. After a clean checkout or any app bundle/source change, run
+ `pnpm --filter @convera/app automation:prepare`.
+3. Start the automation MCP with `pnpm --filter @convera/app automation` and
+ drive the real Electron app with `convera_session`, `convera_observe`,
+ `convera_interact`, and `convera_wait`. Use `convera_execute` only when the
+ semantic actions cannot express the required step.
+4. Use an isolated `profile_id` and verify the production-facing behavior,
+ including visible success and the relevant failure or degraded state. Do
+ not substitute mocks or unit tests for this verification.
+5. Inspect the actual UI state and capture a screenshot or log artifact under
+ the ignored `packages/app/.automation/` directory when it provides useful
+ evidence.
+6. Close the automation session and clean up only the exact temporary
+ configuration and profile data created by the test. Never modify a real
+ user profile, session memo, or conversation database.
+7. Report the exercised scenario, provider/runtime, evidence, and any remaining
+ real-machine boundary. For provider work, do not run real Claude unless the
+ user explicitly asks; use real Codex when requested or available.
+
+If real application automation cannot run, do not silently call the feature
+complete. Report the blocker and the exact unverified boundary.
From f2fed6fc6e6f4488d4e24c5a3d41bb9d3bb410f8 Mon Sep 17 00:00:00 2001
From: NarwhalChen
Date: Thu, 30 Jul 2026 23:56:29 +0800
Subject: [PATCH 6/6] fix(app): support current Cua MCP protocol
---
packages/app/package.json | 2 +-
packages/app/src/electron/mcp/hub.test.ts | 21 +++++++++++++--------
pnpm-lock.yaml | 17 ++++++-----------
3 files changed, 20 insertions(+), 20 deletions(-)
diff --git a/packages/app/package.json b/packages/app/package.json
index 5343e771..a68b8dc0 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -91,7 +91,7 @@
"@hurdlegroup/robotjs": "^0.12.3",
"@icons-pack/react-simple-icons": "^12.2.0",
"@leeoniya/ufuzzy": "^1.0.18",
- "@modelcontextprotocol/sdk": "1.12.3",
+ "@modelcontextprotocol/sdk": "1.13.0",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
diff --git a/packages/app/src/electron/mcp/hub.test.ts b/packages/app/src/electron/mcp/hub.test.ts
index e4b94e0a..bc189ce9 100644
--- a/packages/app/src/electron/mcp/hub.test.ts
+++ b/packages/app/src/electron/mcp/hub.test.ts
@@ -8,8 +8,10 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
+import { SUPPORTED_PROTOCOL_VERSIONS } from "@modelcontextprotocol/sdk/types.js";
import { afterEach, describe, expect, it, vi } from "vitest";
import { MCPHub } from "./hub";
+import * as managedServers from "./managed-servers";
vi.mock("electron", () => ({
app: {
@@ -28,12 +30,17 @@ function configPath(): string {
}
afterEach(() => {
+ vi.restoreAllMocks();
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("managed Cua MCP server", () => {
+ it("supports the protocol version used by the official Cua Driver", () => {
+ expect(SUPPORTED_PROTOCOL_VERSIONS).toContain("2025-06-18");
+ });
+
it("adds the official cua-driver stdio server to the default config", () => {
const path = configPath();
const hub = new MCPHub(path);
@@ -106,9 +113,12 @@ describe("managed Cua MCP server", () => {
it("does not block startup and reports a clear error when cua-driver is missing", async () => {
const hub = new MCPHub(configPath());
- const originalPath = process.env.PATH;
- process.env.PATH = mkdtempSync(join(tmpdir(), "convera-empty-path-"));
- tempDirectories.push(process.env.PATH);
+ vi.spyOn(
+ managedServers,
+ "resolveManagedStdioExecutable",
+ ).mockImplementation(() => {
+ throw new Error("executable 'cua-driver' was not found on PATH");
+ });
try {
await expect(hub.initialize()).resolves.toBeUndefined();
@@ -122,11 +132,6 @@ describe("managed Cua MCP server", () => {
});
});
} finally {
- if (originalPath === undefined) {
- delete process.env.PATH;
- } else {
- process.env.PATH = originalPath;
- }
await hub.cleanup();
}
});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 83e92db4..36cd44dc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -36,8 +36,8 @@ importers:
specifier: ^1.0.18
version: 1.0.18
'@modelcontextprotocol/sdk':
- specifier: 1.12.3
- version: 1.12.3
+ specifier: 1.13.0
+ version: 1.13.0
'@radix-ui/react-accordion':
specifier: ^1.2.4
version: 1.2.11(@types/react-dom@19.1.4)(@types/react@19.1.4)(react-dom@19.1.0)(react@19.1.0)
@@ -3558,8 +3558,8 @@ packages:
langium: 3.3.1
dev: false
- /@modelcontextprotocol/sdk@1.12.3:
- resolution: {integrity: sha512-DyVYSOafBvk3/j1Oka4z5BWT8o4AFmoNyZY9pALOm7Lh3GZglR71Co4r4dEUoqDWdDazIZQHBe7J2Nwkg6gHgQ==}
+ /@modelcontextprotocol/sdk@1.13.0:
+ resolution: {integrity: sha512-P5FZsXU0kY881F6Hbk9GhsYx02/KgWK1DYf7/tyE/1lcFKhDYPQR9iYjhQXJn+Sg6hQleMo3DB7h7+p4wgp2Lw==}
engines: {node: '>=18'}
dependencies:
ajv: 6.12.6
@@ -6829,7 +6829,7 @@ packages:
resolution: {integrity: sha512-Rt7kee+mc4YE4szzA13YESBfM8H7V9zR05OJQbyRLXHsnnBX4PfLOGLqbhgQ4ccTOZgUGxjmESuklExw+kXnJQ==}
dependencies:
'@anthropic-ai/sdk': 0.32.1
- '@modelcontextprotocol/sdk': 1.12.3
+ '@modelcontextprotocol/sdk': 1.13.0
ai: 4.3.19(react@19.1.0)(zod@3.25.76)
express: 5.1.0
json-schema: 0.4.0
@@ -12543,11 +12543,6 @@ packages:
engines: {node: '>=0.8.x'}
dev: true
- /eventsource-parser@3.0.1:
- resolution: {integrity: sha512-VARTJ9CYeuQYb0pZEPbzi740OWFgpHe7AYJ2WFZVnUDUQp5Dk2yJUgF36YsZ81cOyxT0QxmXD2EQpapAouzWVA==}
- engines: {node: '>=18.0.0'}
- dev: false
-
/eventsource-parser@3.1.0:
resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==}
engines: {node: '>=18.0.0'}
@@ -12557,7 +12552,7 @@ packages:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
dependencies:
- eventsource-parser: 3.0.1
+ eventsource-parser: 3.1.0
dev: false
/execa@1.0.0: