aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--renderer/src/components/settings/McpSettings.tsx200
-rw-r--r--renderer/src/styles/globals.css74
-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
6 files changed, 459 insertions, 49 deletions
diff --git a/renderer/src/components/settings/McpSettings.tsx b/renderer/src/components/settings/McpSettings.tsx
index 7d23fe4..3c4f8e5 100644
--- a/renderer/src/components/settings/McpSettings.tsx
+++ b/renderer/src/components/settings/McpSettings.tsx
@@ -4,6 +4,11 @@ const api = window.api;
4 4
5type McpServerType = "stdio" | "sse" | "http"; 5type McpServerType = "stdio" | "sse" | "http";
6 6
7interface McpTool {
8 name: string;
9 description?: string;
10}
11
7interface McpServerConfig { 12interface McpServerConfig {
8 type: McpServerType; 13 type: McpServerType;
9 command?: string; 14 command?: string;
@@ -11,6 +16,9 @@ interface McpServerConfig {
11 env?: Record<string, string>; 16 env?: Record<string, string>;
12 url?: string; 17 url?: string;
13 headers?: Record<string, string>; 18 headers?: Record<string, string>;
19 // Tool management
20 discoveredTools?: McpTool[];
21 enabledTools?: string[]; // If undefined/empty after discovery, all tools enabled
14} 22}
15 23
16type McpServersConfig = Record<string, McpServerConfig>; 24type McpServersConfig = Record<string, McpServerConfig>;
@@ -33,6 +41,7 @@ export function McpSettings() {
33 const [editing, setEditing] = useState<EditingServer | null>(null); 41 const [editing, setEditing] = useState<EditingServer | null>(null);
34 const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); 42 const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle");
35 const [error, setError] = useState<string | null>(null); 43 const [error, setError] = useState<string | null>(null);
44 const [discovering, setDiscovering] = useState<string | null>(null); // server name being discovered
36 45
37 useEffect(() => { 46 useEffect(() => {
38 api.getSettings(["mcpServers"]).then((settings) => { 47 api.getSettings(["mcpServers"]).then((settings) => {
@@ -70,6 +79,62 @@ export function McpSettings() {
70 await saveServers(rest); 79 await saveServers(rest);
71 }; 80 };
72 81
82 const handleDiscoverTools = async (name: string) => {
83 if (!servers) return;
84 const config = servers[name];
85
86 setDiscovering(name);
87 setError(null);
88
89 try {
90 const result = await api.discoverMcpTools({
91 type: config.type,
92 command: config.command,
93 args: config.args,
94 env: config.env,
95 url: config.url,
96 });
97
98 if (result.error) {
99 setError(`Discovery failed: ${result.error}`);
100 } else {
101 // Save discovered tools, enable all by default
102 const newServers = {
103 ...servers,
104 [name]: {
105 ...config,
106 discoveredTools: result.tools,
107 enabledTools: result.tools.map((t) => t.name),
108 },
109 };
110 await saveServers(newServers);
111 }
112 } catch (e) {
113 setError(`Discovery failed: ${String(e)}`);
114 } finally {
115 setDiscovering(null);
116 }
117 };
118
119 const handleToggleTool = async (serverName: string, toolName: string, enabled: boolean) => {
120 if (!servers) return;
121 const config = servers[serverName];
122 const currentEnabled = config.enabledTools || config.discoveredTools?.map((t) => t.name) || [];
123
124 const newEnabled = enabled
125 ? [...currentEnabled, toolName]
126 : currentEnabled.filter((t) => t !== toolName);
127
128 const newServers = {
129 ...servers,
130 [serverName]: {
131 ...config,
132 enabledTools: newEnabled,
133 },
134 };
135 await saveServers(newServers);
136 };
137
73 const handleSaveEdit = async () => { 138 const handleSaveEdit = async () => {
74 if (!editing || !servers) return; 139 if (!editing || !servers) return;
75 const { originalName, name, config } = editing; 140 const { originalName, name, config } = editing;
@@ -113,6 +178,21 @@ export function McpSettings() {
113 } 178 }
114 } 179 }
115 180
181 // Preserve discovered tools if config didn't change substantially
182 const oldConfig = originalName ? servers[originalName] : null;
183 if (oldConfig?.discoveredTools) {
184 const configChanged =
185 config.type !== oldConfig.type ||
186 config.command !== oldConfig.command ||
187 JSON.stringify(config.args) !== JSON.stringify(oldConfig.args) ||
188 config.url !== oldConfig.url;
189
190 if (!configChanged) {
191 cleanConfig.discoveredTools = oldConfig.discoveredTools;
192 cleanConfig.enabledTools = oldConfig.enabledTools;
193 }
194 }
195
116 newServers[name.trim()] = cleanConfig; 196 newServers[name.trim()] = cleanConfig;
117 await saveServers(newServers); 197 await saveServers(newServers);
118 setEditing(null); 198 setEditing(null);
@@ -139,13 +219,11 @@ export function McpSettings() {
139 <div className="settings-section-title">MCP Servers</div> 219 <div className="settings-section-title">MCP Servers</div>
140 <div className="settings-section-desc"> 220 <div className="settings-section-desc">
141 Configure Model Context Protocol servers to give Claude access to 221 Configure Model Context Protocol servers to give Claude access to
142 external data sources like Jira, documentation, or databases. Servers 222 external data sources. Click "Discover Tools" to fetch available tools
143 are available in all phases. 223 and select which ones to enable.
144 </div> 224 </div>
145 225
146 {error && ( 226 {error && <div className="mcp-error">{error}</div>}
147 <div className="mcp-error">{error}</div>
148 )}
149 227
150 {/* Server List */} 228 {/* Server List */}
151 {!editing && ( 229 {!editing && (
@@ -154,39 +232,87 @@ export function McpSettings() {
154 {serverNames.length === 0 ? ( 232 {serverNames.length === 0 ? (
155 <div className="mcp-empty">No MCP servers configured</div> 233 <div className="mcp-empty">No MCP servers configured</div>
156 ) : ( 234 ) : (
157 serverNames.map((name) => ( 235 serverNames.map((name) => {
158 <div key={name} className="mcp-server-card"> 236 const config = servers[name];
159 <div className="mcp-server-info"> 237 const hasTools = config.discoveredTools && config.discoveredTools.length > 0;
160 <span className="mcp-server-name">{name}</span> 238 const enabledTools = config.enabledTools || config.discoveredTools?.map((t) => t.name) || [];
161 <span className="mcp-server-type">{servers[name].type}</span> 239
162 <span className="mcp-server-detail"> 240 return (
163 {servers[name].type === "stdio" 241 <div key={name} className="mcp-server-card">
164 ? servers[name].command 242 <div className="mcp-server-header">
165 : servers[name].url} 243 <div className="mcp-server-info">
166 </span> 244 <span className="mcp-server-name">{name}</span>
245 <span className="mcp-server-type">{config.type}</span>
246 <span className="mcp-server-detail">
247 {config.type === "stdio" ? config.command : config.url}
248 </span>
249 </div>
250 <div className="mcp-server-actions">
251 <button
252 className="btn-secondary btn-small"
253 onClick={() => handleDiscoverTools(name)}
254 disabled={discovering === name}
255 >
256 {discovering === name ? "Discovering..." : "Discover Tools"}
257 </button>
258 <button
259 className="btn-secondary btn-small"
260 onClick={() =>
261 setEditing({
262 originalName: name,
263 name,
264 config: { ...config },
265 })
266 }
267 >
268 Edit
269 </button>
270 <button
271 className="btn-secondary btn-small btn-danger"
272 onClick={() => handleDelete(name)}
273 >
274 Delete
275 </button>
276 </div>
277 </div>
278
279 {/* Tools Section */}
280 {hasTools && (
281 <div className="mcp-tools-section">
282 <div className="mcp-tools-header">
283 Tools ({enabledTools.length}/{config.discoveredTools!.length} enabled)
284 </div>
285 <div className="mcp-tools-list">
286 {config.discoveredTools!.map((tool) => {
287 const isEnabled = enabledTools.includes(tool.name);
288 return (
289 <label key={tool.name} className="mcp-tool-item">
290 <input
291 type="checkbox"
292 checked={isEnabled}
293 onChange={(e) =>
294 handleToggleTool(name, tool.name, e.target.checked)
295 }
296 />
297 <span className="mcp-tool-name">{tool.name}</span>
298 {tool.description && (
299 <span className="mcp-tool-desc">{tool.description}</span>
300 )}
301 </label>
302 );
303 })}
304 </div>
305 </div>
306 )}
307
308 {!hasTools && (
309 <div className="mcp-tools-empty">
310 Click "Discover Tools" to see available tools
311 </div>
312 )}
167 </div> 313 </div>
168 <div className="mcp-server-actions"> 314 );
169 <button 315 })
170 className="btn-secondary btn-small"
171 onClick={() =>
172 setEditing({
173 originalName: name,
174 name,
175 config: { ...servers[name] },
176 })
177 }
178 >
179 Edit
180 </button>
181 <button
182 className="btn-secondary btn-small btn-danger"
183 onClick={() => handleDelete(name)}
184 >
185 Delete
186 </button>
187 </div>
188 </div>
189 ))
190 )} 316 )}
191 </div> 317 </div>
192 318
diff --git a/renderer/src/styles/globals.css b/renderer/src/styles/globals.css
index 6cf3dc7..20275ae 100644
--- a/renderer/src/styles/globals.css
+++ b/renderer/src/styles/globals.css
@@ -1348,3 +1348,77 @@ html[data-theme="light"] .settings-textarea:focus {
1348 padding-top: 16px; 1348 padding-top: 16px;
1349 border-top: 1px solid var(--border); 1349 border-top: 1px solid var(--border);
1350} 1350}
1351
1352/* MCP Server Card with Tools */
1353.mcp-server-card {
1354 display: flex;
1355 flex-direction: column;
1356 padding: 12px;
1357 background: var(--bg-secondary);
1358 border: 1px solid var(--border);
1359 border-radius: 4px;
1360}
1361
1362.mcp-server-header {
1363 display: flex;
1364 align-items: center;
1365 justify-content: space-between;
1366 gap: 12px;
1367}
1368
1369.mcp-tools-section {
1370 margin-top: 12px;
1371 padding-top: 12px;
1372 border-top: 1px solid var(--border);
1373}
1374
1375.mcp-tools-header {
1376 font-size: 11px;
1377 font-weight: 500;
1378 text-transform: uppercase;
1379 letter-spacing: 0.05em;
1380 color: var(--text-secondary);
1381 margin-bottom: 8px;
1382}
1383
1384.mcp-tools-list {
1385 display: flex;
1386 flex-direction: column;
1387 gap: 6px;
1388}
1389
1390.mcp-tool-item {
1391 display: flex;
1392 align-items: flex-start;
1393 gap: 8px;
1394 font-size: 12px;
1395 cursor: pointer;
1396}
1397
1398.mcp-tool-item input[type="checkbox"] {
1399 margin-top: 2px;
1400 cursor: pointer;
1401}
1402
1403.mcp-tool-name {
1404 font-family: var(--font-mono, monospace);
1405 color: var(--text-primary);
1406 font-weight: 500;
1407}
1408
1409.mcp-tool-desc {
1410 color: var(--text-secondary);
1411 font-size: 11px;
1412 margin-left: 4px;
1413}
1414
1415.mcp-tools-empty {
1416 font-size: 11px;
1417 color: var(--text-secondary);
1418 font-style: italic;
1419 margin-top: 8px;
1420 padding: 8px;
1421 background: var(--bg-tertiary);
1422 border-radius: 4px;
1423 text-align: center;
1424}
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) => {