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. diff --git a/packages/app/package.json b/packages/app/package.json index a367b3a8..2409e44b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -95,7 +95,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/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..c12ec109 --- /dev/null +++ b/packages/app/src/electron/ai/__tests__/claude-code-mcp.test.ts @@ -0,0 +1,84 @@ +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", + conversationId: "conversation", + turnId: "turn", + providerId: "claude-code", + modelId: "claude-test", + operation: { + kind: "append", + message: { 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 run = await adapter.prepareRun(request, status, { + tools: [], + nativeMcpServers: { + cua: { + transport: "stdio", + command: "cua-driver", + args: ["mcp"], + toolNames: ["screenshot"], + }, + }, + requestInteraction: vi.fn(async () => ({ approved: false })), + }); + + expect(run.model).toBe(mocks.model); + expect(mocks.provider).toHaveBeenCalledWith( + "claude-test", + expect.objectContaining({ + 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 95745c81..9e59d85d 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 @@ -31,14 +31,58 @@ vi.mock("ai-sdk-provider-codex-cli", () => ({ tool: mocks.tool, })); +function request( + requestId: string, + content: string, + options?: LocalAIChatRequest["options"], +): LocalAIChatRequest { + return { + requestId, + conversationId: `conversation-${requestId}`, + turnId: `turn-${requestId}`, + providerId: "codex-cli", + modelId: "gpt-test", + operation: { + kind: "append", + message: { role: "user", content }, + }, + options, + }; +} + +function status(): LocalAiProviderStatus { + return { + ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], + available: true, + authenticated: true, + executablePath: "/test/codex", + defaultModel: "gpt-test", + models: ["gpt-test"], + checkedAt: new Date(0).toISOString(), + }; +} + function providerSettings() { const calls = mocks.provider.mock.calls as unknown as Array< [ string, { - mcpServers?: { convera?: unknown }; + mcpServers?: { + convera?: unknown; + cua?: { + transport: "stdio"; + command: string; + args?: string[]; + }; + }; + approvalPolicy?: unknown; + sandboxPolicy?: unknown; + configOverrides?: Record; serverRequests?: { - onMcpElicitation?: (request: { + onCommandExecutionApproval?: (...args: never[]) => Promise; + onFileChangeApproval?: (...args: never[]) => Promise; + onSkillApproval?: (...args: never[]) => Promise; + onMcpElicitation?: (request?: { id: number; method: string; params: Record; @@ -53,45 +97,27 @@ function providerSettings() { describe("CodexCliAdapter MCP transport", () => { it("attaches Convera tools without the obsolete RMCP feature flag", async () => { const adapter = new CodexCliAdapter(); - const request: LocalAIChatRequest = { - requestId: "test", - conversationId: "conversation", - turnId: "turn", - providerId: "codex-cli", - modelId: "gpt-test", - operation: { - kind: "append", - message: { role: "user", content: "use a tool" }, + const tool = { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Probe the local MCP bridge", + inputSchema: { + type: "object", + properties: { value: { type: "string" } }, }, - options: { cwd: "/tmp/convera-test" }, - }; - 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(), + inputShape: { value: z.string() }, + inputValidator: z.object({ value: z.string() }), + execute: vi.fn(async () => "PROBE_OK"), }; - await adapter.prepareRun(request, status, { - tools: [ - { - name: "builtin__probe", - qualifiedName: "builtin:probe", - description: "Probe the local MCP bridge", - inputSchema: { - type: "object", - properties: { value: { type: "string" } }, - }, - inputShape: { value: z.string() }, - inputValidator: z.object({ value: z.string() }), - execute: vi.fn(async () => "PROBE_OK"), - }, - ], - requestInteraction: vi.fn(async () => ({ approved: false })), - }); + await adapter.prepareRun( + request("tool", "use a tool", { cwd: "/tmp/convera-test" }), + status(), + { + tools: [tool], + requestInteraction: vi.fn(async () => ({ approved: false })), + }, + ); const mcpServer = providerSettings()?.mcpServers?.convera; expect(mcpServer).toEqual( @@ -115,28 +141,8 @@ describe("CodexCliAdapter MCP transport", () => { it("accepts MCP tool calls with structured empty content", async () => { const adapter = new CodexCliAdapter(); - const request: LocalAIChatRequest = { - requestId: "test", - conversationId: "conversation", - turnId: "turn", - providerId: "codex-cli", - modelId: "gpt-test", - operation: { - kind: "append", - message: { role: "user", content: "use a tool" }, - }, - }; - 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.prepareRun(request, status, { + await adapter.prepareRun(request("empty", "use a tool"), status(), { tools: [ { name: "builtin__probe", @@ -151,8 +157,7 @@ describe("CodexCliAdapter MCP transport", () => { requestInteraction: vi.fn(async () => ({ approved: false })), }); - const settings = providerSettings(); - const handler = settings?.serverRequests?.onMcpElicitation; + const handler = providerSettings()?.serverRequests?.onMcpElicitation; expect(handler).toBeTypeOf("function"); await expect( handler?.({ @@ -169,64 +174,74 @@ describe("CodexCliAdapter MCP transport", () => { await adapter.dispose(); }); + it("passes managed MCP servers natively without converting their tools", async () => { + const adapter = new CodexCliAdapter(); + + await adapter.prepareRun(request("native-mcp", "use cua"), 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(); + }); + it("removes native and configured tools for a text-only turn", async () => { const listConfiguredMcpServers = vi.fn(async () => [ "node_repl", "openaiDeveloperDocs", ]); const adapter = new CodexCliAdapter({ listConfiguredMcpServers }); - const request: LocalAIChatRequest = { - requestId: "restricted", - conversationId: "memory-curator", - turnId: "memory-turn", - providerId: "codex-cli", - modelId: "gpt-test", - operation: { - kind: "append", - message: { role: "user", content: "return json" }, - }, - options: { cwd: "/tmp/convera-test" }, - }; - 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(), - }; const interaction = vi.fn(async () => ({ approved: true })); - await adapter.prepareRun(request, status, { - tools: [ - { - name: "builtin__probe", - qualifiedName: "builtin:probe", - description: "Must not be exposed", - inputSchema: { type: "object", properties: {} }, - inputShape: {}, - inputValidator: z.object({}), - execute: vi.fn(async () => "UNREACHABLE"), + await adapter.prepareRun( + request("restricted", "return json", { cwd: "/tmp/convera-test" }), + status(), + { + tools: [ + { + name: "builtin__probe", + qualifiedName: "builtin:probe", + description: "Must not be exposed", + inputSchema: { type: "object", properties: {} }, + inputShape: {}, + inputValidator: z.object({}), + execute: vi.fn(async () => "UNREACHABLE"), + }, + ], + nativeMcpServers: { + cua: { + transport: "stdio", + command: "cua-driver", + args: ["mcp"], + toolNames: ["screenshot"], + }, }, - ], - executionPolicy: "text-only", - requestInteraction: interaction, - }); + executionPolicy: "text-only", + requestInteraction: interaction, + }, + ); expect(listConfiguredMcpServers).toHaveBeenCalledWith("/test/codex"); - const settings = providerSettings() as - | { - mcpServers?: unknown; - approvalPolicy?: unknown; - sandboxPolicy?: unknown; - configOverrides?: Record; - serverRequests?: Record< - string, - (...args: never[]) => Promise - >; - } - | undefined; + const settings = providerSettings(); expect(settings).toMatchObject({ approvalPolicy: "never", sandboxPolicy: "read-only", @@ -276,33 +291,17 @@ describe("CodexCliAdapter MCP transport", () => { }); }), }); - const request: LocalAIChatRequest = { - requestId: "restricted", - conversationId: "memory-curator", - turnId: "memory-turn", - providerId: "codex-cli", - modelId: "gpt-test", - operation: { - kind: "append", - message: { role: "user", content: "return json" }, - }, - }; - 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 expect( - adapter.prepareRun(request, status, { - tools: [], - executionPolicy: "text-only", - requestInteraction: vi.fn(async () => ({ approved: false })), - }), + adapter.prepareRun( + request("failed-restricted", "return json"), + status(), + { + tools: [], + executionPolicy: "text-only", + requestInteraction: vi.fn(async () => ({ approved: false })), + }, + ), ).rejects.toMatchObject({ code: "LOCAL_AI_TEXT_ONLY_POLICY_UNAVAILABLE", }); 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 d4976fb4..f061ccbb 100644 --- a/packages/app/src/electron/ai/__tests__/codex-cli.test.ts +++ b/packages/app/src/electron/ai/__tests__/codex-cli.test.ts @@ -34,6 +34,7 @@ describe("CodexCliAdapter", () => { const run = await adapter.prepareRun(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 23cf2a5b..326eb811 100644 --- a/packages/app/src/electron/ai/agent-tools.ts +++ b/packages/app/src/electron/ai/agent-tools.ts @@ -3,9 +3,18 @@ import type { AgentSandbox } from "@/shared/types/workspace"; import { z, type ZodRawShape, type ZodTypeAny } from "zod"; import { canonicalizeToolInputForSandbox } from "./sandbox"; +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 9af64f2d..ca68dbad 100644 --- a/packages/app/src/electron/ai/provider-adapter.ts +++ b/packages/app/src/electron/ai/provider-adapter.ts @@ -1,7 +1,11 @@ import type { AgentSandbox } from "@/shared/types/workspace"; import type { LocalAIChatRequest } from "@/shared/types/local-ai"; import type { LanguageModel, ProviderMetadata } from "ai"; -import type { AgentTool, AgentToolInteraction } from "./agent-tools"; +import type { + AgentTool, + AgentToolInteraction, + NativeMcpServer, +} from "./agent-tools"; import type { ProviderSessionBinding } from "./session/types"; import type { LocalAiProviderId, LocalAiProviderStatus } from "./types"; @@ -29,6 +33,9 @@ export type LocalAiProviderExecutionPolicy = "interactive" | "text-only"; export interface LocalAiProviderRunContext { session?: ProviderSessionBinding; tools: AgentTool[]; + nativeMcpServers?: Readonly< + Record + >; executionPolicy?: LocalAiProviderExecutionPolicy; /** Always supplied by LocalAiRuntime; optional for direct adapter callers. */ sandbox?: AgentSandbox; diff --git a/packages/app/src/electron/ai/providers/claude-code.ts b/packages/app/src/electron/ai/providers/claude-code.ts index b8597140..6c0ae76a 100644 --- a/packages/app/src/electron/ai/providers/claude-code.ts +++ b/packages/app/src/electron/ai/providers/claude-code.ts @@ -93,6 +93,29 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter { tools.length > 0 ? createSdkMcpServer({ name: "convera", tools }) : undefined; + const nativeMcpDefinitions = context.nativeMcpServers ?? {}; + const nativeMcpServers = textOnly + ? {} + : Object.fromEntries( + Object.entries(nativeMcpDefinitions).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 = textOnly + ? [] + : Object.entries(nativeMcpDefinitions).flatMap(([serverName, server]) => + server.toolNames.map((toolName) => `mcp__${serverName}__${toolName}`), + ); const fallbackRoot = request.options?.cwd ?? process.cwd(); const sandbox = context.sandbox ?? { @@ -106,14 +129,18 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter { pathToClaudeCodeExecutable: status.executablePath, cwd: sandbox.writableRoots[0] ?? sandbox.root, resume: context.session?.nativeSessionId, - mcpServers: textOnly - ? {} - : mcpServer - ? { convera: mcpServer } - : undefined, - allowedTools: exposedTools.map( - (definition) => `mcp__convera__${definition.name}`, - ), + mcpServers: + Object.keys(mcpServers).length > 0 + ? mcpServers + : textOnly + ? {} + : undefined, + allowedTools: [ + ...exposedTools.map( + (definition) => `mcp__convera__${definition.name}`, + ), + ...nativeAllowedTools, + ], ...(textOnly ? { permissionMode: "dontAsk" as const, diff --git a/packages/app/src/electron/ai/providers/codex-cli.ts b/packages/app/src/electron/ai/providers/codex-cli.ts index 8409baa7..69110779 100644 --- a/packages/app/src/electron/ai/providers/codex-cli.ts +++ b/packages/app/src/electron/ai/providers/codex-cli.ts @@ -190,6 +190,26 @@ export class CodexCliAdapter implements LocalAiProviderAdapter { definitions: context.tools, }) : undefined; + const nativeMcpServers = textOnly + ? {} + : 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, @@ -259,7 +279,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: textOnly ? "never" : "on-request", sandboxPolicy: textOnly 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/ai/runtime.ts b/packages/app/src/electron/ai/runtime.ts index cdc69419..bc91d19c 100644 --- a/packages/app/src/electron/ai/runtime.ts +++ b/packages/app/src/electron/ai/runtime.ts @@ -731,12 +731,35 @@ export class LocalAiRuntime implements LocalAIRuntimeService { ); } + const toolGroups = + this.executionPolicy === "text-only" + ? [] + : await this.getToolGroups(); + controller.signal.throwIfAborted(); + const nativeMcpServers = + this.executionPolicy === "text-only" + ? {} + : Object.fromEntries( + toolGroups.flatMap((group) => + group.nativeMcpServer + ? [ + [ + group.serverName, + { + ...group.nativeMcpServer, + toolNames: group.tools.map((tool) => tool.name), + }, + ], + ] + : [], + ), + ); const tools = this.executionPolicy === "text-only" ? [] : this.mergeTools( createAgentToolCatalog({ - groups: await this.getToolGroups(), + groups: toolGroups.filter((group) => !group.nativeMcpServer), executeTool: this.executeTool, requestInteraction, sandbox, @@ -747,6 +770,7 @@ export class LocalAiRuntime implements LocalAIRuntimeService { const run = await adapter.prepareRun(trustedRequest, probeStatus, { session: resumableBinding, tools, + nativeMcpServers, executionPolicy: this.executionPolicy, sandbox, requestInteraction, diff --git a/packages/app/src/electron/main.ts b/packages/app/src/electron/main.ts index 502fb1c8..e9d2f4ad 100644 --- a/packages/app/src/electron/main.ts +++ b/packages/app/src/electron/main.ts @@ -6,7 +6,7 @@ import { join } from "node:path"; import { getLogger, initializeLogger } from "@/electron/logger"; import { callTool, - getAllTools, + getAgentToolGroups, getMCPHub, initializeMCPHub, mcpToolCall, @@ -166,7 +166,7 @@ app.whenReady().then(async () => { }, getToolGroups: async () => { await initializeMCPHub(); - return getAllTools(); + return getAgentToolGroups(); }, executeTool: (serverName, toolName, input) => serverName.toLowerCase() === "builtin" 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/connection.ts b/packages/app/src/electron/mcp/connection.ts index c14f4344..ae4d8e1e 100644 --- a/packages/app/src/electron/mcp/connection.ts +++ b/packages/app/src/electron/mcp/connection.ts @@ -32,6 +32,7 @@ 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 @@ -244,6 +245,13 @@ export class MCPConnection extends EventEmitter { return typeof entry[1] === "string"; }), ); + if (this.config.managed && actualCommand === "cua-driver") { + actualCommand = resolveManagedStdioExecutable( + 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,22 @@ 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; + } + /** * Resolve environment variables in configuration (Electron-optimized) */ 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..bc189ce9 --- /dev/null +++ b/packages/app/src/electron/mcp/hub.test.ts @@ -0,0 +1,190 @@ +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 { 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: { + 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(() => { + 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); + + 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()); + vi.spyOn( + managedServers, + "resolveManagedStdioExecutable", + ).mockImplementation(() => { + throw new Error("executable 'cua-driver' was not found on 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 { + 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/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/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 new file mode 100644 index 00000000..02838932 --- /dev/null +++ b/packages/app/src/electron/mcp/managed-servers.ts @@ -0,0 +1,106 @@ +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"; + +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, + }; +} + +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 0288f1d1..3b2d806e 100644 --- a/packages/app/src/electron/mcp/runtime-catalog.test.ts +++ b/packages/app/src/electron/mcp/runtime-catalog.test.ts @@ -1,46 +1,76 @@ 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 { InMemorySessionStateRepository } from "../ai/session/repository"; -import { cleanupMCPHub, getAllTools, initializeMCPHub } from "./index"; +import { MCPConnection } from "./connection"; +import { MCPHub } from "./hub"; +import { cleanupMCPHub, getAgentToolGroups, initializeMCPHub } from "./index"; + +vi.mock("./managed-servers", async (importOriginal) => ({ + ...(await importOriginal()), + resolveManagedStdioExecutable: vi.fn(() => "/test/cua-driver"), +})); + +function providerAdapter( + providerId: "codex-cli" | "claude-code", + prepareRun: LocalAiProviderAdapter["prepareRun"], +): LocalAiProviderAdapter { + return { + id: providerId, + enforcesSandbox: providerId === "codex-cli", + getStatus: vi.fn(async () => ({ + ...LOCAL_AI_PROVIDER_DESCRIPTORS[providerId], + available: true, + authenticated: true, + checkedAt: new Date(0).toISOString(), + })), + prepareRun, + dispose: vi.fn(async () => undefined), + }; +} describe("main-process agent tool catalog", () => { afterEach(async () => { + vi.restoreAllMocks(); await cleanupMCPHub(); }); - it("provides every builtin tool to startChat after MCP initialization", async () => { + 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("provides current builtins to startChat without the removed computer control", async () => { const prepareRun = vi.fn( async () => ({ model: {} as LanguageModel, getNativeSessionId: () => "thread-runtime-catalog", }), ); - const adapter: LocalAiProviderAdapter = { - id: "codex-cli", - enforcesSandbox: true, - getStatus: vi.fn(async () => ({ - ...LOCAL_AI_PROVIDER_DESCRIPTORS["codex-cli"], - available: true, - authenticated: true, - checkedAt: new Date(0).toISOString(), - })), - prepareRun, - dispose: vi.fn(async () => undefined), - }; const configPath = join( tmpdir(), `convera-mcp-runtime-catalog-${process.pid}.json`, ); const runtime = new LocalAiRuntime({ - adapters: [adapter], + adapters: [providerAdapter("codex-cli", prepareRun)], getToolGroups: async () => { await initializeMCPHub(configPath); - return getAllTools(); + return getAgentToolGroups(); }, streamInvoker: () => ({ toUIMessageStream: async function* () { @@ -70,11 +100,107 @@ describe("main-process agent tool catalog", () => { const context = prepareRun.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", ]); + expect(context?.tools.map((tool) => tool.qualifiedName)).not.toContain( + "builtin:computer_control", + ); await runtime.dispose(); }); + + it.each(["codex-cli", "claude-code"] as const)( + "provides connected Cua natively and current 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, + }); + + const prepareRun = vi.fn( + async () => ({ + model: {} as LanguageModel, + getNativeSessionId: () => `thread-${providerId}`, + }), + ); + const configPath = join( + tmpdir(), + `convera-mcp-native-catalog-${providerId}-${process.pid}.json`, + ); + const runtime = new LocalAiRuntime({ + adapters: [providerAdapter(providerId, prepareRun)], + getToolGroups: async () => { + await initializeMCPHub(configPath); + return getAgentToolGroups(); + }, + streamInvoker: () => ({ + toUIMessageStream: async function* () { + yield { type: "finish" as const, finishReason: "stop" as const }; + }, + providerMetadata: Promise.resolve({}), + }), + sessionRepository: new InMemorySessionStateRepository(), + }); + + await runtime.startChat( + { + requestId: `runtime-catalog-${providerId}`, + conversationId: `conversation-runtime-catalog-${providerId}`, + turnId: `turn-runtime-catalog-${providerId}`, + providerId, + operation: { + kind: "append", + message: { role: "user", content: "List available tools." }, + }, + }, + vi.fn(), + ); + + const context = prepareRun.mock.calls[0]?.[2]; + expect(context?.tools.map((tool) => tool.qualifiedName)).toEqual([ + "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(); + }, + ); }); 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, 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 51af0fbd..ff46b029 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) { @@ -267,9 +290,33 @@ export function McpSettingsPage() { {server.description}

)} + {server.error && ( +

+ {server.error} +

+ )}
+ {server.managed && ( + + )} {(server.status === ConnectionStatus.ERROR || server.status === ConnectionStatus.DISCONNECTED) && !isRestarting && ( @@ -288,19 +335,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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6a5cece..e5f1e225 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) @@ -3564,8 +3564,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 @@ -6835,7 +6835,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 @@ -12549,11 +12549,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'} @@ -12563,7 +12558,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: