diff options
Diffstat (limited to 'src/main')
| -rw-r--r-- | src/main/claude/index.ts | 59 | ||||
| -rw-r--r-- | src/main/ipc/handlers.ts | 12 | ||||
| -rw-r--r-- | src/main/mcp.ts | 151 | ||||
| -rw-r--r-- | src/main/preload.ts | 12 |
4 files changed, 222 insertions, 12 deletions
diff --git a/src/main/claude/index.ts b/src/main/claude/index.ts index ca54164..8cf512c 100644 --- a/src/main/claude/index.ts +++ b/src/main/claude/index.ts | |||
| @@ -53,7 +53,38 @@ export async function sendMessage({ | |||
| 53 | 53 | ||
| 54 | // Load MCP servers config (JSON string → object, or undefined if not set) | 54 | // Load MCP servers config (JSON string → object, or undefined if not set) |
| 55 | const mcpServersJson = getSetting("mcpServers"); | 55 | const mcpServersJson = getSetting("mcpServers"); |
| 56 | const mcpServers = mcpServersJson ? JSON.parse(mcpServersJson) : undefined; | 56 | const mcpServersConfig = mcpServersJson ? JSON.parse(mcpServersJson) : undefined; |
| 57 | |||
| 58 | // Build allowedTools list from enabled MCP tools | ||
| 59 | // Format: mcp__servername__toolname | ||
| 60 | const mcpAllowedTools: string[] = []; | ||
| 61 | if (mcpServersConfig) { | ||
| 62 | for (const [serverName, config] of Object.entries(mcpServersConfig)) { | ||
| 63 | const serverConfig = config as { | ||
| 64 | enabledTools?: string[]; | ||
| 65 | discoveredTools?: Array<{ name: string }>; | ||
| 66 | }; | ||
| 67 | // Use enabledTools if available, otherwise allow all discovered tools | ||
| 68 | const enabledTools = serverConfig.enabledTools || | ||
| 69 | serverConfig.discoveredTools?.map((t) => t.name) || | ||
| 70 | []; | ||
| 71 | for (const toolName of enabledTools) { | ||
| 72 | mcpAllowedTools.push(`mcp__${serverName}__${toolName}`); | ||
| 73 | } | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | // Strip tool management fields from config before passing to SDK | ||
| 78 | // SDK only needs: type, command, args, env, url, headers | ||
| 79 | let mcpServers: Record<string, unknown> | undefined; | ||
| 80 | if (mcpServersConfig && Object.keys(mcpServersConfig).length > 0) { | ||
| 81 | mcpServers = Object.fromEntries( | ||
| 82 | Object.entries(mcpServersConfig).map(([name, config]) => { | ||
| 83 | const { discoveredTools, enabledTools, ...sdkConfig } = config as Record<string, unknown>; | ||
| 84 | return [name, sdkConfig]; | ||
| 85 | }) | ||
| 86 | ); | ||
| 87 | } | ||
| 57 | 88 | ||
| 58 | const phaseConfig = getPhaseConfig( | 89 | const phaseConfig = getPhaseConfig( |
| 59 | session.phase as Phase, | 90 | session.phase as Phase, |
| @@ -62,28 +93,32 @@ export async function sendMessage({ | |||
| 62 | customSystemPrompt | 93 | customSystemPrompt |
| 63 | ); | 94 | ); |
| 64 | 95 | ||
| 96 | // Build allowedTools for this phase | ||
| 97 | const allowedTools: string[] = []; | ||
| 98 | if (session.phase === "implement") { | ||
| 99 | // Allow git inspection in implement phase | ||
| 100 | allowedTools.push("Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"); | ||
| 101 | } | ||
| 102 | if (mcpAllowedTools.length > 0) { | ||
| 103 | // Add enabled MCP tools | ||
| 104 | allowedTools.push(...mcpAllowedTools); | ||
| 105 | } | ||
| 106 | |||
| 65 | const q = query({ | 107 | const q = query({ |
| 66 | prompt: message, | 108 | prompt: message, |
| 67 | options: { | 109 | options: { |
| 68 | cwd: project.path, | 110 | cwd: project.path, |
| 69 | model: configuredModel, | 111 | model: configuredModel, |
| 70 | mcpServers, | 112 | // eslint-disable-next-line @typescript-eslint/no-explicit-any |
| 113 | mcpServers: mcpServers as any, | ||
| 71 | resume: session.claude_session_id ?? undefined, | 114 | resume: session.claude_session_id ?? undefined, |
| 72 | tools: phaseConfig.tools, | 115 | tools: phaseConfig.tools, |
| 73 | permissionMode: phaseConfig.permissionMode, | 116 | permissionMode: phaseConfig.permissionMode, |
| 74 | // Required companion flag when bypassPermissions is active | 117 | // Required companion flag when bypassPermissions is active |
| 75 | allowDangerouslySkipPermissions: phaseConfig.permissionMode === "bypassPermissions", | 118 | allowDangerouslySkipPermissions: phaseConfig.permissionMode === "bypassPermissions", |
| 76 | systemPrompt: phaseConfig.systemPrompt, | 119 | systemPrompt: phaseConfig.systemPrompt, |
| 77 | // Allow Claude to inspect git state during implementation without prompts. | 120 | // Pre-approve specific tools to avoid permission prompts |
| 78 | // git add/commit intentionally omitted — the app handles those. | 121 | ...(allowedTools.length > 0 && { allowedTools }), |
| 79 | ...(session.phase === "implement" && { | ||
| 80 | allowedTools: ["Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"], | ||
| 81 | }), | ||
| 82 | // When MCPs are configured in research phase, bypass permissions to allow MCP tools | ||
| 83 | ...(session.phase === "research" && mcpServers && { | ||
| 84 | permissionMode: "bypassPermissions" as const, | ||
| 85 | allowDangerouslySkipPermissions: true, | ||
| 86 | }), | ||
| 87 | }, | 122 | }, |
| 88 | }); | 123 | }); |
| 89 | 124 | ||
diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index e0863f3..4894e1d 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts | |||
| @@ -4,6 +4,7 @@ import * as sessions from "../db/sessions"; | |||
| 4 | import * as claude from "../claude"; | 4 | import * as claude from "../claude"; |
| 5 | import * as settingsDb from "../db/settings"; | 5 | import * as settingsDb from "../db/settings"; |
| 6 | import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git"; | 6 | import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git"; |
| 7 | import { discoverMcpTools } from "../mcp"; | ||
| 7 | import type { UserPermissionMode } from "../claude/phases"; | 8 | import type { UserPermissionMode } from "../claude/phases"; |
| 8 | import { getDefaultSystemPromptTemplate } from "../claude/phases"; | 9 | import { getDefaultSystemPromptTemplate } from "../claude/phases"; |
| 9 | 10 | ||
| @@ -199,4 +200,15 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { | |||
| 199 | }); | 200 | }); |
| 200 | return result.canceled ? null : result.filePaths[0]; | 201 | return result.canceled ? null : result.filePaths[0]; |
| 201 | }); | 202 | }); |
| 203 | |||
| 204 | // MCP | ||
| 205 | ipcMain.handle("mcp:discoverTools", async (_, config: { | ||
| 206 | type: "stdio" | "sse" | "http"; | ||
| 207 | command?: string; | ||
| 208 | args?: string[]; | ||
| 209 | env?: Record<string, string>; | ||
| 210 | url?: string; | ||
| 211 | }) => { | ||
| 212 | return discoverMcpTools(config); | ||
| 213 | }); | ||
| 202 | } | 214 | } |
diff --git a/src/main/mcp.ts b/src/main/mcp.ts new file mode 100644 index 0000000..cfd3d26 --- /dev/null +++ b/src/main/mcp.ts | |||
| @@ -0,0 +1,151 @@ | |||
| 1 | import { spawn, type ChildProcess } from "node:child_process"; | ||
| 2 | import { randomUUID } from "node:crypto"; | ||
| 3 | |||
| 4 | interface McpTool { | ||
| 5 | name: string; | ||
| 6 | description?: string; | ||
| 7 | } | ||
| 8 | |||
| 9 | interface McpServerConfig { | ||
| 10 | type: "stdio" | "sse" | "http"; | ||
| 11 | command?: string; | ||
| 12 | args?: string[]; | ||
| 13 | env?: Record<string, string>; | ||
| 14 | url?: string; | ||
| 15 | } | ||
| 16 | |||
| 17 | interface JsonRpcRequest { | ||
| 18 | jsonrpc: "2.0"; | ||
| 19 | id: string | number; | ||
| 20 | method: string; | ||
| 21 | params?: Record<string, unknown>; | ||
| 22 | } | ||
| 23 | |||
| 24 | interface JsonRpcResponse { | ||
| 25 | jsonrpc: "2.0"; | ||
| 26 | id: string | number; | ||
| 27 | result?: unknown; | ||
| 28 | error?: { code: number; message: string }; | ||
| 29 | } | ||
| 30 | |||
| 31 | /** | ||
| 32 | * Connect to an MCP server and discover its available tools. | ||
| 33 | * Only supports stdio transport for now. | ||
| 34 | */ | ||
| 35 | export async function discoverMcpTools( | ||
| 36 | config: McpServerConfig | ||
| 37 | ): Promise<{ tools: McpTool[]; error?: string }> { | ||
| 38 | if (config.type !== "stdio") { | ||
| 39 | return { tools: [], error: "Only stdio transport is supported for tool discovery" }; | ||
| 40 | } | ||
| 41 | |||
| 42 | if (!config.command) { | ||
| 43 | return { tools: [], error: "Command is required for stdio transport" }; | ||
| 44 | } | ||
| 45 | |||
| 46 | let process: ChildProcess | null = null; | ||
| 47 | let buffer = ""; | ||
| 48 | |||
| 49 | try { | ||
| 50 | // Spawn the MCP server process | ||
| 51 | process = spawn(config.command, config.args || [], { | ||
| 52 | env: { ...globalThis.process.env, ...config.env }, | ||
| 53 | stdio: ["pipe", "pipe", "pipe"], | ||
| 54 | }); | ||
| 55 | |||
| 56 | if (!process.stdin || !process.stdout) { | ||
| 57 | return { tools: [], error: "Failed to create process streams" }; | ||
| 58 | } | ||
| 59 | |||
| 60 | const sendRequest = (request: JsonRpcRequest): void => { | ||
| 61 | const json = JSON.stringify(request); | ||
| 62 | process!.stdin!.write(json + "\n"); | ||
| 63 | }; | ||
| 64 | |||
| 65 | const waitForResponse = (id: string | number, timeoutMs = 10000): Promise<JsonRpcResponse> => { | ||
| 66 | return new Promise((resolve, reject) => { | ||
| 67 | const timeout = setTimeout(() => { | ||
| 68 | reject(new Error("Timeout waiting for MCP response")); | ||
| 69 | }, timeoutMs); | ||
| 70 | |||
| 71 | const onData = (chunk: Buffer) => { | ||
| 72 | buffer += chunk.toString(); | ||
| 73 | |||
| 74 | // Try to parse complete JSON-RPC messages (newline-delimited) | ||
| 75 | const lines = buffer.split("\n"); | ||
| 76 | buffer = lines.pop() || ""; // Keep incomplete line in buffer | ||
| 77 | |||
| 78 | for (const line of lines) { | ||
| 79 | if (!line.trim()) continue; | ||
| 80 | try { | ||
| 81 | const response = JSON.parse(line) as JsonRpcResponse; | ||
| 82 | if (response.id === id) { | ||
| 83 | clearTimeout(timeout); | ||
| 84 | process!.stdout!.off("data", onData); | ||
| 85 | resolve(response); | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | } catch { | ||
| 89 | // Not valid JSON, skip | ||
| 90 | } | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | process!.stdout!.on("data", onData); | ||
| 95 | }); | ||
| 96 | }; | ||
| 97 | |||
| 98 | // Step 1: Initialize | ||
| 99 | const initId = randomUUID(); | ||
| 100 | sendRequest({ | ||
| 101 | jsonrpc: "2.0", | ||
| 102 | id: initId, | ||
| 103 | method: "initialize", | ||
| 104 | params: { | ||
| 105 | protocolVersion: "2024-11-05", | ||
| 106 | capabilities: {}, | ||
| 107 | clientInfo: { name: "claude-flow", version: "1.0.0" }, | ||
| 108 | }, | ||
| 109 | }); | ||
| 110 | |||
| 111 | const initResponse = await waitForResponse(initId); | ||
| 112 | if (initResponse.error) { | ||
| 113 | return { tools: [], error: `Initialize failed: ${initResponse.error.message}` }; | ||
| 114 | } | ||
| 115 | |||
| 116 | // Send initialized notification (no response expected) | ||
| 117 | process.stdin.write(JSON.stringify({ | ||
| 118 | jsonrpc: "2.0", | ||
| 119 | method: "notifications/initialized", | ||
| 120 | }) + "\n"); | ||
| 121 | |||
| 122 | // Step 2: List tools | ||
| 123 | const toolsId = randomUUID(); | ||
| 124 | sendRequest({ | ||
| 125 | jsonrpc: "2.0", | ||
| 126 | id: toolsId, | ||
| 127 | method: "tools/list", | ||
| 128 | params: {}, | ||
| 129 | }); | ||
| 130 | |||
| 131 | const toolsResponse = await waitForResponse(toolsId); | ||
| 132 | if (toolsResponse.error) { | ||
| 133 | return { tools: [], error: `tools/list failed: ${toolsResponse.error.message}` }; | ||
| 134 | } | ||
| 135 | |||
| 136 | const result = toolsResponse.result as { tools?: Array<{ name: string; description?: string }> }; | ||
| 137 | const tools: McpTool[] = (result.tools || []).map((t) => ({ | ||
| 138 | name: t.name, | ||
| 139 | description: t.description, | ||
| 140 | })); | ||
| 141 | |||
| 142 | return { tools }; | ||
| 143 | } catch (err) { | ||
| 144 | return { tools: [], error: String(err) }; | ||
| 145 | } finally { | ||
| 146 | // Clean up | ||
| 147 | if (process) { | ||
| 148 | process.kill(); | ||
| 149 | } | ||
| 150 | } | ||
| 151 | } | ||
diff --git a/src/main/preload.ts b/src/main/preload.ts index 44467db..e7ee0aa 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts | |||
| @@ -63,6 +63,15 @@ export interface ClaudeFlowAPI { | |||
| 63 | // Dialogs | 63 | // Dialogs |
| 64 | selectDirectory: () => Promise<string | null>; | 64 | selectDirectory: () => Promise<string | null>; |
| 65 | 65 | ||
| 66 | // MCP | ||
| 67 | discoverMcpTools: (config: { | ||
| 68 | type: "stdio" | "sse" | "http"; | ||
| 69 | command?: string; | ||
| 70 | args?: string[]; | ||
| 71 | env?: Record<string, string>; | ||
| 72 | url?: string; | ||
| 73 | }) => Promise<{ tools: Array<{ name: string; description?: string }>; error?: string }>; | ||
| 74 | |||
| 66 | // Window | 75 | // Window |
| 67 | toggleMaximize: () => Promise<void>; | 76 | toggleMaximize: () => Promise<void>; |
| 68 | onWindowMaximized: (cb: (isMaximized: boolean) => void) => () => void; | 77 | onWindowMaximized: (cb: (isMaximized: boolean) => void) => () => void; |
| @@ -132,6 +141,9 @@ const api: ClaudeFlowAPI = { | |||
| 132 | return result; | 141 | return result; |
| 133 | }, | 142 | }, |
| 134 | 143 | ||
| 144 | // MCP | ||
| 145 | discoverMcpTools: (config) => ipcRenderer.invoke("mcp:discoverTools", config), | ||
| 146 | |||
| 135 | // Window | 147 | // Window |
| 136 | toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), | 148 | toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), |
| 137 | onWindowMaximized: (cb) => { | 149 | onWindowMaximized: (cb) => { |
