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 --- renderer/src/components/settings/McpSettings.tsx | 200 ++++++++++++++++++----- renderer/src/styles/globals.css | 74 +++++++++ src/main/claude/index.ts | 59 +++++-- src/main/ipc/handlers.ts | 12 ++ src/main/mcp.ts | 151 +++++++++++++++++ src/main/preload.ts | 12 ++ 6 files changed, 459 insertions(+), 49 deletions(-) create mode 100644 src/main/mcp.ts 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; type McpServerType = "stdio" | "sse" | "http"; +interface McpTool { + name: string; + description?: string; +} + interface McpServerConfig { type: McpServerType; command?: string; @@ -11,6 +16,9 @@ interface McpServerConfig { env?: Record; url?: string; headers?: Record; + // Tool management + discoveredTools?: McpTool[]; + enabledTools?: string[]; // If undefined/empty after discovery, all tools enabled } type McpServersConfig = Record; @@ -33,6 +41,7 @@ export function McpSettings() { const [editing, setEditing] = useState(null); const [saveStatus, setSaveStatus] = useState<"idle" | "saved" | "error">("idle"); const [error, setError] = useState(null); + const [discovering, setDiscovering] = useState(null); // server name being discovered useEffect(() => { api.getSettings(["mcpServers"]).then((settings) => { @@ -70,6 +79,62 @@ export function McpSettings() { await saveServers(rest); }; + const handleDiscoverTools = async (name: string) => { + if (!servers) return; + const config = servers[name]; + + setDiscovering(name); + setError(null); + + try { + const result = await api.discoverMcpTools({ + type: config.type, + command: config.command, + args: config.args, + env: config.env, + url: config.url, + }); + + if (result.error) { + setError(`Discovery failed: ${result.error}`); + } else { + // Save discovered tools, enable all by default + const newServers = { + ...servers, + [name]: { + ...config, + discoveredTools: result.tools, + enabledTools: result.tools.map((t) => t.name), + }, + }; + await saveServers(newServers); + } + } catch (e) { + setError(`Discovery failed: ${String(e)}`); + } finally { + setDiscovering(null); + } + }; + + const handleToggleTool = async (serverName: string, toolName: string, enabled: boolean) => { + if (!servers) return; + const config = servers[serverName]; + const currentEnabled = config.enabledTools || config.discoveredTools?.map((t) => t.name) || []; + + const newEnabled = enabled + ? [...currentEnabled, toolName] + : currentEnabled.filter((t) => t !== toolName); + + const newServers = { + ...servers, + [serverName]: { + ...config, + enabledTools: newEnabled, + }, + }; + await saveServers(newServers); + }; + const handleSaveEdit = async () => { if (!editing || !servers) return; const { originalName, name, config } = editing; @@ -113,6 +178,21 @@ export function McpSettings() { } } + // Preserve discovered tools if config didn't change substantially + const oldConfig = originalName ? servers[originalName] : null; + if (oldConfig?.discoveredTools) { + const configChanged = + config.type !== oldConfig.type || + config.command !== oldConfig.command || + JSON.stringify(config.args) !== JSON.stringify(oldConfig.args) || + config.url !== oldConfig.url; + + if (!configChanged) { + cleanConfig.discoveredTools = oldConfig.discoveredTools; + cleanConfig.enabledTools = oldConfig.enabledTools; + } + } + newServers[name.trim()] = cleanConfig; await saveServers(newServers); setEditing(null); @@ -139,13 +219,11 @@ export function McpSettings() {
MCP Servers
Configure Model Context Protocol servers to give Claude access to - external data sources like Jira, documentation, or databases. Servers - are available in all phases. + external data sources. Click "Discover Tools" to fetch available tools + and select which ones to enable.
- {error && ( -
{error}
- )} + {error &&
{error}
} {/* Server List */} {!editing && ( @@ -154,39 +232,87 @@ export function McpSettings() { {serverNames.length === 0 ? (
No MCP servers configured
) : ( - serverNames.map((name) => ( -
-
- {name} - {servers[name].type} - - {servers[name].type === "stdio" - ? servers[name].command - : servers[name].url} - + serverNames.map((name) => { + const config = servers[name]; + const hasTools = config.discoveredTools && config.discoveredTools.length > 0; + const enabledTools = config.enabledTools || config.discoveredTools?.map((t) => t.name) || []; + + return ( +
+
+
+ {name} + {config.type} + + {config.type === "stdio" ? config.command : config.url} + +
+
+ + + +
+
+ + {/* Tools Section */} + {hasTools && ( +
+
+ Tools ({enabledTools.length}/{config.discoveredTools!.length} enabled) +
+
+ {config.discoveredTools!.map((tool) => { + const isEnabled = enabledTools.includes(tool.name); + return ( + + ); + })} +
+
+ )} + + {!hasTools && ( +
+ Click "Discover Tools" to see available tools +
+ )}
-
- - -
-
- )) + ); + }) )}
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 { padding-top: 16px; border-top: 1px solid var(--border); } + +/* MCP Server Card with Tools */ +.mcp-server-card { + display: flex; + flex-direction: column; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 4px; +} + +.mcp-server-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.mcp-tools-section { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.mcp-tools-header { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.mcp-tools-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.mcp-tool-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 12px; + cursor: pointer; +} + +.mcp-tool-item input[type="checkbox"] { + margin-top: 2px; + cursor: pointer; +} + +.mcp-tool-name { + font-family: var(--font-mono, monospace); + color: var(--text-primary); + font-weight: 500; +} + +.mcp-tool-desc { + color: var(--text-secondary); + font-size: 11px; + margin-left: 4px; +} + +.mcp-tools-empty { + font-size: 11px; + color: var(--text-secondary); + font-style: italic; + margin-top: 8px; + padding: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + text-align: center; +} 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