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 1e89376f..65a95d9e 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,11 +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) - 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 if err := server.SetPreset(mcpPreset); err != nil { diff --git a/cmd/ckb/setup.go b/cmd/ckb/setup.go index 8e6047b6..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 { @@ -691,22 +727,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 } @@ -731,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, @@ -765,6 +821,208 @@ 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 +} + +// 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 { + 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/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 7be94eed..6a7052eb 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" @@ -60,6 +61,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 +107,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 +167,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() @@ -470,6 +527,96 @@ 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, 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 _, statErr := os.Stat(ckbDir); os.IsNotExist(statErr) { + 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 closeErr := currentEngine.DB().Close(); closeErr != nil { + s.logger.Warn("Failed to close old engine database", "error", closeErr.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 3a9e98e4..0d6e7dcd 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(). @@ -130,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{}) @@ -148,8 +184,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 +240,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(). 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