From 24c7f34fe3d1a091456c077c3dec6225002e698d Mon Sep 17 00:00:00 2001 From: Lisa Date: Tue, 27 Jan 2026 17:59:01 +0100 Subject: [PATCH 1/5] fix: lazy engine loading for fast MCP startup MCP server was taking 43+ seconds to respond to initialize handshake because it loaded the entire SCIP index synchronously before starting. Changes: - Add NewMCPServerLazy() that accepts an engine loader function - Engine is now loaded on first tool call, not during startup - MCP handshake completes in ~0.6s instead of 43s - Temporarily disable multi-repo mode to use lazy path everywhere Also improves `ckb setup` for Claude Code: - Detect if CKB is already configured correctly (no action needed) - Warn when configured path differs from current binary - Show note when switching between npx and local binary - Automatically update config instead of failing with "already exists" Co-Authored-By: Claude Opus 4.5 --- cmd/ckb/mcp.go | 28 +++++----- cmd/ckb/setup.go | 124 ++++++++++++++++++++++++++++++++++++----- internal/mcp/server.go | 58 ++++++++++++++++++- 3 files changed, 181 insertions(+), 29 deletions(-) diff --git a/cmd/ckb/mcp.go b/cmd/ckb/mcp.go index 1e89376f..942a2512 100644 --- a/cmd/ckb/mcp.go +++ b/cmd/ckb/mcp.go @@ -15,6 +15,7 @@ import ( "github.com/SimplyLiz/CodeMCP/internal/index" "github.com/SimplyLiz/CodeMCP/internal/mcp" "github.com/SimplyLiz/CodeMCP/internal/project" + "github.com/SimplyLiz/CodeMCP/internal/query" "github.com/SimplyLiz/CodeMCP/internal/repos" "github.com/SimplyLiz/CodeMCP/internal/repostate" "github.com/SimplyLiz/CodeMCP/internal/slogutil" @@ -118,10 +119,9 @@ func runMCP(cmd *cobra.Command, args []string) error { repoName = mcpRepo fmt.Fprintf(os.Stderr, "Repository: %s (%s) [%s]\n", repoName, repoRoot, state) - // Use multi-repo mode - server = mcp.NewMCPServerWithRegistry(version.Version, registry, logger) - engine := mustGetEngine(repoRoot, logger) - server.SetActiveRepo(repoName, repoRoot, engine) + // Skip multi-repo mode - use lazy loading path instead + // TODO: Add lazy loading support to multi-repo mode + _ = registry // silence unused warning } } else { // No --repo flag - use smart resolution @@ -160,13 +160,9 @@ func runMCP(cmd *cobra.Command, args []string) error { } } - // Use multi-repo mode if registry is available - registry, err := repos.LoadRegistry() - if err == nil && resolved.Source != repos.ResolvedFromCWDGit { - server = mcp.NewMCPServerWithRegistry(version.Version, registry, logger) - engine := mustGetEngine(repoRoot, logger) - server.SetActiveRepo(repoName, repoRoot, engine) - } + // Skip multi-repo mode for now - use lazy loading path instead + // TODO: Add lazy loading support to multi-repo mode + _ = repos.LoadRegistry // silence unused warning } else { // No repo found - fall back to current directory repoRoot = mustGetRepoRoot() @@ -199,10 +195,14 @@ func runMCP(cmd *cobra.Command, args []string) error { logger = slogutil.NewTeeLogger(fileLogger.Handler(), stderrHandler) } - // Create server if not already created (legacy single-engine mode) + // Create server if not already created (legacy single-engine mode with lazy loading) if server == nil { - engine := mustGetEngine(repoRoot, logger) - server = mcp.NewMCPServer(version.Version, engine, logger) + // Use lazy loading for fast MCP handshake + // Capture repoRoot and logger for the closure + root, log := repoRoot, logger + server = mcp.NewMCPServerLazy(version.Version, func() (*query.Engine, error) { + return getEngine(root, log) + }, logger) } // Apply preset configuration diff --git a/cmd/ckb/setup.go b/cmd/ckb/setup.go index 8e6047b6..866f0a5c 100644 --- a/cmd/ckb/setup.go +++ b/cmd/ckb/setup.go @@ -691,22 +691,15 @@ func isGrokAvailable() bool { func configureClaudeCodeGlobal(ckbCommand string, ckbArgs []string) error { // Try using claude mcp add command first if isClaudeAvailable() { - cmdArgs := []string{"mcp", "add", "--transport", "stdio", "ckb", "--scope", "user", "--"} - cmdArgs = append(cmdArgs, ckbCommand) - cmdArgs = append(cmdArgs, ckbArgs...) - - fmt.Printf("Running: claude %s\n", formatArgs(cmdArgs)) - - execCmd := exec.Command("claude", cmdArgs...) - execCmd.Stdout = os.Stdout - execCmd.Stderr = os.Stderr - - if err := execCmd.Run(); err != nil { - return fmt.Errorf("failed to add CKB to Claude: %w", err) + changed, err := claudeMcpAdd(ckbCommand, ckbArgs) + if err != nil { + return err } - fmt.Println("\n✓ CKB added to Claude Code globally.") - fmt.Println("Restart Claude Code to load the new configuration.") + if changed { + fmt.Println("\n✓ CKB configured for Claude Code globally.") + fmt.Println("Restart Claude Code to load the new configuration.") + } return nil } @@ -765,6 +758,109 @@ func isClaudeAvailable() bool { return err == nil } +// claudeConfigEntry represents an MCP server entry in Claude's config +type claudeConfigEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args"` +} + +// getClaudeMcpConfig reads the existing ckb MCP config from ~/.claude.json +func getClaudeMcpConfig() (*claudeConfigEntry, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(home, ".claude.json") + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err // File doesn't exist or can't read + } + + var config struct { + McpServers map[string]claudeConfigEntry `json:"mcpServers"` + } + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + if entry, ok := config.McpServers["ckb"]; ok { + return &entry, nil + } + return nil, nil // Not configured +} + +// formatCommand returns a human-readable command string +func formatCommand(command string, args []string) string { + if len(args) > 0 { + return command + " " + strings.Join(args, " ") + } + return command +} + +// isNpxCommand checks if a command is using npx +func isNpxCommand(command string) bool { + return command == "npx" || strings.HasSuffix(command, "/npx") +} + +// claudeMcpAdd adds ckb to Claude Code, handling the case where it already exists. +// Returns (changed, error) where changed indicates if the config was modified. +func claudeMcpAdd(ckbCommand string, ckbArgs []string) (bool, error) { + // Check existing config first + existing, _ := getClaudeMcpConfig() + + newCommand := formatCommand(ckbCommand, ckbArgs) + + if existing != nil { + existingCommand := formatCommand(existing.Command, existing.Args) + + // Check if already configured with the same command + if existingCommand == newCommand { + fmt.Println("CKB is already configured correctly.") + fmt.Printf(" Command: %s\n", existingCommand) + return false, nil + } + + // Different paths - warn and update + fmt.Println("CKB is already configured with a different path:") + fmt.Printf(" Current: %s\n", existingCommand) + fmt.Printf(" New: %s\n", newCommand) + + // Extra warning for npx vs binary mismatch + if isNpxCommand(existing.Command) && !isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from npx to local binary.") + } else if !isNpxCommand(existing.Command) && isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from local binary to npx.") + } + + fmt.Println("\nUpdating configuration...") + + // Remove existing entry + removeCmd := exec.Command("claude", "mcp", "remove", "ckb", "--scope", "user") + removeCmd.Stdout = os.Stdout + removeCmd.Stderr = os.Stderr + if err := removeCmd.Run(); err != nil { + return false, fmt.Errorf("failed to remove existing CKB config: %w", err) + } + } + + // Add new entry + cmdArgs := []string{"mcp", "add", "--transport", "stdio", "ckb", "--scope", "user", "--"} + cmdArgs = append(cmdArgs, ckbCommand) + cmdArgs = append(cmdArgs, ckbArgs...) + + execCmd := exec.Command("claude", cmdArgs...) + execCmd.Stdout = os.Stdout + execCmd.Stderr = os.Stderr + + if err := execCmd.Run(); err != nil { + return false, fmt.Errorf("failed to add CKB to Claude: %w", err) + } + + return true, nil +} + func formatArgs(args []string) string { result := "" for i, arg := range args { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 7be94eed..6031c268 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -60,6 +60,11 @@ type MCPServer struct { // Binary staleness detection (v8.0) binaryPath string // Path to the running binary binaryModTime time.Time // Modification time at startup + + // Lazy engine loading (for fast MCP startup) + engineLoader func() (*query.Engine, error) + engineOnce sync.Once + engineErr error } // NewMCPServer creates a new MCP server in legacy single-engine mode @@ -101,6 +106,37 @@ func NewMCPServerForCLI() *MCPServer { } } +// EngineLoader is a function that creates an engine on demand +type EngineLoader func() (*query.Engine, error) + +// NewMCPServerLazy creates a new MCP server with lazy engine loading. +// The engine is not created until the first tool call that needs it. +// This allows the MCP handshake to complete quickly. +func NewMCPServerLazy(version string, loader EngineLoader, logger *slog.Logger) *MCPServer { + server := &MCPServer{ + stdin: os.Stdin, + stdout: os.Stdout, + logger: logger, + version: version, + engineLoader: loader, + tools: make(map[string]ToolHandler), + resources: make(map[string]ResourceHandler), + activePreset: DefaultPreset, + roots: newRootsManager(), + } + + // Record binary info for staleness detection + server.recordBinaryInfo() + + // Register all tools + server.RegisterTools() + + // Compute initial toolset hash + server.updateToolsetHash() + + return server +} + // NewMCPServerWithRegistry creates a new MCP server with multi-repo support func NewMCPServerWithRegistry(version string, registry *repos.Registry, logger *slog.Logger) *MCPServer { server := &MCPServer{ @@ -130,11 +166,31 @@ func NewMCPServerWithRegistry(version string, registry *repos.Registry, logger * // engine returns the current engine (for backward compatibility with tool handlers) func (s *MCPServer) engine() *query.Engine { - // Legacy mode + // Legacy mode with preloaded engine if s.legacyEngine != nil { return s.legacyEngine } + // Legacy mode with lazy loading + if s.engineLoader != nil { + s.engineOnce.Do(func() { + s.logger.Info("Loading engine (lazy initialization)...") + engine, err := s.engineLoader() + if err != nil { + s.engineErr = err + s.logger.Error("Failed to load engine", "error", err.Error()) + return + } + s.legacyEngine = engine + // Wire up metrics persistence + if engine != nil && engine.DB() != nil { + SetMetricsDB(engine.DB()) + } + s.logger.Info("Engine loaded successfully") + }) + return s.legacyEngine + } + // Multi-repo mode s.mu.RLock() defer s.mu.RUnlock() From 3d702d1a5620b81d1434c29d7119eaac378ef505 Mon Sep 17 00:00:00 2001 From: Lisa Date: Fri, 30 Jan 2026 12:24:46 +0100 Subject: [PATCH 2/5] fix: enrich not-found errors with repo context for clients without roots support When an MCP client (e.g. Cursor) doesn't support roots/list, tool errors like SYMBOL_NOT_FOUND give no indication that the index might be for a different project. This adds repo path context to those errors so the AI agent can understand the mismatch. Also switches compound tools to GetEngine() to prevent nil panics with lazy loading. Co-Authored-By: Claude Opus 4.5 --- .gitignore | 1 + cmd/ckb/mcp.go | 15 ++++------ internal/mcp/server.go | 29 +++++++++++++++++++ internal/mcp/tool_impls_compound.go | 43 +++++++++++++++++++++++------ 4 files changed, 70 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index f97442ad..adeb9e91 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ coverage.out # IDE .idea/ .vscode/ +.cursor/ *.swp *.swo diff --git a/cmd/ckb/mcp.go b/cmd/ckb/mcp.go index 942a2512..65a95d9e 100644 --- a/cmd/ckb/mcp.go +++ b/cmd/ckb/mcp.go @@ -195,15 +195,12 @@ func runMCP(cmd *cobra.Command, args []string) error { logger = slogutil.NewTeeLogger(fileLogger.Handler(), stderrHandler) } - // Create server if not already created (legacy single-engine mode with lazy loading) - if server == nil { - // Use lazy loading for fast MCP handshake - // Capture repoRoot and logger for the closure - root, log := repoRoot, logger - server = mcp.NewMCPServerLazy(version.Version, func() (*query.Engine, error) { - return getEngine(root, log) - }, logger) - } + // Use lazy loading for fast MCP handshake + // Capture repoRoot and logger for the closure + root, log := repoRoot, logger + server = mcp.NewMCPServerLazy(version.Version, func() (*query.Engine, error) { + return getEngine(root, log) + }, logger) // Apply preset configuration if err := server.SetPreset(mcpPreset); err != nil { diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6031c268..2c137692 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -2,6 +2,7 @@ package mcp import ( "bufio" + stderrors "errors" "fmt" "io" "log/slog" @@ -526,6 +527,34 @@ func (s *MCPServer) switchToClientRoot(clientRoot string) { ) } +// enrichNotFoundError adds repo context to "not found" errors when the client +// didn't provide roots (e.g. Cursor). This prevents AI agents from hallucinating +// explanations for missing symbols/paths that are actually caused by a repo mismatch. +func (s *MCPServer) enrichNotFoundError(err error) error { + // If client supports roots, auto-switch should have handled it — don't add noise + if s.roots != nil && s.roots.IsClientSupported() { + return err + } + + var ckbErr *errors.CkbError + if !stderrors.As(err, &ckbErr) { + return err + } + + if ckbErr.Code != errors.ResourceNotFound && ckbErr.Code != errors.SymbolNotFound { + return err + } + + engine := s.engine() + if engine == nil { + return err + } + + enriched := fmt.Sprintf("%s (note: CKB index is for %s — if your project is in a different directory, restart CKB there or run 'ckb setup' from that repo)", + ckbErr.Message, engine.GetRepoRoot()) + return errors.NewCkbError(ckbErr.Code, enriched, ckbErr.Unwrap(), ckbErr.SuggestedFixes, ckbErr.Drilldowns) +} + // recordBinaryInfo records the current binary's path and modification time func (s *MCPServer) recordBinaryInfo() { execPath, err := os.Executable() diff --git a/internal/mcp/tool_impls_compound.go b/internal/mcp/tool_impls_compound.go index 3a9e98e4..1023ffb8 100644 --- a/internal/mcp/tool_impls_compound.go +++ b/internal/mcp/tool_impls_compound.go @@ -42,14 +42,19 @@ func (s *MCPServer) toolExplore(params map[string]interface{}) (*envelope.Respon } } + engine, err := s.GetEngine() + if err != nil { + return nil, err + } + ctx := context.Background() - result, err := s.engine().Explore(ctx, query.ExploreOptions{ + result, err := engine.Explore(ctx, query.ExploreOptions{ Target: target, Depth: depth, Focus: focus, }) if err != nil { - return nil, err + return nil, s.enrichNotFoundError(err) } return NewToolResponse(). @@ -79,15 +84,20 @@ func (s *MCPServer) toolUnderstand(params map[string]interface{}) (*envelope.Res maxReferences = int(v) } + engine, err := s.GetEngine() + if err != nil { + return nil, err + } + ctx := context.Background() - result, err := s.engine().Understand(ctx, query.UnderstandOptions{ + result, err := engine.Understand(ctx, query.UnderstandOptions{ Query: q, IncludeReferences: includeReferences, IncludeCallGraph: includeCallGraph, MaxReferences: maxReferences, }) if err != nil { - return nil, err + return nil, s.enrichNotFoundError(err) } return NewToolResponse(). @@ -116,13 +126,18 @@ func (s *MCPServer) toolPrepareChange(params map[string]interface{}) (*envelope. } } + engine, err := s.GetEngine() + if err != nil { + return nil, err + } + ctx := context.Background() - result, err := s.engine().PrepareChange(ctx, query.PrepareChangeOptions{ + result, err := engine.PrepareChange(ctx, query.PrepareChangeOptions{ Target: target, ChangeType: changeType, }) if err != nil { - return nil, err + return nil, s.enrichNotFoundError(err) } return NewToolResponse(). @@ -148,8 +163,13 @@ func (s *MCPServer) toolBatchGet(params map[string]interface{}) (*envelope.Respo return nil, errors.NewInvalidParameterError("symbolIds", "must contain string values") } + engine, err := s.GetEngine() + if err != nil { + return nil, err + } + ctx := context.Background() - result, err := s.engine().BatchGet(ctx, query.BatchGetOptions{ + result, err := engine.BatchGet(ctx, query.BatchGetOptions{ SymbolIds: ids, }) if err != nil { @@ -199,12 +219,17 @@ func (s *MCPServer) toolBatchSearch(params map[string]interface{}) (*envelope.Re return nil, errors.NewInvalidParameterError("queries", "must contain valid query objects") } + engine, err := s.GetEngine() + if err != nil { + return nil, err + } + ctx := context.Background() - result, err := s.engine().BatchSearch(ctx, query.BatchSearchOptions{ + result, err := engine.BatchSearch(ctx, query.BatchSearchOptions{ Queries: queries, }) if err != nil { - return nil, err + return nil, s.enrichNotFoundError(err) } return NewToolResponse(). From ae4b841373caed550f982a89505ebbd06e09c643 Mon Sep 17 00:00:00 2001 From: Lisa Date: Fri, 30 Jan 2026 12:48:30 +0100 Subject: [PATCH 3/5] fix: prevent redundant entries in Grok and VS Code global setup configureGrokGlobal and configureVSCodeGlobal now check for existing config before calling their respective CLIs, matching the pattern already used by configureClaudeCodeGlobal via claudeMcpAdd. Co-Authored-By: Claude Opus 4.5 --- cmd/ckb/setup.go | 176 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 169 insertions(+), 7 deletions(-) diff --git a/cmd/ckb/setup.go b/cmd/ckb/setup.go index 866f0a5c..edc25a25 100644 --- a/cmd/ckb/setup.go +++ b/cmd/ckb/setup.go @@ -373,7 +373,8 @@ func configureTool(tool *aiTool, global bool, ckbCommand string, ckbArgs []strin case "vscode": return configureVSCodeGlobal(ckbCommand, ckbArgs) case "grok": - return configureGrokGlobal(ckbCommand, ckbArgs) + _, err := configureGrokGlobal(ckbCommand, ckbArgs) + return err } } @@ -646,9 +647,44 @@ func writeGrokConfig(path, command string, args []string) error { return os.WriteFile(path, data, 0644) } -func configureGrokGlobal(ckbCommand string, ckbArgs []string) error { +func configureGrokGlobal(ckbCommand string, ckbArgs []string) (bool, error) { // Try using grok mcp add command first if isGrokAvailable() { + newCommand := formatCommand(ckbCommand, ckbArgs) + + // Check existing config before calling CLI + existing, _ := getGrokMcpConfig() + if existing != nil { + existingCommand := formatCommand(existing.Command, existing.Args) + + if existingCommand == newCommand { + fmt.Println("CKB is already configured correctly.") + fmt.Printf(" Command: %s\n", existingCommand) + return false, nil + } + + // Different config - warn and update + fmt.Println("CKB is already configured with a different path:") + fmt.Printf(" Current: %s\n", existingCommand) + fmt.Printf(" New: %s\n", newCommand) + + if isNpxCommand(existing.Command) && !isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from npx to local binary.") + } else if !isNpxCommand(existing.Command) && isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from local binary to npx.") + } + + fmt.Println("\nUpdating configuration...") + + // Remove existing entry first + removeCmd := exec.Command("grok", "mcp", "remove", "ckb") + removeCmd.Stdout = os.Stdout + removeCmd.Stderr = os.Stderr + if err := removeCmd.Run(); err != nil { + return false, fmt.Errorf("failed to remove existing CKB config from Grok: %w", err) + } + } + cmdArgs := []string{"mcp", "add", "ckb", "--transport", "stdio", "--command", ckbCommand} for _, arg := range ckbArgs { cmdArgs = append(cmdArgs, "--args", arg) @@ -661,26 +697,26 @@ func configureGrokGlobal(ckbCommand string, ckbArgs []string) error { execCmd.Stderr = os.Stderr if err := execCmd.Run(); err != nil { - return fmt.Errorf("failed to add CKB to Grok: %w", err) + return false, fmt.Errorf("failed to add CKB to Grok: %w", err) } fmt.Println("\n✓ CKB added to Grok globally.") fmt.Println("Restart Grok to load the new configuration.") - return nil + return true, nil } // Fallback to writing ~/.grok/user-settings.json fmt.Println("Grok CLI not found, using fallback configuration...") configPath := getConfigPath("grok", true) if err := writeGrokConfig(configPath, ckbCommand, ckbArgs); err != nil { - return err + return false, err } fmt.Printf("\n✓ Added CKB to %s\n", configPath) fmt.Printf(" Command: %s %s\n", ckbCommand, strings.Join(ckbArgs, " ")) fmt.Println("\nRestart Grok to load the new configuration.") - return nil + return true, nil } func isGrokAvailable() bool { @@ -724,8 +760,35 @@ func configureVSCodeGlobal(ckbCommand string, ckbArgs []string) error { return fmt.Errorf("VS Code CLI (code) not found. Please ensure VS Code is installed and 'code' is in your PATH") } + newCommand := formatCommand(ckbCommand, ckbArgs) + + // Check existing config before calling CLI + existing, _ := getVSCodeGlobalMcpConfig() + if existing != nil { + existingCommand := formatCommand(existing.Command, existing.Args) + + if existingCommand == newCommand { + fmt.Println("CKB is already configured correctly.") + fmt.Printf(" Command: %s\n", existingCommand) + return nil + } + + // Different config - warn and proceed (code --add-mcp overwrites) + fmt.Println("CKB is already configured with a different path:") + fmt.Printf(" Current: %s\n", existingCommand) + fmt.Printf(" New: %s\n", newCommand) + + if isNpxCommand(existing.Command) && !isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from npx to local binary.") + } else if !isNpxCommand(existing.Command) && isNpxCommand(ckbCommand) { + fmt.Println("\n Note: Switching from local binary to npx.") + } + + fmt.Println("\nUpdating configuration...") + } + // Build the MCP server JSON - serverConfig := map[string]interface{}{ + serverConfig := map[string]any{ "name": "ckb", "type": "stdio", "command": ckbCommand, @@ -791,6 +854,105 @@ func getClaudeMcpConfig() (*claudeConfigEntry, error) { return nil, nil // Not configured } +// getGrokMcpConfig reads the existing ckb MCP config from ~/.grok/user-settings.json +func getGrokMcpConfig() (*grokMcpEntry, error) { + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + + configPath := filepath.Join(home, ".grok", "user-settings.json") + data, err := os.ReadFile(configPath) + if err != nil { + return nil, err // File doesn't exist or can't read + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + mcpServersRaw, ok := raw["mcpServers"] + if !ok { + return nil, nil + } + + var mcpServers map[string]grokMcpEntry + if err := json.Unmarshal(mcpServersRaw, &mcpServers); err != nil { + return nil, err + } + + if entry, ok := mcpServers["ckb"]; ok { + return &entry, nil + } + return nil, nil // Not configured +} + +// vsCodeMcpEntry represents an MCP server entry in VS Code's user settings +type vsCodeMcpEntry struct { + Type string `json:"type"` + Command string `json:"command"` + Args []string `json:"args"` +} + +// getVSCodeGlobalMcpConfig reads the existing ckb MCP config from VS Code's user settings.json +func getVSCodeGlobalMcpConfig() (*vsCodeMcpEntry, error) { + var settingsPath string + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + settingsPath = filepath.Join(home, "Library", "Application Support", "Code", "User", "settings.json") + case "linux": + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + settingsPath = filepath.Join(home, ".config", "Code", "User", "settings.json") + case "windows": + settingsPath = filepath.Join(os.Getenv("APPDATA"), "Code", "User", "settings.json") + default: + return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + data, err := os.ReadFile(settingsPath) + if err != nil { + return nil, err // File doesn't exist or can't read + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return nil, err + } + + mcpRaw, ok := raw["mcp"] + if !ok { + return nil, nil + } + + var mcpSection map[string]json.RawMessage + if err := json.Unmarshal(mcpRaw, &mcpSection); err != nil { + return nil, err + } + + serversRaw, ok := mcpSection["servers"] + if !ok { + return nil, nil + } + + var servers map[string]vsCodeMcpEntry + if err := json.Unmarshal(serversRaw, &servers); err != nil { + return nil, err + } + + if entry, ok := servers["ckb"]; ok { + return &entry, nil + } + return nil, nil // Not configured +} + // formatCommand returns a human-readable command string func formatCommand(command string, args []string) string { if len(args) > 0 { From 39b58a62a50b5979b51f38a1319827d56d57f5a9 Mon Sep 17 00:00:00 2001 From: Lisa Date: Sat, 31 Jan 2026 10:25:46 +0100 Subject: [PATCH 4/5] feat: add switchProject tool for dynamic repo switching Cursor (and other MCP clients without roots/list support) doesn't pass the workspace directory to MCP servers, so CKB falls back to the wrong project. The new switchProject tool lets AI agents self-correct by switching to the correct repo path at runtime. Changes: - Add switchProject() method and toolSwitchProject handler - Register tool in core preset (available in all presets) - Update enrichNotFoundError() to suggest switchProject instead of restart - Bump DefaultPageSize from 15 to 40 so all core tools appear on page 1 (Cursor doesn't request subsequent pages) - Update tests for new tool count (core: 20, full: 88) Co-Authored-By: Claude Opus 4.5 --- internal/mcp/cursor.go | 6 ++- internal/mcp/presets.go | 12 +++--- internal/mcp/presets_test.go | 14 +++---- internal/mcp/server.go | 64 ++++++++++++++++++++++++++++- internal/mcp/tool_impls_compound.go | 21 ++++++++++ internal/mcp/tools.go | 17 ++++++++ 6 files changed, 119 insertions(+), 15 deletions(-) diff --git a/internal/mcp/cursor.go b/internal/mcp/cursor.go index 2d0d63be..e1d8c139 100644 --- a/internal/mcp/cursor.go +++ b/internal/mcp/cursor.go @@ -7,8 +7,10 @@ import ( "github.com/SimplyLiz/CodeMCP/internal/errors" ) -// DefaultPageSize is the default number of tools per page -const DefaultPageSize = 15 +// DefaultPageSize is the default number of tools per page. +// Must be >= number of core preset tools so all core tools appear on page 1 +// (Cursor and some other MCP clients don't request subsequent pages). +const DefaultPageSize = 40 // ToolsCursorPayload contains pagination state for tools/list // diff --git a/internal/mcp/presets.go b/internal/mcp/presets.go index 3c08ac57..cbf1b028 100644 --- a/internal/mcp/presets.go +++ b/internal/mcp/presets.go @@ -59,6 +59,7 @@ var Presets = map[string][]string{ // System "getStatus", + "switchProject", // v8.1: Dynamic project switching // Meta (always included) "expandToolset", @@ -71,7 +72,7 @@ var Presets = map[string][]string{ "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", // Review-specific "summarizeDiff", "summarizePr", @@ -88,7 +89,7 @@ var Presets = map[string][]string{ "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", // Refactor-specific "justifySymbol", "analyzeCoupling", @@ -108,7 +109,7 @@ var Presets = map[string][]string{ "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", // Federation-specific "listFederations", "federationStatus", @@ -133,7 +134,7 @@ var Presets = map[string][]string{ "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", // Docs-specific "indexDocs", "getDocsForSymbol", @@ -150,7 +151,7 @@ var Presets = map[string][]string{ "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", // Ops-specific "doctor", "reindex", @@ -221,6 +222,7 @@ var coreToolOrder = []string{ "analyzeImpact", "getHotspots", "getStatus", + "switchProject", "expandToolset", } diff --git a/internal/mcp/presets_test.go b/internal/mcp/presets_test.go index 8c3c3441..634561fb 100644 --- a/internal/mcp/presets_test.go +++ b/internal/mcp/presets_test.go @@ -12,10 +12,10 @@ func TestPresetFiltering(t *testing.T) { server := NewMCPServer("test", nil, logger) // Test core preset (default) - // v8.0: Core now includes 5 compound tools (explore, understand, prepareChange, batchGet, batchSearch) + // v8.1: Core now includes 5 compound tools + switchProject coreTools := server.GetFilteredTools() - if len(coreTools) != 19 { - t.Errorf("expected 19 core tools (v8.0 includes compound tools), got %d", len(coreTools)) + if len(coreTools) != 20 { + t.Errorf("expected 20 core tools (v8.1 includes switchProject), got %d", len(coreTools)) } // Verify compound tools come first (preferred for AI workflows) @@ -24,7 +24,7 @@ func TestPresetFiltering(t *testing.T) { "searchSymbols", "getSymbol", "explainSymbol", "explainFile", "findReferences", "getCallGraph", "traceUsage", "getArchitecture", "getModuleOverview", "listKeyConcepts", - "analyzeImpact", "getHotspots", "getStatus", "expandToolset", + "analyzeImpact", "getHotspots", "getStatus", "switchProject", "expandToolset", } for i, expected := range expectedFirst { if i >= len(coreTools) { @@ -41,9 +41,9 @@ func TestPresetFiltering(t *testing.T) { t.Fatalf("failed to set full preset: %v", err) } fullTools := server.GetFilteredTools() - // v8.0: Full now includes 5 compound tools + scanSecrets (87 = 81 + 5 + 1) - if len(fullTools) != 87 { - t.Errorf("expected 87 full tools (v8.0 includes compound tools + scanSecrets), got %d", len(fullTools)) + // v8.1: Full now includes switchProject (88 = 87 + 1) + if len(fullTools) != 88 { + t.Errorf("expected 88 full tools (v8.1 includes switchProject), got %d", len(fullTools)) } // Full preset should still have core tools first diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 2c137692..02ba1dba 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -550,11 +550,73 @@ func (s *MCPServer) enrichNotFoundError(err error) error { return err } - enriched := fmt.Sprintf("%s (note: CKB index is for %s — if your project is in a different directory, restart CKB there or run 'ckb setup' from that repo)", + enriched := fmt.Sprintf("%s (note: CKB index is for %s — if your project is in a different directory, call the switchProject tool with the correct path to switch)", ckbErr.Message, engine.GetRepoRoot()) return errors.NewCkbError(ckbErr.Code, enriched, ckbErr.Unwrap(), ckbErr.SuggestedFixes, ckbErr.Drilldowns) } +// switchProject switches the active engine to a different project directory. +// Works in all modes: single-engine, lazy, and multi-repo. +func (s *MCPServer) switchProject(path string) (string, error) { + // Validate path exists and is a directory + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("path does not exist: %w", err) + } + if !info.IsDir() { + return "", fmt.Errorf("path is not a directory: %s", path) + } + + // Find git root + gitRoot := repos.FindGitRoot(path) + if gitRoot == "" { + return "", fmt.Errorf("not a git repository: %s", path) + } + + // Check if .ckb/ exists (initialized) + ckbDir := filepath.Join(gitRoot, ".ckb") + if _, err := os.Stat(ckbDir); os.IsNotExist(err) { + return "", fmt.Errorf("CKB not initialized for %s — run 'ckb setup' from that directory first", gitRoot) + } + + // Check if we're already on this root + currentEngine := s.engine() + if currentEngine != nil { + currentRoot := filepath.Clean(currentEngine.GetRepoRoot()) + if currentRoot == filepath.Clean(gitRoot) { + return gitRoot, nil // already there + } + } + + // Close old engine if any + if currentEngine != nil && currentEngine.DB() != nil { + if err := currentEngine.DB().Close(); err != nil { + s.logger.Warn("Failed to close old engine database", "error", err.Error()) + } + } + + // Create new engine for the target root + newEngine, err := s.createEngineForRoot(gitRoot) + if err != nil { + return "", fmt.Errorf("failed to create engine for %s: %w", gitRoot, err) + } + + // Update engine state + s.mu.Lock() + s.legacyEngine = newEngine + s.engineOnce = sync.Once{} // reset for lazy mode + s.engineErr = nil + s.mu.Unlock() + + // Wire up metrics persistence + if newEngine.DB() != nil { + SetMetricsDB(newEngine.DB()) + } + + s.logger.Info("Switched project", "root", gitRoot) + return gitRoot, nil +} + // recordBinaryInfo records the current binary's path and modification time func (s *MCPServer) recordBinaryInfo() { execPath, err := os.Executable() diff --git a/internal/mcp/tool_impls_compound.go b/internal/mcp/tool_impls_compound.go index 1023ffb8..0d6e7dcd 100644 --- a/internal/mcp/tool_impls_compound.go +++ b/internal/mcp/tool_impls_compound.go @@ -145,6 +145,27 @@ func (s *MCPServer) toolPrepareChange(params map[string]interface{}) (*envelope. Build(), nil } +// toolSwitchProject switches CKB to a different project directory +func (s *MCPServer) toolSwitchProject(params map[string]interface{}) (*envelope.Response, error) { + path, ok := params["path"].(string) + if !ok || path == "" { + return nil, errors.NewInvalidParameterError("path", "required") + } + + newRoot, err := s.switchProject(path) + if err != nil { + return nil, err + } + + return NewToolResponse(). + Data(map[string]interface{}{ + "switched": true, + "repoRoot": newRoot, + "message": "Successfully switched to " + newRoot, + }). + Build(), nil +} + // toolBatchGet retrieves multiple symbols by ID func (s *MCPServer) toolBatchGet(params map[string]interface{}) (*envelope.Response, error) { symbolIds, ok := params["symbolIds"].([]interface{}) diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 6e3e0ee8..4a50d29c 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -1977,6 +1977,21 @@ func (s *MCPServer) GetToolDefinitions() []Tool { "properties": map[string]interface{}{}, }, }, + // v8.1 Dynamic project switching + { + Name: "switchProject", + Description: "Switch CKB to a different project directory. Use this when CKB is indexed for the wrong project. Accepts any git repository path — it does not need to be pre-registered.", + InputSchema: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "path": map[string]interface{}{ + "type": "string", + "description": "Absolute path to the project directory (must be a git repository with CKB initialized)", + }, + }, + "required": []string{"path"}, + }, + }, // v8.0 Compound Tools - aggregate multiple queries to reduce tool calls { Name: "explore", @@ -2210,6 +2225,8 @@ func (s *MCPServer) RegisterTools() { s.tools["listRepos"] = s.toolListRepos s.tools["switchRepo"] = s.toolSwitchRepo s.tools["getActiveRepo"] = s.toolGetActiveRepo + // v8.1 Dynamic project switching + s.tools["switchProject"] = s.toolSwitchProject // v8.0 Compound Tools s.tools["explore"] = s.toolExplore s.tools["understand"] = s.toolUnderstand From 3033be554439d98ed737e9561a76eebd9aa349c4 Mon Sep 17 00:00:00 2001 From: Lisa Date: Sat, 31 Jan 2026 11:01:04 +0100 Subject: [PATCH 5/5] fix: resolve govet shadow warnings in switchProject Co-Authored-By: Claude Opus 4.5 --- internal/mcp/server.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 02ba1dba..6a7052eb 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -575,7 +575,7 @@ func (s *MCPServer) switchProject(path string) (string, error) { // Check if .ckb/ exists (initialized) ckbDir := filepath.Join(gitRoot, ".ckb") - if _, err := os.Stat(ckbDir); os.IsNotExist(err) { + if _, statErr := os.Stat(ckbDir); os.IsNotExist(statErr) { return "", fmt.Errorf("CKB not initialized for %s — run 'ckb setup' from that directory first", gitRoot) } @@ -590,8 +590,8 @@ func (s *MCPServer) switchProject(path string) (string, error) { // Close old engine if any if currentEngine != nil && currentEngine.DB() != nil { - if err := currentEngine.DB().Close(); err != nil { - s.logger.Warn("Failed to close old engine database", "error", err.Error()) + if closeErr := currentEngine.DB().Close(); closeErr != nil { + s.logger.Warn("Failed to close old engine database", "error", closeErr.Error()) } }