From 044d628a47f063bcbbd9adba7860542156a0c66e Mon Sep 17 00:00:00 2001 From: Clawd Date: Sun, 1 Mar 2026 10:15:14 -0800 Subject: feat(mcp): add tool discovery and per-tool permissions - Add MCP protocol client for tool discovery (initialize + tools/list) - Show discovered tools in settings UI with enable/disable checkboxes - Build explicit allowedTools list from enabled MCP tools - Remove bypassPermissions hack - now uses proper tool allowlisting - Format: mcp__servername__toolname for SDK allowedTools --- src/main/claude/index.ts | 59 ++++++++++++++---- src/main/ipc/handlers.ts | 12 ++++ src/main/mcp.ts | 151 +++++++++++++++++++++++++++++++++++++++++++++++ src/main/preload.ts | 12 ++++ 4 files changed, 222 insertions(+), 12 deletions(-) create mode 100644 src/main/mcp.ts (limited to 'src') 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({ // Load MCP servers config (JSON string → object, or undefined if not set) const mcpServersJson = getSetting("mcpServers"); - const mcpServers = mcpServersJson ? JSON.parse(mcpServersJson) : undefined; + const mcpServersConfig = mcpServersJson ? JSON.parse(mcpServersJson) : undefined; + + // Build allowedTools list from enabled MCP tools + // Format: mcp__servername__toolname + const mcpAllowedTools: string[] = []; + if (mcpServersConfig) { + for (const [serverName, config] of Object.entries(mcpServersConfig)) { + const serverConfig = config as { + enabledTools?: string[]; + discoveredTools?: Array<{ name: string }>; + }; + // Use enabledTools if available, otherwise allow all discovered tools + const enabledTools = serverConfig.enabledTools || + serverConfig.discoveredTools?.map((t) => t.name) || + []; + for (const toolName of enabledTools) { + mcpAllowedTools.push(`mcp__${serverName}__${toolName}`); + } + } + } + + // Strip tool management fields from config before passing to SDK + // SDK only needs: type, command, args, env, url, headers + let mcpServers: Record | undefined; + if (mcpServersConfig && Object.keys(mcpServersConfig).length > 0) { + mcpServers = Object.fromEntries( + Object.entries(mcpServersConfig).map(([name, config]) => { + const { discoveredTools, enabledTools, ...sdkConfig } = config as Record; + return [name, sdkConfig]; + }) + ); + } const phaseConfig = getPhaseConfig( session.phase as Phase, @@ -62,28 +93,32 @@ export async function sendMessage({ customSystemPrompt ); + // Build allowedTools for this phase + const allowedTools: string[] = []; + if (session.phase === "implement") { + // Allow git inspection in implement phase + allowedTools.push("Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"); + } + if (mcpAllowedTools.length > 0) { + // Add enabled MCP tools + allowedTools.push(...mcpAllowedTools); + } + const q = query({ prompt: message, options: { cwd: project.path, model: configuredModel, - mcpServers, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mcpServers: mcpServers as any, resume: session.claude_session_id ?? undefined, tools: phaseConfig.tools, permissionMode: phaseConfig.permissionMode, // Required companion flag when bypassPermissions is active allowDangerouslySkipPermissions: phaseConfig.permissionMode === "bypassPermissions", systemPrompt: phaseConfig.systemPrompt, - // Allow Claude to inspect git state during implementation without prompts. - // git add/commit intentionally omitted — the app handles those. - ...(session.phase === "implement" && { - allowedTools: ["Bash(git status*)", "Bash(git log*)", "Bash(git diff*)"], - }), - // When MCPs are configured in research phase, bypass permissions to allow MCP tools - ...(session.phase === "research" && mcpServers && { - permissionMode: "bypassPermissions" as const, - allowDangerouslySkipPermissions: true, - }), + // Pre-approve specific tools to avoid permission prompts + ...(allowedTools.length > 0 && { allowedTools }), }, }); 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"; import * as claude from "../claude"; import * as settingsDb from "../db/settings"; import { createSessionBranch, ensureGitIgnore, ensureGitRepo, getCurrentBranch } from "../git"; +import { discoverMcpTools } from "../mcp"; import type { UserPermissionMode } from "../claude/phases"; import { getDefaultSystemPromptTemplate } from "../claude/phases"; @@ -199,4 +200,15 @@ export function registerIpcHandlers(mainWindow: BrowserWindow): void { }); return result.canceled ? null : result.filePaths[0]; }); + + // MCP + ipcMain.handle("mcp:discoverTools", async (_, config: { + type: "stdio" | "sse" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + }) => { + return discoverMcpTools(config); + }); } 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 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { randomUUID } from "node:crypto"; + +interface McpTool { + name: string; + description?: string; +} + +interface McpServerConfig { + type: "stdio" | "sse" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; +} + +interface JsonRpcRequest { + jsonrpc: "2.0"; + id: string | number; + method: string; + params?: Record; +} + +interface JsonRpcResponse { + jsonrpc: "2.0"; + id: string | number; + result?: unknown; + error?: { code: number; message: string }; +} + +/** + * Connect to an MCP server and discover its available tools. + * Only supports stdio transport for now. + */ +export async function discoverMcpTools( + config: McpServerConfig +): Promise<{ tools: McpTool[]; error?: string }> { + if (config.type !== "stdio") { + return { tools: [], error: "Only stdio transport is supported for tool discovery" }; + } + + if (!config.command) { + return { tools: [], error: "Command is required for stdio transport" }; + } + + let process: ChildProcess | null = null; + let buffer = ""; + + try { + // Spawn the MCP server process + process = spawn(config.command, config.args || [], { + env: { ...globalThis.process.env, ...config.env }, + stdio: ["pipe", "pipe", "pipe"], + }); + + if (!process.stdin || !process.stdout) { + return { tools: [], error: "Failed to create process streams" }; + } + + const sendRequest = (request: JsonRpcRequest): void => { + const json = JSON.stringify(request); + process!.stdin!.write(json + "\n"); + }; + + const waitForResponse = (id: string | number, timeoutMs = 10000): Promise => { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for MCP response")); + }, timeoutMs); + + const onData = (chunk: Buffer) => { + buffer += chunk.toString(); + + // Try to parse complete JSON-RPC messages (newline-delimited) + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; // Keep incomplete line in buffer + + for (const line of lines) { + if (!line.trim()) continue; + try { + const response = JSON.parse(line) as JsonRpcResponse; + if (response.id === id) { + clearTimeout(timeout); + process!.stdout!.off("data", onData); + resolve(response); + return; + } + } catch { + // Not valid JSON, skip + } + } + }; + + process!.stdout!.on("data", onData); + }); + }; + + // Step 1: Initialize + const initId = randomUUID(); + sendRequest({ + jsonrpc: "2.0", + id: initId, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "claude-flow", version: "1.0.0" }, + }, + }); + + const initResponse = await waitForResponse(initId); + if (initResponse.error) { + return { tools: [], error: `Initialize failed: ${initResponse.error.message}` }; + } + + // Send initialized notification (no response expected) + process.stdin.write(JSON.stringify({ + jsonrpc: "2.0", + method: "notifications/initialized", + }) + "\n"); + + // Step 2: List tools + const toolsId = randomUUID(); + sendRequest({ + jsonrpc: "2.0", + id: toolsId, + method: "tools/list", + params: {}, + }); + + const toolsResponse = await waitForResponse(toolsId); + if (toolsResponse.error) { + return { tools: [], error: `tools/list failed: ${toolsResponse.error.message}` }; + } + + const result = toolsResponse.result as { tools?: Array<{ name: string; description?: string }> }; + const tools: McpTool[] = (result.tools || []).map((t) => ({ + name: t.name, + description: t.description, + })); + + return { tools }; + } catch (err) { + return { tools: [], error: String(err) }; + } finally { + // Clean up + if (process) { + process.kill(); + } + } +} 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 { // Dialogs selectDirectory: () => Promise; + // MCP + discoverMcpTools: (config: { + type: "stdio" | "sse" | "http"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + }) => Promise<{ tools: Array<{ name: string; description?: string }>; error?: string }>; + // Window toggleMaximize: () => Promise; onWindowMaximized: (cb: (isMaximized: boolean) => void) => () => void; @@ -132,6 +141,9 @@ const api: ClaudeFlowAPI = { return result; }, + // MCP + discoverMcpTools: (config) => ipcRenderer.invoke("mcp:discoverTools", config), + // Window toggleMaximize: () => ipcRenderer.invoke("window:toggleMaximize"), onWindowMaximized: (cb) => { -- cgit v1.2.3