From af8f1a07bb0d388f3a981961e0e64ecf274c8566 Mon Sep 17 00:00:00 2001 From: Jacob Kim Date: Fri, 10 Jul 2026 13:30:37 +0900 Subject: [PATCH] fix(mcp): refresh and discover provider servers --- electron/main/ipc/provider.ts | 409 +++++++++++------- electron/main/ipc/schemas.ts | 44 +- electron/main/mcp-discovery.ts | 125 ++++++ electron/preload.ts | 50 ++- electron/providers/claude-sdk-runtime.ts | 52 ++- .../providers/codex-app-server-runtime.ts | 222 +++++++--- electron/providers/mcp-config-refresh.ts | 134 ++++++ .../layout/settings-dialog-mcp-section.tsx | 80 +++- src/lib/providers/provider.types.ts | 154 +++++-- src/types/window-api.d.ts | 34 +- tests/mcp-config-refresh.test.ts | 104 +++++ tests/mcp-discovery.test.ts | 35 ++ 12 files changed, 1126 insertions(+), 317 deletions(-) create mode 100644 electron/main/mcp-discovery.ts create mode 100644 electron/providers/mcp-config-refresh.ts create mode 100644 tests/mcp-config-refresh.test.ts create mode 100644 tests/mcp-discovery.test.ts diff --git a/electron/main/ipc/provider.ts b/electron/main/ipc/provider.ts index 83ce9e5..5e79eed 100644 --- a/electron/main/ipc/provider.ts +++ b/electron/main/ipc/provider.ts @@ -1,8 +1,5 @@ import { ipcMain, webContents } from "electron"; -import { - invokeHostService, - onHostServiceEvent, -} from "../host-service-client"; +import { invokeHostService, onHostServiceEvent } from "../host-service-client"; import { ApprovalResponseArgsSchema, ClassifyRouteArgsSchema, @@ -15,6 +12,7 @@ import { CodexExternalConfigImportArgsSchema, CodexMcpOauthLoginArgsSchema, CodexMcpResourceReadArgsSchema, + McpDiscoveryArgsSchema, CodexPluginDetailArgsSchema, CodexPluginInstallArgsSchema, CodexPluginUninstallArgsSchema, @@ -39,6 +37,7 @@ import { UserInputResponseArgsSchema, SteerTurnArgsSchema, } from "./schemas"; +import { discoverMcpServers } from "../mcp-discovery"; function formatSchemaIssuePath(path: PropertyKey[]) { if (path.length === 0) { @@ -92,7 +91,9 @@ function registerProviderEventCleanup(contentsId: number) { } providerCleanupRegisteredContentsIds.add(contentsId); contents.once("destroyed", () => { - const streamIds = [...(providerStreamIdsByWebContentsId.get(contentsId) ?? [])]; + const streamIds = [ + ...(providerStreamIdsByWebContentsId.get(contentsId) ?? []), + ]; for (const streamId of streamIds) { forgetProviderStreamOwner(streamId); } @@ -112,8 +113,13 @@ function rememberProviderStreamOwner(args: { return; } forgetProviderStreamOwner(args.streamId); - providerOwnerWebContentsIdByStreamId.set(args.streamId, args.ownerWebContentsId); - const streamIds = providerStreamIdsByWebContentsId.get(args.ownerWebContentsId) ?? new Set(); + providerOwnerWebContentsIdByStreamId.set( + args.streamId, + args.ownerWebContentsId, + ); + const streamIds = + providerStreamIdsByWebContentsId.get(args.ownerWebContentsId) ?? + new Set(); streamIds.add(args.streamId); providerStreamIdsByWebContentsId.set(args.ownerWebContentsId, streamIds); registerProviderEventCleanup(args.ownerWebContentsId); @@ -167,20 +173,23 @@ export function registerProviderHandlers() { return invokeHostService("provider.stream-turn", parsedArgs.data); }); - ipcMain.handle("provider:start-stream-turn", async (_event, args: unknown) => { - const parsedArgs = StreamTurnArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - streamId: "", - message: formatSchemaFailureMessage({ - issues: parsedArgs.error.issues, - fallback: "IPC schema rejected provider request.", - }), - }; - } - return invokeHostService("provider.start-stream-turn", parsedArgs.data); - }); + ipcMain.handle( + "provider:start-stream-turn", + async (_event, args: unknown) => { + const parsedArgs = StreamTurnArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + streamId: "", + message: formatSchemaFailureMessage({ + issues: parsedArgs.error.issues, + fallback: "IPC schema rejected provider request.", + }), + }; + } + return invokeHostService("provider.start-stream-turn", parsedArgs.data); + }, + ); ipcMain.handle("provider:start-push-turn", async (event, args: unknown) => { const parsedArgs = StreamTurnArgsSchema.safeParse(args); @@ -196,7 +205,10 @@ export function registerProviderHandlers() { }; } - const result = await invokeHostService("provider.start-push-turn", parsedArgs.data); + const result = await invokeHostService( + "provider.start-push-turn", + parsedArgs.data, + ); if (result.ok) { rememberProviderStreamOwner({ streamId: result.streamId, @@ -302,29 +314,41 @@ export function registerProviderHandlers() { }); }); - ipcMain.handle("provider:get-connected-tool-status", (_event, args: unknown) => { - const parsedArgs = ConnectedToolStatusArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - providerId: "claude-code" as const, - detail: "Invalid connected-tool status request.", - tools: [], - }; - } - return invokeHostService("provider.get-connected-tool-status", parsedArgs.data); - }); + ipcMain.handle( + "provider:get-connected-tool-status", + (_event, args: unknown) => { + const parsedArgs = ConnectedToolStatusArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + providerId: "claude-code" as const, + detail: "Invalid connected-tool status request.", + tools: [], + }; + } + return invokeHostService( + "provider.get-connected-tool-status", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:get-claude-context-usage", (_event, args: unknown) => { - const parsedArgs = ClaudeRuntimeActionArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Claude context usage request.", - }; - } - return invokeHostService("provider.get-claude-context-usage", parsedArgs.data); - }); + ipcMain.handle( + "provider:get-claude-context-usage", + (_event, args: unknown) => { + const parsedArgs = ClaudeRuntimeActionArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Claude context usage request.", + }; + } + return invokeHostService( + "provider.get-claude-context-usage", + parsedArgs.data, + ); + }, + ); ipcMain.handle("provider:reload-claude-plugins", (_event, args: unknown) => { const parsedArgs = ClaudeRuntimeActionArgsSchema.safeParse(args); @@ -349,66 +373,100 @@ export function registerProviderHandlers() { return invokeHostService("provider.get-codex-mcp-status", parsedArgs.data); }); - ipcMain.handle("provider:get-codex-model-catalog", (_event, args: unknown) => { - const parsedArgs = CodexRuntimeActionArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex model catalog request.", - models: [], - }; - } - return invokeHostService("provider.get-codex-model-catalog", parsedArgs.data); - }); + ipcMain.handle( + "provider:discover-mcp-servers", + async (_event, args: unknown) => { + const parsedArgs = McpDiscoveryArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + servers: [], + errors: ["Invalid MCP discovery request."], + discoveredAt: Date.now(), + }; + } + return discoverMcpServers(parsedArgs.data); + }, + ); - ipcMain.handle("provider:get-codex-app-server-snapshot", (_event, args: unknown) => { - const parsedArgs = CodexRuntimeActionArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex App Server snapshot request.", - sectionErrors: {}, - }; - } - return invokeHostService( - "provider.get-codex-app-server-snapshot", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:get-codex-model-catalog", + (_event, args: unknown) => { + const parsedArgs = CodexRuntimeActionArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex model catalog request.", + models: [], + }; + } + return invokeHostService( + "provider.get-codex-model-catalog", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:get-rate-limits-snapshot", (_event, args: unknown) => { - const parsedArgs = RateLimitsSnapshotArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - claude: { - source: "unavailable", - session: null, - weekly: null, - error: "Invalid rate-limits snapshot request.", - }, - codex: { - source: "unavailable", - buckets: [], - error: "Invalid rate-limits snapshot request.", - }, - }; - } - return invokeHostService( - "provider.get-rate-limits-snapshot", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:get-codex-app-server-snapshot", + (_event, args: unknown) => { + const parsedArgs = CodexRuntimeActionArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex App Server snapshot request.", + sectionErrors: {}, + }; + } + return invokeHostService( + "provider.get-codex-app-server-snapshot", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:get-codex-plugin-detail", (_event, args: unknown) => { - const parsedArgs = CodexPluginDetailArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex plugin detail request.", - }; - } - return invokeHostService("provider.get-codex-plugin-detail", parsedArgs.data); - }); + ipcMain.handle( + "provider:get-rate-limits-snapshot", + (_event, args: unknown) => { + const parsedArgs = RateLimitsSnapshotArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + claude: { + source: "unavailable", + session: null, + weekly: null, + error: "Invalid rate-limits snapshot request.", + }, + codex: { + source: "unavailable", + buckets: [], + error: "Invalid rate-limits snapshot request.", + }, + }; + } + return invokeHostService( + "provider.get-rate-limits-snapshot", + parsedArgs.data, + ); + }, + ); + + ipcMain.handle( + "provider:get-codex-plugin-detail", + (_event, args: unknown) => { + const parsedArgs = CodexPluginDetailArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex plugin detail request.", + }; + } + return invokeHostService( + "provider.get-codex-plugin-detail", + parsedArgs.data, + ); + }, + ); ipcMain.handle("provider:install-codex-plugin", (_event, args: unknown) => { const parsedArgs = CodexPluginInstallArgsSchema.safeParse(args); @@ -431,7 +489,10 @@ export function registerProviderHandlers() { detail: "Invalid Codex plugin uninstall request.", }; } - return invokeHostService("provider.uninstall-codex-plugin", parsedArgs.data); + return invokeHostService( + "provider.uninstall-codex-plugin", + parsedArgs.data, + ); }); ipcMain.handle( @@ -452,31 +513,40 @@ export function registerProviderHandlers() { }, ); - ipcMain.handle("provider:start-codex-mcp-oauth-login", (_event, args: unknown) => { - const parsedArgs = CodexMcpOauthLoginArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex MCP OAuth login request.", - }; - } - return invokeHostService( - "provider.start-codex-mcp-oauth-login", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:start-codex-mcp-oauth-login", + (_event, args: unknown) => { + const parsedArgs = CodexMcpOauthLoginArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex MCP OAuth login request.", + }; + } + return invokeHostService( + "provider.start-codex-mcp-oauth-login", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:read-codex-mcp-resource", (_event, args: unknown) => { - const parsedArgs = CodexMcpResourceReadArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex MCP resource read request.", - contents: [], - }; - } - return invokeHostService("provider.read-codex-mcp-resource", parsedArgs.data); - }); + ipcMain.handle( + "provider:read-codex-mcp-resource", + (_event, args: unknown) => { + const parsedArgs = CodexMcpResourceReadArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex MCP resource read request.", + contents: [], + }; + } + return invokeHostService( + "provider.read-codex-mcp-resource", + parsedArgs.data, + ); + }, + ); ipcMain.handle("provider:rename-codex-thread", (_event, args: unknown) => { const parsedArgs = CodexThreadRenameArgsSchema.safeParse(args); @@ -555,47 +625,56 @@ export function registerProviderHandlers() { return invokeHostService("provider.start-codex-review", parsedArgs.data); }); - ipcMain.handle("provider:import-codex-external-config", (_event, args: unknown) => { - const parsedArgs = CodexExternalConfigImportArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex external config import request.", - }; - } - return invokeHostService( - "provider.import-codex-external-config", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:import-codex-external-config", + (_event, args: unknown) => { + const parsedArgs = CodexExternalConfigImportArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex external config import request.", + }; + } + return invokeHostService( + "provider.import-codex-external-config", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:write-codex-config-value", (_event, args: unknown) => { - const parsedArgs = CodexConfigValueWriteArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex config write request.", - }; - } - return invokeHostService( - "provider.write-codex-config-value", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:write-codex-config-value", + (_event, args: unknown) => { + const parsedArgs = CodexConfigValueWriteArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex config write request.", + }; + } + return invokeHostService( + "provider.write-codex-config-value", + parsedArgs.data, + ); + }, + ); - ipcMain.handle("provider:batch-write-codex-config", (_event, args: unknown) => { - const parsedArgs = CodexConfigBatchWriteArgsSchema.safeParse(args); - if (!parsedArgs.success) { - return { - ok: false, - detail: "Invalid Codex config batch write request.", - }; - } - return invokeHostService( - "provider.batch-write-codex-config", - parsedArgs.data, - ); - }); + ipcMain.handle( + "provider:batch-write-codex-config", + (_event, args: unknown) => { + const parsedArgs = CodexConfigBatchWriteArgsSchema.safeParse(args); + if (!parsedArgs.success) { + return { + ok: false, + detail: "Invalid Codex config batch write request.", + }; + } + return invokeHostService( + "provider.batch-write-codex-config", + parsedArgs.data, + ); + }, + ); ipcMain.handle("provider:suggest-task-name", (_event, args: unknown) => { const parsed = SuggestTaskNameArgsSchema.safeParse(args); diff --git a/electron/main/ipc/schemas.ts b/electron/main/ipc/schemas.ts index e97c6e3..d2e2671 100644 --- a/electron/main/ipc/schemas.ts +++ b/electron/main/ipc/schemas.ts @@ -7,6 +7,10 @@ export const ProviderIdSchema = z.union([ z.literal("codex"), ]); +export const McpDiscoveryArgsSchema = z + .object({ cwd: z.string().max(4096).optional() }) + .strict(); + export const SuggestTaskNameArgsSchema = z .object({ prompt: z.string().max(2000), @@ -823,22 +827,30 @@ export const CodexThreadRollbackArgsSchema = z .strict(); export const CodexReviewTargetSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal("uncommittedChanges"), - }).strict(), - z.object({ - type: z.literal("baseBranch"), - baseBranch: z.string().min(1).max(200), - }).strict(), - z.object({ - type: z.literal("commit"), - sha: z.string().min(1).max(200), - title: z.string().max(200).optional(), - }).strict(), - z.object({ - type: z.literal("custom"), - instructions: z.string().min(1).max(20_000), - }).strict(), + z + .object({ + type: z.literal("uncommittedChanges"), + }) + .strict(), + z + .object({ + type: z.literal("baseBranch"), + baseBranch: z.string().min(1).max(200), + }) + .strict(), + z + .object({ + type: z.literal("commit"), + sha: z.string().min(1).max(200), + title: z.string().max(200).optional(), + }) + .strict(), + z + .object({ + type: z.literal("custom"), + instructions: z.string().min(1).max(20_000), + }) + .strict(), ]); export const CodexReviewStartArgsSchema = z diff --git a/electron/main/mcp-discovery.ts b/electron/main/mcp-discovery.ts new file mode 100644 index 0000000..b1b9b35 --- /dev/null +++ b/electron/main/mcp-discovery.ts @@ -0,0 +1,125 @@ +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; +import type { + McpDiscoveryResponse, + McpDiscoveredServer, +} from "../../src/lib/providers/provider.types"; + +type Source = McpDiscoveredServer["sources"][number]; + +async function readOptionalJson(filePath: string) { + try { + return JSON.parse(await readFile(filePath, "utf8")) as Record< + string, + unknown + >; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + return { __error: error instanceof Error ? error.message : "Invalid JSON" }; + } +} + +function asRecord(value: unknown) { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function discoverClaudeEntries(args: { + document: Record; + source: Source; +}) { + const servers = + asRecord(args.document.mcpServers) ?? asRecord(args.document.mcp_servers); + if (!servers) return []; + return Object.entries(servers).map(([name, config]) => { + const value = asRecord(config) ?? {}; + return { + name, + sources: [args.source], + claude: { configured: true }, + codex: { configured: false }, + transport: typeof value.url === "string" ? "http" : "stdio", + } satisfies McpDiscoveredServer; + }); +} + +export function discoverCodexEntries(toml: string) { + const servers: McpDiscoveredServer[] = []; + const pattern = /^\s*\[mcp_servers\.(["']?)([^\]"']+)\1\]\s*$/gm; + let match: RegExpExecArray | null; + while ((match = pattern.exec(toml))) { + const next = toml.slice(match.index + match[0].length).search(/^\s*\[/m); + const section = toml.slice( + match.index, + next < 0 ? undefined : match.index + match[0].length + next, + ); + servers.push({ + name: match[2].trim(), + sources: ["codex-user"], + claude: { configured: false }, + codex: { configured: true }, + transport: /^\s*url\s*=/m.test(section) ? "http" : "stdio", + }); + } + return servers; +} + +function mergeServers(servers: McpDiscoveredServer[]) { + const merged = new Map(); + for (const server of servers) { + const key = server.name.toLowerCase(); + const existing = merged.get(key); + if (!existing) { + merged.set(key, server); + continue; + } + existing.sources.push(...server.sources); + existing.claude.configured ||= server.claude.configured; + existing.codex.configured ||= server.codex.configured; + if (existing.transport !== server.transport) existing.transport = "unknown"; + } + return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +export async function discoverMcpServers(args: { + cwd?: string; +}): Promise { + const cwd = args.cwd && path.isAbsolute(args.cwd) ? args.cwd : process.cwd(); + const home = homedir(); + const claudeFiles: Array<[string, Source]> = [ + [path.join(home, ".claude", "settings.json"), "claude-user"], + [path.join(cwd, ".claude", "settings.json"), "claude-project"], + [path.join(cwd, ".mcp.json"), "claude-project"], + ]; + const discovered: McpDiscoveredServer[] = []; + const errors: string[] = []; + for (const [filePath, source] of claudeFiles) { + const document = await readOptionalJson(filePath); + if (!document) continue; + if (typeof document.__error === "string") { + errors.push(`${path.basename(filePath)}: ${document.__error}`); + continue; + } + discovered.push(...discoverClaudeEntries({ document, source })); + } + try { + discovered.push( + ...discoverCodexEntries( + await readFile(path.join(home, ".codex", "config.toml"), "utf8"), + ), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") + errors.push( + `config.toml: ${error instanceof Error ? error.message : "Unreadable"}`, + ); + } + return { + ok: errors.length === 0, + servers: mergeServers(discovered), + errors, + discoveredAt: Date.now(), + }; +} diff --git a/electron/preload.ts b/electron/preload.ts index 90fc5f6..a99fafc 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -9,6 +9,7 @@ import type { CanonicalConversationRequest, ClaudeContextUsageResponse, CodexMcpStatusResponse, + McpDiscoveryResponse, ClaudePluginReloadResponse, CodexMutationResponse, CodexPluginDetailResponse, @@ -570,6 +571,11 @@ contextBridge.exposeInMainWorld("api", { "provider:get-codex-mcp-status", args, ) as Promise, + discoverMcpServers: (args: { cwd?: string }) => + ipcRenderer.invoke( + "provider:discover-mcp-servers", + args, + ) as Promise, getCodexModelCatalog: (args: { cwd?: string; runtimeOptions?: StreamTurnArgs["runtimeOptions"]; @@ -1205,12 +1211,20 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("scm:discard-file", args), getDiff: (args: { path: string; cwd?: string }) => ipcRenderer.invoke("scm:diff", args), - getGraph: (args: { cwd?: string; limit?: number; skip?: number; scope?: string }) => - ipcRenderer.invoke("scm:graph", args), + getGraph: (args: { + cwd?: string; + limit?: number; + skip?: number; + scope?: string; + }) => ipcRenderer.invoke("scm:graph", args), getCommitFiles: (args: { hash: string; cwd?: string }) => ipcRenderer.invoke("scm:commit-files", args), - getCommitDiff: (args: { hash: string; path: string; oldPath?: string; cwd?: string }) => - ipcRenderer.invoke("scm:commit-diff", args), + getCommitDiff: (args: { + hash: string; + path: string; + oldPath?: string; + cwd?: string; + }) => ipcRenderer.invoke("scm:commit-diff", args), getHistory: (args: { cwd?: string; limit?: number }) => ipcRenderer.invoke("scm:history", args), listBranches: (args: { cwd?: string; refreshRemote?: boolean }) => @@ -1231,18 +1245,29 @@ contextBridge.exposeInMainWorld("api", { ipcRenderer.invoke("scm:cherry-pick", args), revert: (args: { commit: string; cwd?: string }) => ipcRenderer.invoke("scm:revert", args), - reset: (args: { commit: string; mode: "soft" | "mixed" | "hard"; cwd?: string }) => - ipcRenderer.invoke("scm:reset", args), - createTag: (args: { name: string; commit?: string; message?: string; cwd?: string }) => - ipcRenderer.invoke("scm:create-tag", args), + reset: (args: { + commit: string; + mode: "soft" | "mixed" | "hard"; + cwd?: string; + }) => ipcRenderer.invoke("scm:reset", args), + createTag: (args: { + name: string; + commit?: string; + message?: string; + cwd?: string; + }) => ipcRenderer.invoke("scm:create-tag", args), deleteTag: (args: { name: string; cwd?: string }) => ipcRenderer.invoke("scm:delete-tag", args), renameBranch: (args: { from: string; to: string; cwd?: string }) => ipcRenderer.invoke("scm:rename-branch", args), deleteBranch: (args: { name: string; force?: boolean; cwd?: string }) => ipcRenderer.invoke("scm:delete-branch", args), - push: (args: { branch?: string; remote?: string; force?: boolean; cwd?: string }) => - ipcRenderer.invoke("scm:push", args), + push: (args: { + branch?: string; + remote?: string; + force?: boolean; + cwd?: string; + }) => ipcRenderer.invoke("scm:push", args), createPR: (args: { title: string; body?: string; @@ -1617,10 +1642,7 @@ contextBridge.exposeInMainWorld("api", { annotations?: LensAnnotation[]; message?: string; }>, - removeAnnotation: (args: { - workspaceId: string; - annotationId: string; - }) => + removeAnnotation: (args: { workspaceId: string; annotationId: string }) => ipcRenderer.invoke("lens:remove-annotation", args) as Promise<{ ok: boolean; message?: string; diff --git a/electron/providers/claude-sdk-runtime.ts b/electron/providers/claude-sdk-runtime.ts index 0ab1372..58e3e06 100644 --- a/electron/providers/claude-sdk-runtime.ts +++ b/electron/providers/claude-sdk-runtime.ts @@ -76,6 +76,10 @@ import { createBoundedBridgeEventCollector, measureBridgeEventBytes, } from "./provider-buffering"; +import { + getClaudeMcpConfigPaths, + McpConfigRefreshTracker, +} from "./mcp-config-refresh"; /** * Cache boundary marker for the claude-agent-sdk systemPrompt string[] API. @@ -2578,7 +2582,10 @@ export function mapClaudeMessageToEvents(args: { } const sessionIdByTask = new Map(); +const sessionMcpScopeByTask = new Map(); const activeRunByTask = new Map>(); +const claudeMcpConfigRefreshTracker = new McpConfigRefreshTracker(); +const freshClaudeSessionScopes = new Set(); export async function getClaudeCommandCatalog(args: { cwd?: string; @@ -2750,6 +2757,7 @@ export async function reloadClaudePlugins(args: { export function cleanupClaudeTask(taskId: string) { sessionIdByTask.delete(taskId); + sessionMcpScopeByTask.delete(taskId); activeRunByTask.delete(taskId); } @@ -2761,13 +2769,20 @@ function resolveSessionId(args: { return sessionIdByTask.get(taskKey) ?? args.fallbackSessionId?.trim(); } -function rememberSessionId(args: { taskId?: string; sessionId?: string }) { +function rememberSessionId(args: { + taskId?: string; + sessionId?: string; + mcpScopeKey?: string; +}) { const nextSessionId = args.sessionId?.trim(); if (!nextSessionId) { return; } const taskKey = args.taskId ?? "default"; sessionIdByTask.set(taskKey, nextSessionId); + if (args.mcpScopeKey) { + sessionMcpScopeByTask.set(taskKey, args.mcpScopeKey); + } } /** @@ -2951,12 +2966,38 @@ export async function streamClaudeWithSdk( return { ok: true }; }); + const claudeRuntimeEnv = buildClaudeEnv({ + executablePath: claudeExecutablePath, + }); + const claudeMcpConfigPaths = getClaudeMcpConfigPaths({ + cwd: runtimeCwd, + claudeConfigDir: claudeRuntimeEnv.CLAUDE_CONFIG_DIR, + }); + const claudeMcpScopeKey = `claude:${claudeRuntimeEnv.CLAUDE_CONFIG_DIR ?? "default"}:${runtimeCwd}`; + const claudeMcpRefresh = await claudeMcpConfigRefreshTracker.check({ + scopeKey: claudeMcpScopeKey, + paths: claudeMcpConfigPaths, + }); + if (claudeMcpRefresh.changed) { + // Claude resumes preserve the native session's MCP catalog. Begin fresh + // sessions after configuration changes; Stave still provides transcript + // context through the rendered provider prompt. + for (const [taskKey, scopeKey] of sessionMcpScopeByTask) { + if (scopeKey === claudeMcpScopeKey) { + sessionIdByTask.delete(taskKey); + } + } + freshClaudeSessionScopes.add(claudeMcpScopeKey); + } + const existingSessionId = resolveSessionId({ taskId: args.taskId, - fallbackSessionId: resolveProviderResumeSessionId({ - conversation: args.conversation, - fallbackResumeId: args.runtimeOptions?.claudeResumeSessionId, - }), + fallbackSessionId: freshClaudeSessionScopes.has(claudeMcpScopeKey) + ? undefined + : resolveProviderResumeSessionId({ + conversation: args.conversation, + fallbackResumeId: args.runtimeOptions?.claudeResumeSessionId, + }), }); const claudeSystemPrompt = buildClaudeSystemPrompt({ cwd: runtimeCwd, @@ -3411,6 +3452,7 @@ export async function streamClaudeWithSdk( rememberSessionId({ taskId: args.taskId, sessionId: (message as SDKSystemMessage).session_id, + mcpScopeKey: claudeMcpScopeKey, }); } if ( diff --git a/electron/providers/codex-app-server-runtime.ts b/electron/providers/codex-app-server-runtime.ts index c59e063..1e6c829 100644 --- a/electron/providers/codex-app-server-runtime.ts +++ b/electron/providers/codex-app-server-runtime.ts @@ -82,9 +82,18 @@ import { buildCodexDeveloperInstructions, buildCodexInstructionProfileKey, } from "./codex-runtime-config"; +import { + getCodexMcpConfigPaths, + McpConfigRefreshTracker, +} from "./mcp-config-refresh"; const threadIdByTask = new Map(); +const threadExecutableByTask = new Map(); const clientByExecutablePath = new Map(); +const codexMcpConfigRefreshTracker = new McpConfigRefreshTracker(); +const freshCodexThreadExecutables = new Set(); +const activeCodexTurnsByExecutable = new Map(); +const pendingMcpRefreshExecutables = new Set(); const APP_SERVER_INTERRUPT_GRACE_MS = 10_000; const CODEX_APP_SERVER_STDOUT_BUFFER_MAX_BYTES = 32 * 1024 * 1024; @@ -610,17 +619,25 @@ function buildThreadKey(args: { function resolveThreadId(args: { threadKey: string; + executablePath: string; fallbackThreadId?: string; }) { - return threadIdByTask.get(args.threadKey) ?? args.fallbackThreadId?.trim(); + return threadExecutableByTask.get(args.threadKey) === args.executablePath + ? (threadIdByTask.get(args.threadKey) ?? args.fallbackThreadId?.trim()) + : args.fallbackThreadId?.trim(); } -function rememberThreadId(args: { threadKey: string; threadId?: string }) { +function rememberThreadId(args: { + threadKey: string; + threadId?: string; + executablePath: string; +}) { const nextThreadId = args.threadId?.trim(); if (!nextThreadId) { return; } threadIdByTask.set(args.threadKey, nextThreadId); + threadExecutableByTask.set(args.threadKey, args.executablePath); } function resolveCodexResumeThreadFallback(args: { @@ -2005,8 +2022,36 @@ function getCodexAppServerClient(args: { executablePath: string }) { return client; } +function restartCodexAppServerForMcpConfigChange(executablePath: string) { + clientByExecutablePath + .get(executablePath) + ?.dispose("Restarting Codex App Server after MCP configuration change."); + clientByExecutablePath.delete(executablePath); + for (const [threadKey, threadExecutablePath] of threadExecutableByTask) { + if (threadExecutablePath === executablePath) { + threadIdByTask.delete(threadKey); + threadExecutableByTask.delete(threadKey); + } + } + freshCodexThreadExecutables.add(executablePath); +} + +function finishCodexTurn(executablePath: string) { + const activeTurns = + (activeCodexTurnsByExecutable.get(executablePath) ?? 1) - 1; + if (activeTurns > 0) { + activeCodexTurnsByExecutable.set(executablePath, activeTurns); + return; + } + activeCodexTurnsByExecutable.delete(executablePath); + if (pendingMcpRefreshExecutables.delete(executablePath)) { + restartCodexAppServerForMcpConfigChange(executablePath); + } +} + async function ensureCodexThread(args: { client: CodexAppServerClient; + executablePath: string; taskId?: string; cwd: string; conversation?: StreamTurnArgs["conversation"]; @@ -2019,10 +2064,13 @@ async function ensureCodexThread(args: { }); const resumeThreadId = resolveThreadId({ threadKey, - fallbackThreadId: resolveCodexResumeThreadFallback({ - conversation: args.conversation, - runtimeOptions: args.runtimeOptions, - }), + executablePath: args.executablePath, + fallbackThreadId: freshCodexThreadExecutables.has(args.executablePath) + ? undefined + : resolveCodexResumeThreadFallback({ + conversation: args.conversation, + runtimeOptions: args.runtimeOptions, + }), }); const response = resumeThreadId @@ -2041,7 +2089,11 @@ async function ensureCodexThread(args: { }), ); const threadId = response.thread.id; - rememberThreadId({ threadKey, threadId }); + rememberThreadId({ + threadKey, + threadId, + executablePath: args.executablePath, + }); return { threadId, threadKey }; } @@ -2050,6 +2102,7 @@ export function cleanupCodexAppServerTask(taskId: string) { for (const threadKey of threadIdByTask.keys()) { if (threadKey.startsWith(keyPrefix)) { threadIdByTask.delete(threadKey); + threadExecutableByTask.delete(threadKey); } } } @@ -2448,17 +2501,15 @@ function mapCodexConfigSnapshot(response: any): CodexConfigSnapshot { : {}, origins, layers: Array.isArray(response?.layers) - ? response.layers.map( - (layer: any): CodexConfigLayerSnapshot => ({ - name: toCodexConfigLayerDisplayValue(layer?.name), - version: toCodexConfigLayerDisplayValue(layer?.version, ""), - disabledReason: - typeof layer?.disabledReason === "string" - ? layer.disabledReason - : null, - config: layer?.config ?? null, - }), - ) + ? response.layers.map((layer: any): CodexConfigLayerSnapshot => ({ + name: toCodexConfigLayerDisplayValue(layer?.name), + version: toCodexConfigLayerDisplayValue(layer?.version, ""), + disabledReason: + typeof layer?.disabledReason === "string" + ? layer.disabledReason + : null, + config: layer?.config ?? null, + })) : [], }; } @@ -2699,37 +2750,35 @@ export async function getCodexAppServerSnapshot(args: { forceReload: false, }); snapshot.skills = Array.isArray(response?.data) - ? response.data.map( - (entry: any): CodexSkillCatalogGroup => ({ - cwd: String(entry?.cwd ?? cwd), - skills: Array.isArray(entry?.skills) - ? entry.skills.map((skill: any) => ({ - name: String(skill?.name ?? ""), - description: String(skill?.description ?? ""), - shortDescription: - typeof skill?.shortDescription === "string" - ? skill.shortDescription - : typeof skill?.interface?.short_description === - "string" - ? skill.interface.short_description - : null, - path: String(skill?.path ?? ""), - scope: - typeof skill?.scope === "string" - ? skill.scope - : "unknown", - enabled: Boolean(skill?.enabled), - })) - : [], - errors: Array.isArray(entry?.errors) - ? entry.errors.map((error: any) => - typeof error?.message === "string" - ? error.message - : JSON.stringify(error ?? {}), - ) - : [], - }), - ) + ? response.data.map((entry: any): CodexSkillCatalogGroup => ({ + cwd: String(entry?.cwd ?? cwd), + skills: Array.isArray(entry?.skills) + ? entry.skills.map((skill: any) => ({ + name: String(skill?.name ?? ""), + description: String(skill?.description ?? ""), + shortDescription: + typeof skill?.shortDescription === "string" + ? skill.shortDescription + : typeof skill?.interface?.short_description === + "string" + ? skill.interface.short_description + : null, + path: String(skill?.path ?? ""), + scope: + typeof skill?.scope === "string" + ? skill.scope + : "unknown", + enabled: Boolean(skill?.enabled), + })) + : [], + errors: Array.isArray(entry?.errors) + ? entry.errors.map((error: any) => + typeof error?.message === "string" + ? error.message + : JSON.stringify(error ?? {}), + ) + : [], + })) : []; }), loadSection("plugins", async () => { @@ -3638,10 +3687,7 @@ export async function reviewCodexWorktreeDiff(args: { if (message.method === "item/completed") { const item = isRecord(params.item) ? params.item : null; - if ( - item?.type === "agentMessage" && - typeof item.text === "string" - ) { + if (item?.type === "agentMessage" && typeof item.text === "string") { latestAgentMessageText = item.text; } return; @@ -3697,7 +3743,8 @@ export async function reviewCodexWorktreeDiff(args: { } if (turnResponse.turn.status === "failed") { failureMessage = - turnResponse.turn.error?.message ?? "Codex App Server review turn failed."; + turnResponse.turn.error?.message ?? + "Codex App Server review turn failed."; } if ( turnResponse.turn.status !== "completed" && @@ -3764,9 +3811,34 @@ export async function streamCodexWithAppServer( return unavailableEvents; } + const codexRuntimeEnv = buildCodexCliEnv({ + executablePath: codexExecutablePath, + }); + const codexMcpRefresh = await codexMcpConfigRefreshTracker.check({ + scopeKey: `codex:${codexRuntimeEnv.CODEX_HOME ?? "default"}`, + paths: getCodexMcpConfigPaths({ + cwd: runtimeCwd, + codexHome: codexRuntimeEnv.CODEX_HOME, + }), + }); + if (codexMcpRefresh.changed) { + // App Server reads config.toml only when its process starts, while resumed + // threads retain their MCP catalog. Restart and start fresh native threads + // so servers added after the Stave conversation began are usable. + if ((activeCodexTurnsByExecutable.get(codexExecutablePath) ?? 0) > 0) { + pendingMcpRefreshExecutables.add(codexExecutablePath); + } else { + restartCodexAppServerForMcpConfigChange(codexExecutablePath); + } + } + const client = getCodexAppServerClient({ executablePath: codexExecutablePath, }); + activeCodexTurnsByExecutable.set( + codexExecutablePath, + (activeCodexTurnsByExecutable.get(codexExecutablePath) ?? 0) + 1, + ); try { const account = await client.request<{ account: unknown | null; @@ -3782,6 +3854,7 @@ export async function streamCodexWithAppServer( { type: "done" }, ]; events.forEach((event) => args.onEvent?.(event)); + finishCodexTurn(codexExecutablePath); return events; } } catch (error) { @@ -3796,16 +3869,35 @@ export async function streamCodexWithAppServer( { type: "done" }, ]; events.forEach((event) => args.onEvent?.(event)); + finishCodexTurn(codexExecutablePath); return events; } - const { threadId } = await ensureCodexThread({ - client, - taskId: args.taskId, - cwd: runtimeCwd, - conversation: args.conversation, - runtimeOptions: args.runtimeOptions, - }); + let threadId: string; + try { + ({ threadId } = await ensureCodexThread({ + client, + executablePath: codexExecutablePath, + taskId: args.taskId, + cwd: runtimeCwd, + conversation: args.conversation, + runtimeOptions: args.runtimeOptions, + })); + } catch (error) { + const events: BridgeEvent[] = [ + { + type: "error", + message: toCodexUserFacingErrorMessage({ + message: error instanceof Error ? error.message : String(error), + }), + recoverable: true, + }, + { type: "done" }, + ]; + events.forEach((event) => args.onEvent?.(event)); + finishCodexTurn(codexExecutablePath); + return events; + } const eventCollector = createBoundedBridgeEventCollector({ maxBytes: CODEX_APP_SERVER_COLLECTED_EVENTS_MAX_BYTES, @@ -4279,7 +4371,8 @@ export async function streamCodexWithAppServer( void client.respond(message.id as JsonRpcId, {}); return; case "account/chatgptAuthTokens/refresh": { - const params = (message.params ?? {}) as CodexChatgptAuthTokensRefreshParams; + const params = (message.params ?? + {}) as CodexChatgptAuthTokensRefreshParams; void (async () => { try { const response = await refreshCodexChatgptAuthTokens({ @@ -4313,9 +4406,7 @@ export async function streamCodexWithAppServer( if (message.method === "thread/goal/updated") { const goal = normalizeCodexThreadGoal(params.goal); const eventThreadId = - typeof params.threadId === "string" - ? params.threadId - : goal?.threadId; + typeof params.threadId === "string" ? params.threadId : goal?.threadId; if (eventThreadId === threadId && goal) { emitBridgeEvent(buildCodexGoalStatusEvent(goal)); } @@ -4994,5 +5085,6 @@ export async function streamCodexWithAppServer( } await elicitationPauseController.endAll(); unsubscribe(); + finishCodexTurn(codexExecutablePath); } } diff --git a/electron/providers/mcp-config-refresh.ts b/electron/providers/mcp-config-refresh.ts new file mode 100644 index 0000000..02670fd --- /dev/null +++ b/electron/providers/mcp-config-refresh.ts @@ -0,0 +1,134 @@ +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import path from "node:path"; + +type McpConfigPathOptions = { + cwd: string; + claudeConfigDir?: string; + codexHome?: string; +}; + +function resolveHomeRelativePath(args: { value?: string; fallback: string }) { + const value = args.value?.trim(); + return value && path.isAbsolute(value) ? value : args.fallback; +} + +export function getClaudeMcpConfigPaths(args: McpConfigPathOptions) { + const configDir = resolveHomeRelativePath({ + value: args.claudeConfigDir, + fallback: path.join(homedir(), ".claude"), + }); + return [ + path.join(configDir, "settings.json"), + path.join(configDir, "settings.local.json"), + path.join(homedir(), ".claude.json"), + path.join(args.cwd, ".claude", "settings.json"), + path.join(args.cwd, ".claude", "settings.local.json"), + path.join(args.cwd, ".mcp.json"), + ]; +} + +export function getCodexMcpConfigPaths(args: McpConfigPathOptions) { + const codexHome = resolveHomeRelativePath({ + value: args.codexHome, + fallback: path.join(homedir(), ".codex"), + }); + return [path.join(codexHome, "config.toml")]; +} + +async function getMcpConfigFingerprint(paths: readonly string[]) { + const entries = await Promise.all( + paths.map(async (filePath) => { + try { + const metadata = await stat(filePath); + return `${filePath}:${metadata.mtimeMs}:${metadata.size}`; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return `${filePath}:missing`; + } + // The provider will surface a native config error when it loads an + // unreadable file. Do not reset a healthy session for a stat failure. + return `${filePath}:unavailable`; + } + }), + ); + return entries.join("|"); +} + +/** + * Provider CLIs snapshot MCP discovery at native session/app-server startup. + * Track MCP-bearing config files between turns so callers can refresh safely. + */ +export type McpConfigRefreshResult = { + changed: boolean; + generation: number; + checkedAt: number; +}; + +type McpConfigRefreshScope = McpConfigRefreshResult & { + fingerprint: string | null; + pendingCheck?: Promise; +}; + +/** + * Tracks cheap file metadata separately from provider-native MCP probing. + * + * A scope is intentionally supplied by the runtime: a global tracker must not + * mistake moving between workspaces or CLI homes for a configuration change. + * Concurrent turn preflights for the same scope share one stat pass. + */ +export class McpConfigRefreshTracker { + private readonly scopes = new Map(); + + check(args: { + scopeKey: string; + paths: readonly string[]; + force?: boolean; + minIntervalMs?: number; + }): Promise { + const scope = this.scopes.get(args.scopeKey) ?? { + fingerprint: null, + generation: 0, + checkedAt: 0, + changed: false, + }; + this.scopes.set(args.scopeKey, scope); + + const minIntervalMs = args.minIntervalMs ?? 0; + if ( + !args.force && + scope.checkedAt > 0 && + Date.now() - scope.checkedAt < minIntervalMs + ) { + return Promise.resolve({ + changed: false, + generation: scope.generation, + checkedAt: scope.checkedAt, + }); + } + if (scope.pendingCheck) { + return scope.pendingCheck; + } + + scope.pendingCheck = getMcpConfigFingerprint(args.paths).then( + (fingerprint) => { + const changed = + scope.fingerprint !== null && scope.fingerprint !== fingerprint; + scope.fingerprint = fingerprint; + if (changed) { + scope.generation += 1; + } + scope.checkedAt = Date.now(); + scope.changed = changed; + return { + changed, + generation: scope.generation, + checkedAt: scope.checkedAt, + }; + }, + ); + return scope.pendingCheck.finally(() => { + scope.pendingCheck = undefined; + }); + } +} diff --git a/src/components/layout/settings-dialog-mcp-section.tsx b/src/components/layout/settings-dialog-mcp-section.tsx index c481ad1..dccccd3 100644 --- a/src/components/layout/settings-dialog-mcp-section.tsx +++ b/src/components/layout/settings-dialog-mcp-section.tsx @@ -1,9 +1,86 @@ +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui"; +import type { McpDiscoveryResponse } from "@/lib/providers/provider.types"; import { CodexMcpStatusCard, LocalMcpRequestLogCard, LocalMcpServerCard, } from "./settings-dialog-developer-section"; -import { SectionHeading, SectionStack } from "./settings-dialog.shared"; +import { + SectionHeading, + SectionStack, + SettingsCard, +} from "./settings-dialog.shared"; + +function DiscoveredMcpServersCard() { + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + + async function refresh() { + const discover = window.api?.provider?.discoverMcpServers; + if (!discover) return; + setBusy(true); + try { + setState(await discover({})); + } finally { + setBusy(false); + } + } + + useEffect(() => { + void refresh(); + }, []); + + return ( + void refresh()} + disabled={busy} + > + {busy ? "Refreshing..." : "Refresh"} + + } + > + {!state ? ( +

Loading configuration…

+ ) : null} + {state?.servers.length === 0 ? ( +

+ No MCP servers found in the current Claude or Codex configuration. +

+ ) : null} +
+ {state?.servers.map((server) => ( +
+
+

{server.name}

+

+ {server.sources.join(", ")} · {server.transport} +

+
+ + Claude {server.claude.configured ? "on" : "—"} · Codex{" "} + {server.codex.configured ? "on" : "—"} + +
+ ))} +
+ {state?.errors.map((error) => ( +

+ {error} +

+ ))} +
+ ); +} export function McpSection() { return ( @@ -13,6 +90,7 @@ export function McpSection() { description="Local MCP server status, request logs, and provider connection state." /> + diff --git a/src/lib/providers/provider.types.ts b/src/lib/providers/provider.types.ts index 309b041..29a1803 100644 --- a/src/lib/providers/provider.types.ts +++ b/src/lib/providers/provider.types.ts @@ -112,6 +112,21 @@ export interface CodexMcpStatusResponse { servers: CodexMcpServerStatusSnapshot[]; } +export interface McpDiscoveredServer { + name: string; + sources: Array<"claude-user" | "claude-project" | "codex-user">; + claude: { configured: boolean }; + codex: { configured: boolean }; + transport: "stdio" | "http" | "unknown"; +} + +export interface McpDiscoveryResponse { + ok: boolean; + servers: McpDiscoveredServer[]; + errors: string[]; + discoveredAt: number; +} + export interface CodexModelCatalogEntry { id: string; model: string; @@ -471,7 +486,12 @@ export interface CanonicalConversationRequest { mode: "chat" | "review"; history: CanonicalConversationMessage[]; input: CanonicalConversationMessage & { role: "user" }; - contextParts: Array; + contextParts: Array< + | FileContextPart + | CanonicalRetrievedContextPart + | ImageContextPart + | CanonicalSkillContextPart + >; resume?: { nativeSessionId?: string; }; @@ -500,37 +520,88 @@ export interface ProviderGoalSnapshot { export type NormalizedProviderEvent = | { type: "thinking"; text: string; isStreaming?: boolean } | { type: "text"; text: string; segmentId?: string } - | { type: "provider_session"; providerId: ProviderId; nativeSessionId: string } - | { type: "goal_status"; providerId: "codex"; goal: ProviderGoalSnapshot | null } | { - type: "usage"; - inputTokens: number; - outputTokens: number; - cacheReadTokens?: number; - cacheCreationTokens?: number; - totalCostUsd?: number; - ttftMs?: number; - } + type: "provider_session"; + providerId: ProviderId; + nativeSessionId: string; + } + | { + type: "goal_status"; + providerId: "codex"; + goal: ProviderGoalSnapshot | null; + } + | { + type: "usage"; + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + totalCostUsd?: number; + ttftMs?: number; + } | { type: "prompt_suggestions"; suggestions: string[] } - | { type: "tool"; toolUseId?: string; toolName: string; input: string; output?: string; state: ToolUsePart["state"] } - | { type: "tool_progress"; toolUseId: string; toolName: string; elapsedSeconds: number } - | { type: "tool_result"; tool_use_id: string; output: string; isError?: boolean; isPartial?: boolean } - | { type: "diff"; filePath: string; oldContent: string; newContent: string; status?: CodeDiffPart["status"] } - | { type: "approval"; toolName: string; requestId: string; description: string; input?: string } - | { type: "user_input"; toolName: string; requestId: string; questions: UserInputQuestion[] } + | { + type: "tool"; + toolUseId?: string; + toolName: string; + input: string; + output?: string; + state: ToolUsePart["state"]; + } + | { + type: "tool_progress"; + toolUseId: string; + toolName: string; + elapsedSeconds: number; + } + | { + type: "tool_result"; + tool_use_id: string; + output: string; + isError?: boolean; + isPartial?: boolean; + } + | { + type: "diff"; + filePath: string; + oldContent: string; + newContent: string; + status?: CodeDiffPart["status"]; + } + | { + type: "approval"; + toolName: string; + requestId: string; + description: string; + input?: string; + } + | { + type: "user_input"; + toolName: string; + requestId: string; + questions: UserInputQuestion[]; + } | { type: "plan_ready"; planText: string; sourceSegmentId?: string } | { - type: "system"; - content: string; - compactBoundary?: { - trigger?: string; - gitRef?: string; - }; - } + type: "system"; + content: string; + compactBoundary?: { + trigger?: string; + gitRef?: string; + }; + } | { type: "subagent_progress"; toolUseId?: string; content: string } - | { type: "model_resolved"; resolvedProviderId: ProviderId; resolvedModel: string } + | { + type: "model_resolved"; + resolvedProviderId: ProviderId; + resolvedModel: string; + } | { type: "error"; message: string; recoverable: boolean } - | { type: "done"; stop_reason?: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | string }; + | { + type: "done"; + stop_reason?: + "end_turn" | "max_tokens" | "stop_sequence" | "tool_use" | string; + }; export interface ProviderTurnRequest { turnId?: string; @@ -548,9 +619,16 @@ export interface ProviderRuntimeOptions { debug?: boolean; providerTimeoutMs?: number; claudeBinaryPath?: string; - claudePermissionMode?: "default" | "acceptEdits" | "bypassPermissions" | "plan" | "dontAsk" | "auto"; + claudePermissionMode?: + | "default" + | "acceptEdits" + | "bypassPermissions" + | "plan" + | "dontAsk" + | "auto"; /** How much plan-mode auto-approves non-mutating tool calls (Bash/Task/MCP). */ - claudePlanModeApprovalScope?: "strict" | "bash" | "bashAndTask" | "bashTaskAndMcp"; + claudePlanModeApprovalScope?: + "strict" | "bash" | "bashAndTask" | "bashTaskAndMcp"; claudeAllowDangerouslySkipPermissions?: boolean; claudeSandboxEnabled?: boolean; claudeAllowUnsandboxedCommands?: boolean; @@ -585,13 +663,7 @@ export interface ProviderRuntimeOptions { // "minimal" is a legacy value kept for persisted settings; the runtime maps // it to "low". "max" and "ultra" arrived with the GPT-5.6 Codex CLI scale. codexReasoningEffort?: - | "minimal" - | "low" - | "medium" - | "high" - | "xhigh" - | "max" - | "ultra"; + "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | "ultra"; codexWebSearch?: "disabled" | "cached" | "live"; codexShowRawReasoning?: boolean; codexReasoningSummary?: "auto" | "concise" | "detailed" | "none"; @@ -610,13 +682,19 @@ export interface ProviderRuntimeOptions { export interface ProviderAdapter { id: ProviderId; - runTurn: (args: ProviderTurnRequest) => AsyncGenerator; + runTurn: ( + args: ProviderTurnRequest, + ) => AsyncGenerator; } export interface ProviderEventSource { - streamTurn: (args: ProviderTurnRequest) => AsyncGenerator; + streamTurn: ( + args: ProviderTurnRequest, + ) => AsyncGenerator; } export interface ProviderEventNormalizer { - normalize: (args: { event: TRawEvent }) => NormalizedProviderEvent | NormalizedProviderEvent[]; + normalize: (args: { + event: TRawEvent; + }) => NormalizedProviderEvent | NormalizedProviderEvent[]; } diff --git a/src/types/window-api.d.ts b/src/types/window-api.d.ts index 758dc69..a770fce 100644 --- a/src/types/window-api.d.ts +++ b/src/types/window-api.d.ts @@ -8,6 +8,7 @@ import type { CanonicalConversationRequest, ClaudeContextUsageResponse, CodexMcpStatusResponse, + McpDiscoveryResponse, ClaudePluginReloadResponse, CodexMutationResponse, CodexPluginDetailResponse, @@ -182,6 +183,9 @@ interface WindowProviderApi { cwd?: string; runtimeOptions?: ProviderStreamTurnArgs["runtimeOptions"]; }) => Promise; + discoverMcpServers?: (args: { + cwd?: string; + }) => Promise; getCodexModelCatalog?: (args: { cwd?: string; runtimeOptions?: ProviderStreamTurnArgs["runtimeOptions"]; @@ -1601,10 +1605,7 @@ interface LensSessionProfileArgs { } type LensDownloadState = - | "progressing" - | "completed" - | "cancelled" - | "interrupted"; + "progressing" | "completed" | "cancelled" | "interrupted"; interface LensDownloadEntry { id: string; @@ -1659,12 +1660,7 @@ interface LensAnnotation { styleEdits?: LensStyleEdit[]; } -type LensAnnotationEventType = - | "add" - | "update" - | "remove" - | "clear" - | "submit"; +type LensAnnotationEventType = "add" | "update" | "remove" | "clear" | "submit"; interface LensAnnotationEventPayload { workspaceId: string; @@ -1700,11 +1696,19 @@ interface WindowLensApi { ) => Promise<{ ok: boolean; message?: string }>; createView?: ( args: LensSessionProfileArgs, - ) => Promise<{ ok: boolean; sessionScope?: LensSessionScope; message?: string }>; + ) => Promise<{ + ok: boolean; + sessionScope?: LensSessionScope; + message?: string; + }>; destroyView?: (args: { workspaceId: string }) => Promise<{ ok: boolean }>; clearSessionData?: ( args: LensSessionProfileArgs, - ) => Promise<{ ok: boolean; sessionScope?: LensSessionScope; message?: string }>; + ) => Promise<{ + ok: boolean; + sessionScope?: LensSessionScope; + message?: string; + }>; setBounds?: (args: { workspaceId: string; bounds: { x: number; y: number; width: number; height: number }; @@ -1760,7 +1764,11 @@ interface WindowLensApi { }>; listDownloads?: (args: { workspaceId: string; - }) => Promise<{ ok: boolean; entries?: LensDownloadEntry[]; message?: string }>; + }) => Promise<{ + ok: boolean; + entries?: LensDownloadEntry[]; + message?: string; + }>; getDom?: (args: { workspaceId: string; selector?: string; diff --git a/tests/mcp-config-refresh.test.ts b/tests/mcp-config-refresh.test.ts new file mode 100644 index 0000000..6ac6e48 --- /dev/null +++ b/tests/mcp-config-refresh.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + getClaudeMcpConfigPaths, + getCodexMcpConfigPaths, + McpConfigRefreshTracker, +} from "../electron/providers/mcp-config-refresh"; + +const tempDirectories: string[] = []; + +async function makeTempDirectory() { + const directory = await mkdtemp(path.join(tmpdir(), "stave-mcp-config-")); + tempDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => + rm(directory, { + recursive: true, + force: true, + }), + ), + ); +}); + +describe("MCP config refresh tracking", () => { + test("uses the same configured provider homes as runtime processes", () => { + expect( + getClaudeMcpConfigPaths({ + cwd: "/tmp/workspace", + claudeConfigDir: "/tmp/claude-home", + }), + ).toContain("/tmp/claude-home/settings.json"); + expect( + getCodexMcpConfigPaths({ + cwd: "/tmp/workspace", + codexHome: "/tmp/codex-home", + }), + ).toEqual(["/tmp/codex-home/config.toml"]); + }); + + test("detects a config file created or changed between provider turns", async () => { + const directory = await makeTempDirectory(); + const configPath = path.join(directory, "config.toml"); + const tracker = new McpConfigRefreshTracker(); + + const args = { scopeKey: "codex:/tmp/codex-home", paths: [configPath] }; + expect((await tracker.check(args)).changed).toBe(false); + await writeFile(configPath, "[mcp_servers.crane]\nurl = 'http://one'\n"); + expect((await tracker.check(args)).changed).toBe(true); + expect((await tracker.check(args)).changed).toBe(false); + + await writeFile( + configPath, + "[mcp_servers.crane]\nurl = 'http://two-longer'\n", + ); + expect((await tracker.check(args)).changed).toBe(true); + }); + + test("keeps workspace scopes independent and deduplicates concurrent checks", async () => { + const directory = await makeTempDirectory(); + const first = path.join(directory, "first.json"); + const second = path.join(directory, "second.json"); + const tracker = new McpConfigRefreshTracker(); + + await Promise.all([ + tracker.check({ scopeKey: "claude:one", paths: [first] }), + tracker.check({ scopeKey: "claude:one", paths: [first] }), + ]); + expect( + (await tracker.check({ scopeKey: "claude:two", paths: [second] })) + .changed, + ).toBe(false); + + await writeFile(first, '{"mcpServers":{}}'); + expect( + (await tracker.check({ scopeKey: "claude:one", paths: [first] })).changed, + ).toBe(true); + expect( + (await tracker.check({ scopeKey: "claude:two", paths: [second] })) + .changed, + ).toBe(false); + }); + + test("uses the check interval as a cheap UI cache", async () => { + const directory = await makeTempDirectory(); + const configPath = path.join(directory, "config.toml"); + const tracker = new McpConfigRefreshTracker(); + const args = { + scopeKey: "codex:cache", + paths: [configPath], + minIntervalMs: 60_000, + }; + + await tracker.check(args); + await writeFile(configPath, "[mcp_servers.crane]\n"); + expect((await tracker.check(args)).changed).toBe(false); + expect((await tracker.check({ ...args, force: true })).changed).toBe(true); + }); +}); diff --git a/tests/mcp-discovery.test.ts b/tests/mcp-discovery.test.ts new file mode 100644 index 0000000..a1f3731 --- /dev/null +++ b/tests/mcp-discovery.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { + discoverClaudeEntries, + discoverCodexEntries, +} from "../electron/main/mcp-discovery"; + +describe("MCP discovery", () => { + test("normalizes Claude stdio and HTTP configuration without exposing values", () => { + expect( + discoverClaudeEntries({ + source: "claude-user", + document: { + mcpServers: { + crane: { command: "crane", env: { TOKEN: "secret" } }, + remote: { url: "https://mcp.example.test" }, + }, + }, + }), + ).toEqual([ + expect.objectContaining({ name: "crane", transport: "stdio" }), + expect.objectContaining({ name: "remote", transport: "http" }), + ]); + }); + + test("finds Codex server sections without returning credentials", () => { + expect( + discoverCodexEntries( + '[mcp_servers.crane]\ncommand = "crane"\n[mcp_servers.remote]\nurl = "https://example.test"\nbearer_token_env_var = "TOKEN"', + ), + ).toEqual([ + expect.objectContaining({ name: "crane", transport: "stdio" }), + expect.objectContaining({ name: "remote", transport: "http" }), + ]); + }); +});