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: