diff options
| -rw-r--r-- | renderer/src/components/settings/McpSettings.tsx | 200 | ||||
| -rw-r--r-- | renderer/src/styles/globals.css | 74 | ||||
| -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 |
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 | ||
| 5 | type McpServerType = "stdio" | "sse" | "http"; | 5 | type McpServerType = "stdio" | "sse" | "http"; |
| 6 | 6 | ||
| 7 | interface McpTool { | ||
| 8 | name: string; | ||
| 9 | description?: string; | ||
| 10 | } | ||
| 11 | |||
| 7 | interface McpServerConfig { | 12 | interface 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 | ||
| 16 | type McpServersConfig = Record<string, McpServerConfig>; | 24 | type 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"; | |||
| 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) => { |
