From 08f4b01f78c12b136ecdc34047bd8a41a3fb5901 Mon Sep 17 00:00:00 2001 From: Lisa Date: Sat, 7 Feb 2026 09:53:21 +0100 Subject: [PATCH] feat: Auto-resolve active repository from file paths in MCP tool params MCP tools now auto-detect which repo a call is for based on file paths in the params (filePath, path, targetPath, target, moduleId). This eliminates the need for explicit switchRepo calls before querying a different repo. - Add repo_resolver.go with extractPathHint() and resolveRepoForPath() - Add engine_cache.go with getOrCreateEngine() and ensureActiveEngine() - Hook auto-resolution into handleCallTool dispatch - Initialize engine cache in all server constructors - Remove IsMultiRepoMode() gates from listRepos/switchRepo/getActiveRepo - Remove TODO skips in cmd/ckb/mcp.go Co-Authored-By: Claude Opus 4.5 --- cmd/ckb/mcp.go | 12 +- internal/mcp/engine_cache.go | 119 +++++++++++++ internal/mcp/handler.go | 7 + internal/mcp/repo_resolver.go | 68 +++++++ internal/mcp/server.go | 104 +++++------ internal/mcp/tool_impls_multirepo.go | 254 +++++++++++---------------- 6 files changed, 351 insertions(+), 213 deletions(-) create mode 100644 internal/mcp/engine_cache.go create mode 100644 internal/mcp/repo_resolver.go diff --git a/cmd/ckb/mcp.go b/cmd/ckb/mcp.go index 65a95d9e..78268fc3 100644 --- a/cmd/ckb/mcp.go +++ b/cmd/ckb/mcp.go @@ -118,10 +118,6 @@ func runMCP(cmd *cobra.Command, args []string) error { repoRoot = entry.Path repoName = mcpRepo fmt.Fprintf(os.Stderr, "Repository: %s (%s) [%s]\n", repoName, repoRoot, state) - - // 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,9 +156,6 @@ func runMCP(cmd *cobra.Command, args []string) error { } } - // 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() @@ -370,6 +363,11 @@ func triggerReindex(repoRoot, ckbDir string, trigger index.RefreshTrigger, trigg logger.Error("Failed to save index metadata", "error", err.Error()) } + // Populate incremental tracking tables so subsequent incremental updates work + if project.SupportsIncrementalIndexing(config.Language) { + populateIncrementalTracking(repoRoot, config.Language) + } + logger.Info("Reindex complete", "trigger", string(trigger), "duration", duration.String(), diff --git a/internal/mcp/engine_cache.go b/internal/mcp/engine_cache.go new file mode 100644 index 00000000..78a46a5f --- /dev/null +++ b/internal/mcp/engine_cache.go @@ -0,0 +1,119 @@ +package mcp + +import ( + "path/filepath" + "sync" + "time" +) + +// getOrCreateEngine returns a cached engine for the given repo root, creating one if needed. +// Thread-safe: uses s.mu for synchronization. +func (s *MCPServer) getOrCreateEngine(repoRoot string) (*engineEntry, error) { + normalized := normalizePath(repoRoot) + + // Fast path: check cache with read lock + s.mu.RLock() + if entry, ok := s.engines[normalized]; ok { + entry.lastUsed = time.Now() + s.mu.RUnlock() + return entry, nil + } + s.mu.RUnlock() + + // Slow path: upgrade to write lock, double-check, create + s.mu.Lock() + defer s.mu.Unlock() + + // Double-check after acquiring write lock + if entry, ok := s.engines[normalized]; ok { + entry.lastUsed = time.Now() + return entry, nil + } + + // Evict LRU if at capacity + if len(s.engines) >= maxEngines { + s.evictLRULocked() + } + + // Create new engine + engine, err := s.createEngineForRoot(normalized) + if err != nil { + return nil, err + } + + entry := &engineEntry{ + engine: engine, + repoPath: normalized, + repoName: filepath.Base(normalized), + loadedAt: time.Now(), + lastUsed: time.Now(), + } + s.engines[normalized] = entry + + s.logger.Info("Created engine for repo", + "path", normalized, + "totalLoaded", len(s.engines), + ) + + return entry, nil +} + +// ensureActiveEngine switches the active engine to the repo at repoRoot, if needed. +// No-op if repoRoot is empty or already the active repo. +// MCP over stdio is sequential, so no race on legacyEngine. +func (s *MCPServer) ensureActiveEngine(repoRoot string) error { + if repoRoot == "" { + return nil + } + + normalized := normalizePath(repoRoot) + + // Check if current engine already points here + if eng := s.engine(); eng != nil { + currentRoot := normalizePath(eng.GetRepoRoot()) + if currentRoot == normalized { + return nil + } + } + + entry, err := s.getOrCreateEngine(normalized) + if err != nil { + s.logger.Warn("Auto-resolve failed, keeping current engine", + "targetRoot", normalized, + "error", err.Error(), + ) + return err + } + + // Swap the active engine pointer + s.mu.Lock() + s.legacyEngine = entry.engine + s.activeRepo = entry.repoName + s.activeRepoPath = entry.repoPath + s.engineOnce = sync.Once{} // mark as loaded + s.engineErr = nil + s.mu.Unlock() + + // Wire up metrics persistence + if entry.engine.DB() != nil { + SetMetricsDB(entry.engine.DB()) + } + + s.logger.Info("Auto-resolved active repo", + "repo", entry.repoName, + "path", entry.repoPath, + ) + + return nil +} + +// normalizePath cleans and resolves symlinks for a path. +// Always returns a usable path — falls back to filepath.Clean if symlink resolution fails. +func normalizePath(path string) string { + cleaned := filepath.Clean(path) + resolved, err := filepath.EvalSymlinks(cleaned) + if err != nil { + return cleaned + } + return resolved +} diff --git a/internal/mcp/handler.go b/internal/mcp/handler.go index 1ae8bdea..58621403 100644 --- a/internal/mcp/handler.go +++ b/internal/mcp/handler.go @@ -338,6 +338,13 @@ func (s *MCPServer) handleCallTool(params map[string]interface{}) (interface{}, "params", toolParams, ) + // v8.1: Auto-resolve active repository from file paths in params + if pathHint := extractPathHint(toolParams); pathHint != "" { + if repoRoot := s.resolveRepoForPath(pathHint); repoRoot != "" { + _ = s.ensureActiveEngine(repoRoot) + } + } + // v8.0: Check for streaming request if streamResp, err := s.wrapForStreaming(toolName, toolParams); streamResp != nil || err != nil { if err != nil { diff --git a/internal/mcp/repo_resolver.go b/internal/mcp/repo_resolver.go new file mode 100644 index 00000000..29f35f66 --- /dev/null +++ b/internal/mcp/repo_resolver.go @@ -0,0 +1,68 @@ +package mcp + +import ( + "os" + "path/filepath" + "strings" + + "github.com/SimplyLiz/CodeMCP/internal/repos" +) + +// pathParams is the ordered list of tool parameter names that may contain file paths. +// We check these in priority order and return the first path-like value found. +var pathParams = []string{"filePath", "path", "targetPath", "target", "moduleId"} + +// extractPathHint extracts a file path hint from tool parameters. +// Returns the first path-like value found, or "" if none. +func extractPathHint(toolParams map[string]interface{}) string { + for _, key := range pathParams { + val, ok := toolParams[key].(string) + if !ok || val == "" { + continue + } + + // "target" is overloaded: sometimes a symbol name like "MCPServer.GetEngine". + // Only treat it as a path if it contains a path separator. + if key == "target" && !strings.Contains(val, "/") && !strings.Contains(val, "\\") { + continue + } + + return val + } + return "" +} + +// resolveRepoForPath resolves a path hint to a git repo root. +// Returns the repo root path or "" if the hint cannot be resolved. +func (s *MCPServer) resolveRepoForPath(pathHint string) string { + if pathHint == "" { + return "" + } + + // Absolute path: resolve directly + if filepath.IsAbs(pathHint) { + return repos.FindGitRoot(pathHint) + } + + // Relative path: try against each client root + for _, root := range s.GetRootPaths() { + candidate := filepath.Join(root, pathHint) + if _, err := os.Stat(candidate); err == nil { + if gitRoot := repos.FindGitRoot(candidate); gitRoot != "" { + return gitRoot + } + } + } + + // Try against current engine's repo root + if eng := s.engine(); eng != nil { + candidate := filepath.Join(eng.GetRepoRoot(), pathHint) + if _, err := os.Stat(candidate); err == nil { + if gitRoot := repos.FindGitRoot(candidate); gitRoot != "" { + return gitRoot + } + } + } + + return "" +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 6a7052eb..dea4390c 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -76,6 +76,7 @@ func NewMCPServer(version string, engine *query.Engine, logger *slog.Logger) *MC logger: logger, version: version, legacyEngine: engine, + engines: make(map[string]*engineEntry), tools: make(map[string]ToolHandler), resources: make(map[string]ResourceHandler), activePreset: DefaultPreset, @@ -96,6 +97,23 @@ func NewMCPServer(version string, engine *query.Engine, logger *slog.Logger) *MC SetMetricsDB(engine.DB()) } + // Store initial engine in cache for auto-resolution + if engine != nil { + repoRoot := engine.GetRepoRoot() + normalized := normalizePath(repoRoot) + if normalized != "" { + server.engines[normalized] = &engineEntry{ + engine: engine, + repoPath: normalized, + repoName: filepath.Base(normalized), + loadedAt: time.Now(), + lastUsed: time.Now(), + } + server.activeRepoPath = normalized + server.activeRepo = filepath.Base(normalized) + } + } + return server } @@ -120,6 +138,7 @@ func NewMCPServerLazy(version string, loader EngineLoader, logger *slog.Logger) logger: logger, version: version, engineLoader: loader, + engines: make(map[string]*engineEntry), tools: make(map[string]ToolHandler), resources: make(map[string]ResourceHandler), activePreset: DefaultPreset, @@ -187,6 +206,26 @@ func (s *MCPServer) engine() *query.Engine { if engine != nil && engine.DB() != nil { SetMetricsDB(engine.DB()) } + // Store in engine cache for auto-resolution + if engine != nil { + repoRoot := engine.GetRepoRoot() + normalized := normalizePath(repoRoot) + if normalized != "" { + s.mu.Lock() + s.engines[normalized] = &engineEntry{ + engine: engine, + repoPath: normalized, + repoName: filepath.Base(normalized), + loadedAt: time.Now(), + lastUsed: time.Now(), + } + if s.activeRepoPath == "" { + s.activeRepoPath = normalized + s.activeRepo = filepath.Base(normalized) + } + s.mu.Unlock() + } + } s.logger.Info("Engine loaded successfully") }) return s.legacyEngine @@ -456,75 +495,36 @@ func (s *MCPServer) createEngineForRoot(repoRoot string) (*query.Engine, error) // switchToClientRoot switches the engine to the client's root directory if different. // This fixes repo confusion when using a binary from a different location. -// -// IMPORTANT: Only switches in legacy single-engine mode. In multi-repo mode, -// users have explicit control via switchRepo tool, so we don't override that. +// Uses the engine cache so old engines are retained for auto-resolution. func (s *MCPServer) switchToClientRoot(clientRoot string) { if clientRoot == "" { return } - // Only switch in legacy single-engine mode - // Multi-repo mode users have explicit control via switchRepo - if s.legacyEngine == nil { - s.logger.Debug("Multi-repo mode active, not auto-switching to client root", - "clientRoot", clientRoot, - ) - return - } - - currentRoot := s.legacyEngine.GetRepoRoot() - - // Normalize paths for comparison clientRootClean := filepath.Clean(clientRoot) - currentRootClean := filepath.Clean(currentRoot) - // Check if they're the same - if clientRootClean == currentRootClean { - s.logger.Debug("Client root matches current repo, no switch needed", - "root", clientRootClean, - ) - return + // Check if current engine already points here + if eng := s.engine(); eng != nil { + currentRootClean := filepath.Clean(eng.GetRepoRoot()) + if clientRootClean == currentRootClean { + s.logger.Debug("Client root matches current repo, no switch needed", + "root", clientRootClean, + ) + return + } } s.logger.Info("Client root differs from server repo, switching to client's project", "clientRoot", clientRootClean, - "serverRoot", currentRootClean, ) - // Create a new engine for the client's root - newEngine, err := s.createEngineForRoot(clientRootClean) - if err != nil { - s.logger.Warn("Failed to create engine for client root, keeping current repo", + // Use ensureActiveEngine which handles caching and swapping + if err := s.ensureActiveEngine(clientRootClean); err != nil { + s.logger.Warn("Failed to switch to client root, keeping current repo", "clientRoot", clientRootClean, "error", err.Error(), ) - return } - - // Close the old engine's database to avoid resource leaks - oldEngine := s.legacyEngine - if oldEngine != nil && oldEngine.DB() != nil { - if err := oldEngine.DB().Close(); err != nil { - s.logger.Warn("Failed to close old engine database", - "error", err.Error(), - ) - } - } - - // Switch to the new engine - s.mu.Lock() - s.legacyEngine = newEngine - s.mu.Unlock() - - // Wire up metrics persistence for the new engine - if newEngine.DB() != nil { - SetMetricsDB(newEngine.DB()) - } - - s.logger.Info("Switched to client root", - "root", clientRootClean, - ) } // enrichNotFoundError adds repo context to "not found" errors when the client diff --git a/internal/mcp/tool_impls_multirepo.go b/internal/mcp/tool_impls_multirepo.go index 3335ad44..d734d79f 100644 --- a/internal/mcp/tool_impls_multirepo.go +++ b/internal/mcp/tool_impls_multirepo.go @@ -2,34 +2,18 @@ package mcp import ( "fmt" + "path/filepath" "time" - "github.com/SimplyLiz/CodeMCP/internal/config" "github.com/SimplyLiz/CodeMCP/internal/envelope" "github.com/SimplyLiz/CodeMCP/internal/errors" - "github.com/SimplyLiz/CodeMCP/internal/query" "github.com/SimplyLiz/CodeMCP/internal/repos" - "github.com/SimplyLiz/CodeMCP/internal/storage" ) -// toolListRepos lists all registered repositories +// toolListRepos lists all registered repositories and loaded engines func (s *MCPServer) toolListRepos(params map[string]interface{}) (*envelope.Response, error) { s.logger.Debug("Executing listRepos") - if !s.IsMultiRepoMode() { - return nil, &MCPError{ - Code: InvalidRequest, - Message: "Multi-repo mode not enabled. Start MCP server with a registry.", - } - } - - registry, err := repos.LoadRegistry() - if err != nil { - return nil, errors.NewOperationError("load registry", err) - } - - activeRepo, _ := s.GetActiveRepo() - type repoInfo struct { Name string `json:"name"` Path string `json:"path"` @@ -39,28 +23,58 @@ func (s *MCPServer) toolListRepos(params map[string]interface{}) (*envelope.Resp IsLoaded bool `json:"is_loaded"` } + activeRepo, _ := s.GetActiveRepo() var repoList []repoInfo - for _, entry := range registry.List() { - state := registry.ValidateState(entry.Name) + var defaultName string - s.mu.RLock() - _, isLoaded := s.engines[entry.Path] - s.mu.RUnlock() + // Include repos from registry if available + registry, err := repos.LoadRegistry() + if err == nil && len(registry.List()) > 0 { + defaultName = registry.Default + for _, entry := range registry.List() { + state := registry.ValidateState(entry.Name) + + s.mu.RLock() + _, isLoaded := s.engines[entry.Path] + s.mu.RUnlock() + + repoList = append(repoList, repoInfo{ + Name: entry.Name, + Path: entry.Path, + State: string(state), + IsDefault: entry.Name == registry.Default, + IsActive: entry.Name == activeRepo, + IsLoaded: isLoaded, + }) + } + } - repoList = append(repoList, repoInfo{ - Name: entry.Name, - Path: entry.Path, - State: string(state), - IsDefault: entry.Name == registry.Default, - IsActive: entry.Name == activeRepo, - IsLoaded: isLoaded, - }) + // Also include any loaded engines not in the registry + s.mu.RLock() + for path, entry := range s.engines { + found := false + for _, r := range repoList { + if r.Path == path { + found = true + break + } + } + if !found { + repoList = append(repoList, repoInfo{ + Name: entry.repoName, + Path: entry.repoPath, + State: "valid", + IsActive: entry.repoPath == s.activeRepoPath, + IsLoaded: true, + }) + } } + s.mu.RUnlock() return OperationalResponse(map[string]interface{}{ "repos": repoList, "activeRepo": activeRepo, - "default": registry.Default, + "default": defaultName, }), nil } @@ -70,13 +84,6 @@ func (s *MCPServer) toolSwitchRepo(params map[string]interface{}) (*envelope.Res "params", params, ) - if !s.IsMultiRepoMode() { - return nil, &MCPError{ - Code: InvalidRequest, - Message: "Multi-repo mode not enabled. Start MCP server with a registry.", - } - } - name, ok := params["name"].(string) if !ok || name == "" { return nil, &MCPError{ @@ -85,104 +92,67 @@ func (s *MCPServer) toolSwitchRepo(params map[string]interface{}) (*envelope.Res } } + // Try registry first registry, err := repos.LoadRegistry() - if err != nil { - return nil, errors.NewOperationError("load registry", err) - } - - entry, state, err := registry.Get(name) - if err != nil { - return nil, &MCPError{ - Code: InvalidParams, - Message: fmt.Sprintf("Repository not found: %s", name), + if err == nil { + entry, state, getErr := registry.Get(name) + if getErr == nil { + switch state { + case repos.RepoStateMissing: + return nil, &MCPError{ + Code: InvalidParams, + Message: fmt.Sprintf("Path does not exist: %s", entry.Path), + Data: map[string]string{"hint": fmt.Sprintf("Run: ckb repo remove %s", name)}, + } + case repos.RepoStateUninitialized: + return nil, &MCPError{ + Code: InvalidParams, + Message: fmt.Sprintf("Repository not initialized: %s", entry.Path), + Data: map[string]string{"hint": fmt.Sprintf("Run: cd %s && ckb init", entry.Path)}, + } + } + + // Use ensureActiveEngine for the switch + if switchErr := s.ensureActiveEngine(entry.Path); switchErr != nil { + return nil, errors.NewOperationError("switch to "+name, switchErr) + } + + // Update the repo name (ensureActiveEngine uses filepath.Base) + s.mu.Lock() + s.activeRepo = name + s.mu.Unlock() + + _ = registry.TouchLastUsed(name) + + return OperationalResponse(map[string]interface{}{ + "success": true, + "activeRepo": name, + "path": entry.Path, + }), nil } } - switch state { - case repos.RepoStateMissing: - return nil, &MCPError{ - Code: InvalidParams, - Message: fmt.Sprintf("Path does not exist: %s", entry.Path), - Data: map[string]string{"hint": fmt.Sprintf("Run: ckb repo remove %s", name)}, - } - case repos.RepoStateUninitialized: - return nil, &MCPError{ - Code: InvalidParams, - Message: fmt.Sprintf("Repository not initialized: %s", entry.Path), - Data: map[string]string{"hint": fmt.Sprintf("Run: cd %s && ckb init", entry.Path)}, - } + // Not in registry — treat name as a path + return nil, &MCPError{ + Code: InvalidParams, + Message: fmt.Sprintf("Repository not found: %s", name), } - - // Load or switch engine - s.mu.Lock() - defer s.mu.Unlock() - - // Check if already loaded - if existingEntry, ok := s.engines[entry.Path]; ok { - existingEntry.lastUsed = time.Now() - s.activeRepo = name - s.activeRepoPath = entry.Path - s.logger.Info("Switched to existing engine", - "repo", name, - "path", entry.Path, - ) - return OperationalResponse(map[string]interface{}{ - "success": true, - "activeRepo": name, - "path": entry.Path, - }), nil - } - - // Need to create new engine - check if we're at max - if len(s.engines) >= maxEngines { - s.evictLRULocked() - } - - // Create new engine - engine, err := s.createEngineForRepo(entry.Path) - if err != nil { - return nil, errors.NewOperationError("create engine for "+name, err) - } - - s.engines[entry.Path] = &engineEntry{ - engine: engine, - repoPath: entry.Path, - repoName: name, - loadedAt: time.Now(), - lastUsed: time.Now(), - } - s.activeRepo = name - s.activeRepoPath = entry.Path - - // Update last used in registry - _ = registry.TouchLastUsed(name) - - s.logger.Info("Created new engine and switched", - "repo", name, - "path", entry.Path, - "totalLoaded", len(s.engines), - ) - - return OperationalResponse(map[string]interface{}{ - "success": true, - "activeRepo": name, - "path": entry.Path, - }), nil } // toolGetActiveRepo returns information about the currently active repository func (s *MCPServer) toolGetActiveRepo(params map[string]interface{}) (*envelope.Response, error) { s.logger.Debug("Executing getActiveRepo") - if !s.IsMultiRepoMode() { - return nil, &MCPError{ - Code: InvalidRequest, - Message: "Multi-repo mode not enabled. Start MCP server with a registry.", + name, path := s.GetActiveRepo() + + // Fall back to current engine info if no explicit active repo + if name == "" && path == "" { + if eng := s.engine(); eng != nil { + path = eng.GetRepoRoot() + name = filepath.Base(path) } } - name, path := s.GetActiveRepo() - if name == "" { return OperationalResponse(map[string]interface{}{ "name": nil, @@ -191,17 +161,18 @@ func (s *MCPServer) toolGetActiveRepo(params map[string]interface{}) (*envelope. }), nil } - registry, err := repos.LoadRegistry() - if err != nil { - return nil, errors.NewOperationError("load registry", err) + // Try to get state from registry + state := "valid" + if registry, err := repos.LoadRegistry(); err == nil { + if rs := registry.ValidateState(name); rs != "" { + state = string(rs) + } } - state := registry.ValidateState(name) - return OperationalResponse(map[string]interface{}{ "name": name, "path": path, - "state": string(state), + "state": state, }), nil } @@ -238,31 +209,6 @@ func (s *MCPServer) evictLRULocked() { } } -// createEngineForRepo creates a new query engine for a repository -func (s *MCPServer) createEngineForRepo(repoPath string) (*query.Engine, error) { - // Load config from repo - cfg, err := config.LoadConfig(repoPath) - if err != nil { - // Use default config - cfg = config.DefaultConfig() - } - - // Open storage for this repo - db, err := storage.Open(repoPath, s.logger) - if err != nil { - return nil, errors.NewOperationError("open database", err) - } - - // Create engine - engine, err := query.NewEngine(repoPath, db, s.logger, cfg) - if err != nil { - _ = db.Close() - return nil, errors.NewOperationError("create engine", err) - } - - return engine, nil -} - // CloseAllEngines closes all loaded engines (for graceful shutdown) func (s *MCPServer) CloseAllEngines() { s.mu.Lock()