aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/main/claude/index.ts59
-rw-r--r--src/main/ipc/handlers.ts12
-rw-r--r--src/main/mcp.ts151
-rw-r--r--src/main/preload.ts12
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";
4import * as claude from "../claude"; 4import * as claude from "../claude";
5import * as settingsDb from "../db/settings"; 5import * as settingsDb from "../db/settings";
6import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git"; 6import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git";
7import { discoverMcpTools } from "../mcp";
7import type { UserPermissionMode } from "../claude/phases"; 8import type { UserPermissionMode } from "../claude/phases";
8import { getDefaultSystemPromptTemplate } from "../claude/phases"; 9import { 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 @@
1import { spawn, type ChildProcess } from "node:child_process";
2import { randomUUID } from "node:crypto";
3
4interface McpTool {
5 name: string;
6 description?: string;
7}
8
9interface McpServerConfig {
10 type: "stdio" | "sse" | "http";
11 command?: string;
12 args?: string[];
13 env?: Record<string, string>;
14 url?: string;
15}
16
17interface JsonRpcRequest {
18 jsonrpc: "2.0";
19 id: string | number;
20 method: string;
21 params?: Record<string, unknown>;
22}
23
24interface 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 */
35export 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) => {