Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions cmd/ckb/mcp.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package main

import (
Expand Down Expand Up @@ -118,10 +118,6 @@
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
Expand Down Expand Up @@ -160,9 +156,6 @@
}
}

// 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()
Expand Down Expand Up @@ -370,6 +363,11 @@
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(),
Expand Down
119 changes: 119 additions & 0 deletions internal/mcp/engine_cache.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 7 additions & 0 deletions internal/mcp/handler.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package mcp

import (
Expand Down Expand Up @@ -338,6 +338,13 @@
"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 {
Expand Down
68 changes: 68 additions & 0 deletions internal/mcp/repo_resolver.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
104 changes: 52 additions & 52 deletions internal/mcp/server.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package mcp

import (
Expand Down Expand Up @@ -76,6 +76,7 @@
logger: logger,
version: version,
legacyEngine: engine,
engines: make(map[string]*engineEntry),
tools: make(map[string]ToolHandler),
resources: make(map[string]ResourceHandler),
activePreset: DefaultPreset,
Expand All @@ -96,6 +97,23 @@
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
}

Expand All @@ -120,6 +138,7 @@
logger: logger,
version: version,
engineLoader: loader,
engines: make(map[string]*engineEntry),
tools: make(map[string]ToolHandler),
resources: make(map[string]ResourceHandler),
activePreset: DefaultPreset,
Expand Down Expand Up @@ -187,6 +206,26 @@
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
Expand Down Expand Up @@ -456,75 +495,36 @@

// 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
Expand Down
Loading
Loading