From 7daf5f8110b90c20d45c59c32122aa33a03e6046 Mon Sep 17 00:00:00 2001 From: "21st.dev Bot" Date: Thu, 29 Jan 2026 19:39:45 +0000 Subject: [PATCH] feat: auto-refresh MCP servers when ~/.claude.json changes Watch ~/.claude.json for changes using chokidar and automatically re-initialize MCP servers in the active tab. Previously, users had to manually restart the app or refresh when editing their Claude config (e.g., adding MCP servers). When a config change is detected: 1. Main process clears MCP config and working servers caches 2. IPC event notifies all renderer windows 3. Renderer invalidates tRPC queries to refetch MCP config 4. Next chat message uses the updated MCP server configuration Co-Authored-By: Claude Opus 4.5 --- src/main/index.ts | 7 ++ src/main/lib/claude-config-watcher.ts | 91 +++++++++++++++++++ src/main/lib/trpc/routers/claude.ts | 2 +- src/preload/index.ts | 9 ++ src/renderer/App.tsx | 4 + .../lib/hooks/use-file-change-listener.ts | 30 +++++- 6 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/main/lib/claude-config-watcher.ts diff --git a/src/main/index.ts b/src/main/index.ts index 32bf51eb..57b3be16 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,6 +26,7 @@ import { uninstallCli, parseLaunchDirectory, } from "./lib/cli" +import { startClaudeConfigWatcher, stopClaudeConfigWatcher } from "./lib/claude-config-watcher" import { cleanupGitWatchers } from "./lib/git/watcher" import { cancelAllPendingOAuth, handleMcpOAuthCallback } from "./lib/mcp-auth" import { @@ -877,6 +878,11 @@ if (gotTheLock) { } }, 3000) + // Watch ~/.claude.json for changes to auto-refresh MCP servers + startClaudeConfigWatcher().catch((error) => { + console.error("[App] Failed to start config watcher:", error) + }) + // Handle directory argument from CLI (e.g., `1code /path/to/project`) parseLaunchDirectory() @@ -907,6 +913,7 @@ if (gotTheLock) { app.on("before-quit", async () => { console.log("[App] Shutting down...") cancelAllPendingOAuth() + await stopClaudeConfigWatcher() await cleanupGitWatchers() await shutdownAnalytics() await closeDatabase() diff --git a/src/main/lib/claude-config-watcher.ts b/src/main/lib/claude-config-watcher.ts new file mode 100644 index 00000000..c3c47e36 --- /dev/null +++ b/src/main/lib/claude-config-watcher.ts @@ -0,0 +1,91 @@ +/** + * Watches ~/.claude.json for changes and notifies renderer to re-initialize MCP servers. + * + * When a user edits their Claude config (e.g., adding/removing MCP servers), + * this watcher detects the change, clears cached MCP data, and notifies + * the renderer so it can refresh MCP server status without requiring a restart. + */ +import { BrowserWindow } from "electron" +import * as os from "os" +import * as path from "path" +import { mcpConfigCache, workingMcpServers } from "./trpc/routers/claude" + +const CLAUDE_CONFIG_PATH = path.join(os.homedir(), ".claude.json") + +// Simple debounce to batch rapid file changes +function debounce unknown>( + func: T, + wait: number, +): (...args: Parameters) => void { + let timeoutId: NodeJS.Timeout | null = null + return (...args: Parameters) => { + if (timeoutId) clearTimeout(timeoutId) + timeoutId = setTimeout(() => func(...args), wait) + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let watcher: any = null + +/** + * Start watching ~/.claude.json for changes. + * When changes are detected: + * 1. Clears the in-memory MCP config cache and working servers cache + * 2. Sends an IPC event to all renderer windows so they can refetch MCP config + */ +export async function startClaudeConfigWatcher(): Promise { + if (watcher) return + + const chokidar = await import("chokidar") + + watcher = chokidar.watch(CLAUDE_CONFIG_PATH, { + persistent: true, + ignoreInitial: true, + awaitWriteFinish: { + stabilityThreshold: 100, + pollInterval: 50, + }, + usePolling: false, + followSymlinks: false, + }) + + const handleChange = debounce(() => { + console.log("[ConfigWatcher] ~/.claude.json changed, clearing MCP caches") + + // Clear MCP-related caches so next session/query reads fresh config + mcpConfigCache.clear() + workingMcpServers.clear() + + // Notify all renderer windows + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + try { + win.webContents.send("claude-config-changed") + } catch { + // Window may have been destroyed between check and send + } + } + } + }, 300) + + watcher + .on("change", () => handleChange()) + .on("add", () => handleChange()) + .on("error", (error: Error) => { + console.error("[ConfigWatcher] Error watching ~/.claude.json:", error) + }) + + console.log("[ConfigWatcher] Watching ~/.claude.json for changes") +} + +/** + * Stop watching ~/.claude.json. + * Call this when the app is shutting down. + */ +export async function stopClaudeConfigWatcher(): Promise { + if (watcher) { + await (watcher as any).close() + watcher = null + console.log("[ConfigWatcher] Stopped watching ~/.claude.json") + } +} diff --git a/src/main/lib/trpc/routers/claude.ts b/src/main/lib/trpc/routers/claude.ts index d6363f59..7f0023e0 100644 --- a/src/main/lib/trpc/routers/claude.ts +++ b/src/main/lib/trpc/routers/claude.ts @@ -172,7 +172,7 @@ function mcpCacheKey(scope: string | null, serverName: string): string { const symlinksCreated = new Set() // Cache for MCP config (avoid re-reading ~/.claude.json on every message) -const mcpConfigCache = new Map | undefined mtime: number }>() diff --git a/src/preload/index.ts b/src/preload/index.ts index 236b4003..3c21458f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -216,6 +216,13 @@ contextBridge.exposeInMainWorld("desktopApi", { subscribeToGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:subscribe-watcher", worktreePath), unsubscribeFromGitWatcher: (worktreePath: string) => ipcRenderer.invoke("git:unsubscribe-watcher", worktreePath), + // Claude config change events (from ~/.claude.json watcher) + onClaudeConfigChanged: (callback: () => void) => { + const handler = () => callback() + ipcRenderer.on("claude-config-changed", handler) + return () => ipcRenderer.removeListener("claude-config-changed", handler) + }, + // VS Code theme scanning scanVSCodeThemes: () => ipcRenderer.invoke("vscode:scan-themes"), loadVSCodeTheme: (themePath: string) => ipcRenderer.invoke("vscode:load-theme", themePath), @@ -347,6 +354,8 @@ export interface DesktopApi { onGitStatusChanged: (callback: (data: { worktreePath: string; changes: Array<{ path: string; type: "add" | "change" | "unlink" }> }) => void) => () => void subscribeToGitWatcher: (worktreePath: string) => Promise unsubscribeFromGitWatcher: (worktreePath: string) => Promise + // Claude config changes (from ~/.claude.json watcher) + onClaudeConfigChanged: (callback: () => void) => () => void // VS Code theme scanning scanVSCodeThemes: () => Promise loadVSCodeTheme: (themePath: string) => Promise diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 9e3c9010..d8175f02 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -14,6 +14,7 @@ import { BillingMethodPage, SelectRepoPage, } from "./features/onboarding" +import { useClaudeConfigWatcher } from "./lib/hooks/use-file-change-listener" import { identify, initAnalytics, shutdown } from "./lib/analytics" import { anthropicOnboardingCompletedAtom, apiKeyOnboardingCompletedAtom, @@ -54,6 +55,9 @@ function AppContent() { const setSelectedChatId = useSetAtom(selectedAgentChatIdAtom) const { setActiveSubChat, addToOpenSubChats, setChatId } = useAgentSubChatStore() + // Watch ~/.claude.json for changes and auto-refresh MCP config + useClaudeConfigWatcher() + // Apply initial window params (chatId/subChatId) when opening via "Open in new window" useEffect(() => { const params = getInitialWindowParams() diff --git a/src/renderer/lib/hooks/use-file-change-listener.ts b/src/renderer/lib/hooks/use-file-change-listener.ts index 25a6f805..b6f274f8 100644 --- a/src/renderer/lib/hooks/use-file-change-listener.ts +++ b/src/renderer/lib/hooks/use-file-change-listener.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react" +import { useEffect, useRef, useCallback } from "react" import { useQueryClient } from "@tanstack/react-query" /** @@ -84,3 +84,31 @@ export function useGitWatcher(worktreePath: string | null | undefined) { } }, [worktreePath, queryClient]) } + +/** + * Hook that listens for ~/.claude.json changes and invalidates MCP config queries. + * This allows MCP servers to be re-initialized when the user edits their config + * without needing to restart the app or manually refresh. + */ +export function useClaudeConfigWatcher() { + const queryClient = useQueryClient() + + const handleConfigChanged = useCallback(() => { + console.log("[useClaudeConfigWatcher] Config changed, invalidating MCP queries") + + // Invalidate all MCP-related queries so they refetch fresh data + queryClient.invalidateQueries({ + queryKey: [["claude", "getAllMcpConfig"]], + }) + queryClient.invalidateQueries({ + queryKey: [["claude", "getMcpConfig"]], + }) + }, [queryClient]) + + useEffect(() => { + const cleanup = window.desktopApi?.onClaudeConfigChanged(handleConfigChanged) + return () => { + cleanup?.() + } + }, [handleConfigChanged]) +}