From 560b07d4dd02072bedb43259a0105645f1a7c194 Mon Sep 17 00:00:00 2001 From: Yi LIU Date: Mon, 16 Feb 2026 13:27:13 +0800 Subject: [PATCH 1/3] Fix concurrent map panic in MCP server permission and repository caches The permissionCache map and repoCache pointer are accessed concurrently from HTTP handler goroutines without synchronization. In Go, concurrent read+write on a map causes a fatal runtime panic ("concurrent map read and map write"). Add a sync.RWMutex to protect both caches. Read operations use RLock for concurrency, write operations use Lock for exclusive access. --- pkg/cli/mcp_server.go | 23 +++++- pkg/cli/mcp_server_cache_test.go | 124 +++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 pkg/cli/mcp_server_cache_test.go diff --git a/pkg/cli/mcp_server.go b/pkg/cli/mcp_server.go index 19371793ec..885a635ecd 100644 --- a/pkg/cli/mcp_server.go +++ b/pkg/cli/mcp_server.go @@ -10,6 +10,7 @@ import ( "os/exec" "strconv" "strings" + "sync" "time" "github.com/github/gh-aw/pkg/console" @@ -51,6 +52,7 @@ type repositoryCache struct { } var ( + cacheMu sync.RWMutex permissionCache = make(map[string]*actorPermissionCache) permissionCacheTTL = 1 * time.Hour repoCache *repositoryCache @@ -62,20 +64,26 @@ var ( // Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. func getRepository() (string, error) { // Check cache first + cacheMu.RLock() if repoCache != nil && time.Since(repoCache.timestamp) < repoCacheTTL { - mcpLog.Printf("Using cached repository: %s (age: %v)", repoCache.repository, time.Since(repoCache.timestamp)) - return repoCache.repository, nil + repo := repoCache.repository + cacheMu.RUnlock() + mcpLog.Printf("Using cached repository: %s (age: %v)", repo, time.Since(repoCache.timestamp)) + return repo, nil } + cacheMu.RUnlock() // Try GITHUB_REPOSITORY environment variable first repo := os.Getenv("GITHUB_REPOSITORY") if repo != "" { mcpLog.Printf("Got repository from GITHUB_REPOSITORY: %s", repo) // Cache the result + cacheMu.Lock() repoCache = &repositoryCache{ repository: repo, timestamp: time.Now(), } + cacheMu.Unlock() return repo, nil } @@ -95,10 +103,12 @@ func getRepository() (string, error) { mcpLog.Printf("Got repository from gh repo view: %s", repo) // Cache the result + cacheMu.Lock() repoCache = &repositoryCache{ repository: repo, timestamp: time.Now(), } + cacheMu.Unlock() return repo, nil } @@ -115,14 +125,21 @@ func queryActorRole(ctx context.Context, actor string, repo string) (string, err // Check cache first cacheKey := fmt.Sprintf("%s:%s", actor, repo) + cacheMu.RLock() if cached, ok := permissionCache[cacheKey]; ok { if time.Since(cached.timestamp) < permissionCacheTTL { + cacheMu.RUnlock() mcpLog.Printf("Using cached permission for %s in %s: %s (age: %v)", actor, repo, cached.permission, time.Since(cached.timestamp)) return cached.permission, nil } + cacheMu.RUnlock() // Cache expired, remove it + cacheMu.Lock() delete(permissionCache, cacheKey) + cacheMu.Unlock() mcpLog.Printf("Permission cache expired for %s in %s", actor, repo) + } else { + cacheMu.RUnlock() } // Query GitHub API for user's permission level @@ -143,10 +160,12 @@ func queryActorRole(ctx context.Context, actor string, repo string) (string, err } // Cache the result + cacheMu.Lock() permissionCache[cacheKey] = &actorPermissionCache{ permission: permission, timestamp: time.Now(), } + cacheMu.Unlock() mcpLog.Printf("Cached permission for %s in %s: %s", actor, repo, permission) return permission, nil diff --git a/pkg/cli/mcp_server_cache_test.go b/pkg/cli/mcp_server_cache_test.go new file mode 100644 index 0000000000..727a67e031 --- /dev/null +++ b/pkg/cli/mcp_server_cache_test.go @@ -0,0 +1,124 @@ +//go:build !integration + +package cli + +import ( + "fmt" + "sync" + "testing" + "time" +) + +func TestQueryActorRole_ConcurrentCacheAccess(t *testing.T) { + // Save and restore global state + origCache := permissionCache + origTTL := permissionCacheTTL + t.Cleanup(func() { + cacheMu.Lock() + permissionCache = origCache + permissionCacheTTL = origTTL + cacheMu.Unlock() + }) + + // Use a fresh cache with a short TTL to exercise both read and write paths + cacheMu.Lock() + permissionCache = make(map[string]*actorPermissionCache) + permissionCacheTTL = 50 * time.Millisecond + cacheMu.Unlock() + + // Pre-populate cache entries + cacheMu.Lock() + for i := 0; i < 5; i++ { + key := fmt.Sprintf("actor%d:owner/repo", i) + permissionCache[key] = &actorPermissionCache{ + permission: "write", + timestamp: time.Now(), + } + } + cacheMu.Unlock() + + const numGoroutines = 20 + const numIterations = 100 + + var wg sync.WaitGroup + + // Concurrent goroutines reading and writing the cache + for g := 0; g < numGoroutines; g++ { + wg.Add(1) + go func(goroutineID int) { + defer wg.Done() + for i := 0; i < numIterations; i++ { + cacheKey := fmt.Sprintf("actor%d:owner/repo", i%10) + + cacheMu.RLock() + if cached, ok := permissionCache[cacheKey]; ok { + if time.Since(cached.timestamp) >= permissionCacheTTL { + cacheMu.RUnlock() + cacheMu.Lock() + delete(permissionCache, cacheKey) + cacheMu.Unlock() + } else { + cacheMu.RUnlock() + } + } else { + cacheMu.RUnlock() + } + + cacheMu.Lock() + permissionCache[cacheKey] = &actorPermissionCache{ + permission: "write", + timestamp: time.Now(), + } + cacheMu.Unlock() + } + }(g) + } + + wg.Wait() +} + +func TestGetRepository_ConcurrentCacheAccess(t *testing.T) { + // Save and restore global state + origRepoCache := repoCache + origRepoCacheTTL := repoCacheTTL + t.Cleanup(func() { + cacheMu.Lock() + repoCache = origRepoCache + repoCacheTTL = origRepoCacheTTL + cacheMu.Unlock() + }) + + cacheMu.Lock() + repoCache = nil + repoCacheTTL = 50 * time.Millisecond + cacheMu.Unlock() + + const numGoroutines = 20 + const numIterations = 100 + + var wg sync.WaitGroup + + for g := 0; g < numGoroutines; g++ { + wg.Add(1) + go func(goroutineID int) { + defer wg.Done() + for i := 0; i < numIterations; i++ { + cacheMu.RLock() + cached := repoCache + if cached != nil { + _ = cached.repository + } + cacheMu.RUnlock() + + cacheMu.Lock() + repoCache = &repositoryCache{ + repository: fmt.Sprintf("owner/repo-%d", goroutineID), + timestamp: time.Now(), + } + cacheMu.Unlock() + } + }(g) + } + + wg.Wait() +} From 5bac40631db03f8afc67a5109b82c64049128309 Mon Sep 17 00:00:00 2001 From: Yi LIU Date: Mon, 16 Feb 2026 15:22:54 +0800 Subject: [PATCH 2/3] Refactor caches into encapsulated mcpCacheStore type Replace bare global mutex + maps with an mcpCacheStore struct that encapsulates all locking internally. Callers now use GetPermission, SetPermission, GetRepo, and SetRepo methods instead of manual lock/unlock sequences, making it impossible to access caches without proper synchronization. --- pkg/cli/mcp_server.go | 134 +++++++++++++++++------------ pkg/cli/mcp_server_cache_test.go | 140 ++++++++++++++----------------- 2 files changed, 141 insertions(+), 133 deletions(-) diff --git a/pkg/cli/mcp_server.go b/pkg/cli/mcp_server.go index 885a635ecd..b3d64bba2c 100644 --- a/pkg/cli/mcp_server.go +++ b/pkg/cli/mcp_server.go @@ -39,51 +39,102 @@ func mcpErrorData(v any) json.RawMessage { return data } -// actorPermissionCache stores cached actor permission lookups with TTL -type actorPermissionCache struct { +// mcpCacheStore provides thread-safe caching for actor permissions and repository lookups. +// All exported methods are safe for concurrent use. +type mcpCacheStore struct { + mu sync.RWMutex + permissions map[string]*permissionEntry + permissionTTL time.Duration + repo *repoEntry + repoTTL time.Duration +} + +type permissionEntry struct { permission string timestamp time.Time } -// repositoryCache stores cached repository information with TTL -type repositoryCache struct { +type repoEntry struct { repository string timestamp time.Time } -var ( - cacheMu sync.RWMutex - permissionCache = make(map[string]*actorPermissionCache) - permissionCacheTTL = 1 * time.Hour - repoCache *repositoryCache - repoCacheTTL = 1 * time.Hour -) +func newMCPCacheStore() *mcpCacheStore { + return &mcpCacheStore{ + permissions: make(map[string]*permissionEntry), + permissionTTL: 1 * time.Hour, + repoTTL: 1 * time.Hour, + } +} + +// GetPermission returns the cached permission for the given actor and repo, or ("", false) on cache miss. +func (c *mcpCacheStore) GetPermission(actor, repo string) (string, bool) { + cacheKey := actor + ":" + repo + c.mu.RLock() + entry, ok := c.permissions[cacheKey] + if ok && time.Since(entry.timestamp) < c.permissionTTL { + perm := entry.permission + c.mu.RUnlock() + return perm, true + } + c.mu.RUnlock() + if ok { + // Expired — remove it + c.mu.Lock() + delete(c.permissions, cacheKey) + c.mu.Unlock() + } + return "", false +} + +// SetPermission stores a permission in the cache. +func (c *mcpCacheStore) SetPermission(actor, repo, permission string) { + cacheKey := actor + ":" + repo + c.mu.Lock() + c.permissions[cacheKey] = &permissionEntry{ + permission: permission, + timestamp: time.Now(), + } + c.mu.Unlock() +} + +// GetRepo returns the cached repository name, or ("", false) on cache miss. +func (c *mcpCacheStore) GetRepo() (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.repo != nil && time.Since(c.repo.timestamp) < c.repoTTL { + return c.repo.repository, true + } + return "", false +} + +// SetRepo stores a repository name in the cache. +func (c *mcpCacheStore) SetRepo(repository string) { + c.mu.Lock() + c.repo = &repoEntry{ + repository: repository, + timestamp: time.Now(), + } + c.mu.Unlock() +} + +var mcpCache = newMCPCacheStore() // getRepository retrieves the current repository name (owner/repo format). // Results are cached for 1 hour to avoid repeated queries. // Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. func getRepository() (string, error) { // Check cache first - cacheMu.RLock() - if repoCache != nil && time.Since(repoCache.timestamp) < repoCacheTTL { - repo := repoCache.repository - cacheMu.RUnlock() - mcpLog.Printf("Using cached repository: %s (age: %v)", repo, time.Since(repoCache.timestamp)) + if repo, ok := mcpCache.GetRepo(); ok { + mcpLog.Printf("Using cached repository: %s", repo) return repo, nil } - cacheMu.RUnlock() // Try GITHUB_REPOSITORY environment variable first repo := os.Getenv("GITHUB_REPOSITORY") if repo != "" { mcpLog.Printf("Got repository from GITHUB_REPOSITORY: %s", repo) - // Cache the result - cacheMu.Lock() - repoCache = &repositoryCache{ - repository: repo, - timestamp: time.Now(), - } - cacheMu.Unlock() + mcpCache.SetRepo(repo) return repo, nil } @@ -102,13 +153,7 @@ func getRepository() (string, error) { } mcpLog.Printf("Got repository from gh repo view: %s", repo) - // Cache the result - cacheMu.Lock() - repoCache = &repositoryCache{ - repository: repo, - timestamp: time.Now(), - } - cacheMu.Unlock() + mcpCache.SetRepo(repo) return repo, nil } @@ -124,22 +169,9 @@ func queryActorRole(ctx context.Context, actor string, repo string) (string, err } // Check cache first - cacheKey := fmt.Sprintf("%s:%s", actor, repo) - cacheMu.RLock() - if cached, ok := permissionCache[cacheKey]; ok { - if time.Since(cached.timestamp) < permissionCacheTTL { - cacheMu.RUnlock() - mcpLog.Printf("Using cached permission for %s in %s: %s (age: %v)", actor, repo, cached.permission, time.Since(cached.timestamp)) - return cached.permission, nil - } - cacheMu.RUnlock() - // Cache expired, remove it - cacheMu.Lock() - delete(permissionCache, cacheKey) - cacheMu.Unlock() - mcpLog.Printf("Permission cache expired for %s in %s", actor, repo) - } else { - cacheMu.RUnlock() + if perm, ok := mcpCache.GetPermission(actor, repo); ok { + mcpLog.Printf("Using cached permission for %s in %s: %s", actor, repo, perm) + return perm, nil } // Query GitHub API for user's permission level @@ -159,13 +191,7 @@ func queryActorRole(ctx context.Context, actor string, repo string) (string, err return "", fmt.Errorf("no permission found for actor %s in repository %s", actor, repo) } - // Cache the result - cacheMu.Lock() - permissionCache[cacheKey] = &actorPermissionCache{ - permission: permission, - timestamp: time.Now(), - } - cacheMu.Unlock() + mcpCache.SetPermission(actor, repo, permission) mcpLog.Printf("Cached permission for %s in %s: %s", actor, repo, permission) return permission, nil diff --git a/pkg/cli/mcp_server_cache_test.go b/pkg/cli/mcp_server_cache_test.go index 727a67e031..b944b8fb47 100644 --- a/pkg/cli/mcp_server_cache_test.go +++ b/pkg/cli/mcp_server_cache_test.go @@ -9,89 +9,38 @@ import ( "time" ) -func TestQueryActorRole_ConcurrentCacheAccess(t *testing.T) { - // Save and restore global state - origCache := permissionCache - origTTL := permissionCacheTTL - t.Cleanup(func() { - cacheMu.Lock() - permissionCache = origCache - permissionCacheTTL = origTTL - cacheMu.Unlock() - }) - - // Use a fresh cache with a short TTL to exercise both read and write paths - cacheMu.Lock() - permissionCache = make(map[string]*actorPermissionCache) - permissionCacheTTL = 50 * time.Millisecond - cacheMu.Unlock() - - // Pre-populate cache entries - cacheMu.Lock() +func TestMCPCacheStore_ConcurrentPermissionAccess(t *testing.T) { + cache := newMCPCacheStore() + cache.permissionTTL = 50 * time.Millisecond + + // Pre-populate for i := 0; i < 5; i++ { - key := fmt.Sprintf("actor%d:owner/repo", i) - permissionCache[key] = &actorPermissionCache{ - permission: "write", - timestamp: time.Now(), - } + cache.SetPermission(fmt.Sprintf("actor%d", i), "owner/repo", "write") } - cacheMu.Unlock() const numGoroutines = 20 const numIterations = 100 var wg sync.WaitGroup - // Concurrent goroutines reading and writing the cache for g := 0; g < numGoroutines; g++ { wg.Add(1) - go func(goroutineID int) { + go func() { defer wg.Done() for i := 0; i < numIterations; i++ { - cacheKey := fmt.Sprintf("actor%d:owner/repo", i%10) - - cacheMu.RLock() - if cached, ok := permissionCache[cacheKey]; ok { - if time.Since(cached.timestamp) >= permissionCacheTTL { - cacheMu.RUnlock() - cacheMu.Lock() - delete(permissionCache, cacheKey) - cacheMu.Unlock() - } else { - cacheMu.RUnlock() - } - } else { - cacheMu.RUnlock() - } - - cacheMu.Lock() - permissionCache[cacheKey] = &actorPermissionCache{ - permission: "write", - timestamp: time.Now(), - } - cacheMu.Unlock() + actor := fmt.Sprintf("actor%d", i%10) + cache.GetPermission(actor, "owner/repo") + cache.SetPermission(actor, "owner/repo", "write") } - }(g) + }() } wg.Wait() } -func TestGetRepository_ConcurrentCacheAccess(t *testing.T) { - // Save and restore global state - origRepoCache := repoCache - origRepoCacheTTL := repoCacheTTL - t.Cleanup(func() { - cacheMu.Lock() - repoCache = origRepoCache - repoCacheTTL = origRepoCacheTTL - cacheMu.Unlock() - }) - - cacheMu.Lock() - repoCache = nil - repoCacheTTL = 50 * time.Millisecond - cacheMu.Unlock() +func TestMCPCacheStore_ConcurrentRepoAccess(t *testing.T) { + cache := newMCPCacheStore() + cache.repoTTL = 50 * time.Millisecond const numGoroutines = 20 const numIterations = 100 @@ -100,25 +49,58 @@ func TestGetRepository_ConcurrentCacheAccess(t *testing.T) { for g := 0; g < numGoroutines; g++ { wg.Add(1) - go func(goroutineID int) { + go func(id int) { defer wg.Done() for i := 0; i < numIterations; i++ { - cacheMu.RLock() - cached := repoCache - if cached != nil { - _ = cached.repository - } - cacheMu.RUnlock() - - cacheMu.Lock() - repoCache = &repositoryCache{ - repository: fmt.Sprintf("owner/repo-%d", goroutineID), - timestamp: time.Now(), - } - cacheMu.Unlock() + cache.GetRepo() + cache.SetRepo(fmt.Sprintf("owner/repo-%d", id)) } }(g) } wg.Wait() } + +func TestMCPCacheStore_PermissionExpiry(t *testing.T) { + cache := newMCPCacheStore() + cache.permissionTTL = 10 * time.Millisecond + + cache.SetPermission("actor", "owner/repo", "admin") + + // Should hit cache + perm, ok := cache.GetPermission("actor", "owner/repo") + if !ok || perm != "admin" { + t.Errorf("GetPermission() = (%q, %v), want (\"admin\", true)", perm, ok) + } + + // Wait for expiry + time.Sleep(20 * time.Millisecond) + + // Should miss cache + _, ok = cache.GetPermission("actor", "owner/repo") + if ok { + t.Error("GetPermission() should return false after TTL expiry") + } +} + +func TestMCPCacheStore_RepoExpiry(t *testing.T) { + cache := newMCPCacheStore() + cache.repoTTL = 10 * time.Millisecond + + cache.SetRepo("owner/repo") + + // Should hit cache + repo, ok := cache.GetRepo() + if !ok || repo != "owner/repo" { + t.Errorf("GetRepo() = (%q, %v), want (\"owner/repo\", true)", repo, ok) + } + + // Wait for expiry + time.Sleep(20 * time.Millisecond) + + // Should miss cache + _, ok = cache.GetRepo() + if ok { + t.Error("GetRepo() should return false after TTL expiry") + } +} From 912c6d75a0dc8b3e57777615c5659fa9d7e75968 Mon Sep 17 00:00:00 2001 From: Yi LIU Date: Mon, 16 Feb 2026 22:55:02 +0800 Subject: [PATCH 3/3] Move cache types to dedicated mcp_server_cache.go file --- pkg/cli/mcp_server.go | 82 ---------------------------------- pkg/cli/mcp_server_cache.go | 87 +++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 82 deletions(-) create mode 100644 pkg/cli/mcp_server_cache.go diff --git a/pkg/cli/mcp_server.go b/pkg/cli/mcp_server.go index b3d64bba2c..ead520a888 100644 --- a/pkg/cli/mcp_server.go +++ b/pkg/cli/mcp_server.go @@ -10,7 +10,6 @@ import ( "os/exec" "strconv" "strings" - "sync" "time" "github.com/github/gh-aw/pkg/console" @@ -39,87 +38,6 @@ func mcpErrorData(v any) json.RawMessage { return data } -// mcpCacheStore provides thread-safe caching for actor permissions and repository lookups. -// All exported methods are safe for concurrent use. -type mcpCacheStore struct { - mu sync.RWMutex - permissions map[string]*permissionEntry - permissionTTL time.Duration - repo *repoEntry - repoTTL time.Duration -} - -type permissionEntry struct { - permission string - timestamp time.Time -} - -type repoEntry struct { - repository string - timestamp time.Time -} - -func newMCPCacheStore() *mcpCacheStore { - return &mcpCacheStore{ - permissions: make(map[string]*permissionEntry), - permissionTTL: 1 * time.Hour, - repoTTL: 1 * time.Hour, - } -} - -// GetPermission returns the cached permission for the given actor and repo, or ("", false) on cache miss. -func (c *mcpCacheStore) GetPermission(actor, repo string) (string, bool) { - cacheKey := actor + ":" + repo - c.mu.RLock() - entry, ok := c.permissions[cacheKey] - if ok && time.Since(entry.timestamp) < c.permissionTTL { - perm := entry.permission - c.mu.RUnlock() - return perm, true - } - c.mu.RUnlock() - if ok { - // Expired — remove it - c.mu.Lock() - delete(c.permissions, cacheKey) - c.mu.Unlock() - } - return "", false -} - -// SetPermission stores a permission in the cache. -func (c *mcpCacheStore) SetPermission(actor, repo, permission string) { - cacheKey := actor + ":" + repo - c.mu.Lock() - c.permissions[cacheKey] = &permissionEntry{ - permission: permission, - timestamp: time.Now(), - } - c.mu.Unlock() -} - -// GetRepo returns the cached repository name, or ("", false) on cache miss. -func (c *mcpCacheStore) GetRepo() (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - if c.repo != nil && time.Since(c.repo.timestamp) < c.repoTTL { - return c.repo.repository, true - } - return "", false -} - -// SetRepo stores a repository name in the cache. -func (c *mcpCacheStore) SetRepo(repository string) { - c.mu.Lock() - c.repo = &repoEntry{ - repository: repository, - timestamp: time.Now(), - } - c.mu.Unlock() -} - -var mcpCache = newMCPCacheStore() - // getRepository retrieves the current repository name (owner/repo format). // Results are cached for 1 hour to avoid repeated queries. // Checks GITHUB_REPOSITORY environment variable first, then falls back to gh repo view. diff --git a/pkg/cli/mcp_server_cache.go b/pkg/cli/mcp_server_cache.go new file mode 100644 index 0000000000..561a6bcfb2 --- /dev/null +++ b/pkg/cli/mcp_server_cache.go @@ -0,0 +1,87 @@ +package cli + +import ( + "sync" + "time" +) + +// mcpCacheStore provides thread-safe caching for actor permissions and repository lookups. +// All exported methods are safe for concurrent use. +type mcpCacheStore struct { + mu sync.RWMutex + permissions map[string]*permissionEntry + permissionTTL time.Duration + repo *repoEntry + repoTTL time.Duration +} + +type permissionEntry struct { + permission string + timestamp time.Time +} + +type repoEntry struct { + repository string + timestamp time.Time +} + +func newMCPCacheStore() *mcpCacheStore { + return &mcpCacheStore{ + permissions: make(map[string]*permissionEntry), + permissionTTL: 1 * time.Hour, + repoTTL: 1 * time.Hour, + } +} + +// GetPermission returns the cached permission for the given actor and repo, or ("", false) on cache miss. +func (c *mcpCacheStore) GetPermission(actor, repo string) (string, bool) { + cacheKey := actor + ":" + repo + c.mu.RLock() + entry, ok := c.permissions[cacheKey] + if ok && time.Since(entry.timestamp) < c.permissionTTL { + perm := entry.permission + c.mu.RUnlock() + return perm, true + } + c.mu.RUnlock() + if ok { + // Expired — remove it + c.mu.Lock() + delete(c.permissions, cacheKey) + c.mu.Unlock() + } + return "", false +} + +// SetPermission stores a permission in the cache. +func (c *mcpCacheStore) SetPermission(actor, repo, permission string) { + cacheKey := actor + ":" + repo + c.mu.Lock() + c.permissions[cacheKey] = &permissionEntry{ + permission: permission, + timestamp: time.Now(), + } + c.mu.Unlock() +} + +// GetRepo returns the cached repository name, or ("", false) on cache miss. +func (c *mcpCacheStore) GetRepo() (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if c.repo != nil && time.Since(c.repo.timestamp) < c.repoTTL { + return c.repo.repository, true + } + return "", false +} + +// SetRepo stores a repository name in the cache. +func (c *mcpCacheStore) SetRepo(repository string) { + c.mu.Lock() + c.repo = &repoEntry{ + repository: repository, + timestamp: time.Now(), + } + c.mu.Unlock() +} + +var mcpCache = newMCPCacheStore()