diff --git a/gitnexus/src/cli/eval-server.ts b/gitnexus/src/cli/eval-server.ts index 171fb0741..49643dc5c 100644 --- a/gitnexus/src/cli/eval-server.ts +++ b/gitnexus/src/cli/eval-server.ts @@ -308,7 +308,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise r.name).join(', ')}`); let idleTimer: ReturnType | null = null; diff --git a/gitnexus/src/cli/mcp.ts b/gitnexus/src/cli/mcp.ts index be373826b..90c55b9bd 100644 --- a/gitnexus/src/cli/mcp.ts +++ b/gitnexus/src/cli/mcp.ts @@ -45,7 +45,7 @@ export const mcpCommand = async () => { process.exit(1); } - const repoNames = backend.listRepos().map(r => r.name); + const repoNames = (await backend.listRepos()).map(r => r.name); console.error(`GitNexus: MCP server starting with ${repoNames.length} repo(s): ${repoNames.join(', ')}`); // Start MCP server (serves all repos) diff --git a/gitnexus/src/mcp/local/local-backend.ts b/gitnexus/src/mcp/local/local-backend.ts index 7048fd176..b0beff286 100644 --- a/gitnexus/src/mcp/local/local-backend.ts +++ b/gitnexus/src/mcp/local/local-backend.ts @@ -76,10 +76,23 @@ export class LocalBackend { * Returns true if at least one repo is available. */ async init(): Promise { + await this.refreshRepos(); + return this.repos.size > 0; + } + + /** + * Re-read the global registry and update the in-memory repo map. + * New repos are added, existing repos are updated, removed repos are pruned. + * KuzuDB connections for removed repos are NOT closed (they idle-timeout naturally). + */ + private async refreshRepos(): Promise { const entries = await listRegisteredRepos({ validate: true }); + const freshIds = new Set(); for (const entry of entries) { const id = this.repoId(entry.name, entry.path); + freshIds.add(id); + const storagePath = entry.storagePath; const kuzuPath = path.join(storagePath, 'kuzu'); @@ -109,7 +122,14 @@ export class LocalBackend { }); } - return this.repos.size > 0; + // Prune repos that no longer exist in the registry + for (const id of this.repos.keys()) { + if (!freshIds.has(id)) { + this.repos.delete(id); + this.contextCache.delete(id); + this.initializedRepos.delete(id); + } + } } /** @@ -136,11 +156,38 @@ export class LocalBackend { * - If repoParam is given, match by name or path * - If only 1 repo, use it * - If 0 or multiple without param, throw with helpful message + * + * On a miss, re-reads the registry once in case a new repo was indexed + * while the MCP server was running. */ - resolveRepo(repoParam?: string): RepoHandle { + async resolveRepo(repoParam?: string): Promise { + const result = this.resolveRepoFromCache(repoParam); + if (result) return result; + + // Miss — refresh registry and try once more + await this.refreshRepos(); + const retried = this.resolveRepoFromCache(repoParam); + if (retried) return retried; + + // Still no match — throw with helpful message if (this.repos.size === 0) { throw new Error('No indexed repositories. Run: gitnexus analyze'); } + if (repoParam) { + const names = [...this.repos.values()].map(h => h.name); + throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); + } + const names = [...this.repos.values()].map(h => h.name); + throw new Error( + `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}` + ); + } + + /** + * Try to resolve a repo from the in-memory cache. Returns null on miss. + */ + private resolveRepoFromCache(repoParam?: string): RepoHandle | null { + if (this.repos.size === 0) return null; if (repoParam) { const paramLower = repoParam.toLowerCase(); @@ -159,19 +206,14 @@ export class LocalBackend { for (const handle of this.repos.values()) { if (handle.name.toLowerCase().includes(paramLower)) return handle; } - - const names = [...this.repos.values()].map(h => h.name); - throw new Error(`Repository "${repoParam}" not found. Available: ${names.join(', ')}`); + return null; } if (this.repos.size === 1) { return this.repos.values().next().value!; } - const names = [...this.repos.values()].map(h => h.name); - throw new Error( - `Multiple repositories indexed. Specify which one with the "repo" parameter. Available: ${names.join(', ')}` - ); + return null; // Multiple repos, no param — ambiguous } // ─── Lazy KuzuDB Init ──────────────────────────────────────────── @@ -210,8 +252,11 @@ export class LocalBackend { /** * List all registered repos with their metadata. + * Re-reads the global registry so newly indexed repos are discovered + * without restarting the MCP server. */ - listRepos(): Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }> { + async listRepos(): Promise> { + await this.refreshRepos(); return [...this.repos.values()].map(h => ({ name: h.name, path: h.repoPath, @@ -228,8 +273,8 @@ export class LocalBackend { return this.listRepos(); } - // Resolve repo from optional param - const repo = this.resolveRepo(params?.repo); + // Resolve repo from optional param (re-reads registry on miss) + const repo = await this.resolveRepo(params?.repo); switch (method) { case 'query': @@ -1289,7 +1334,7 @@ export class LocalBackend { * Used by getClustersResource — avoids legacy overview() dispatch. */ async queryClusters(repoName?: string, limit = 100): Promise<{ clusters: any[] }> { - const repo = this.resolveRepo(repoName); + const repo = await this.resolveRepo(repoName); await this.ensureInitialized(repo.id); try { @@ -1318,7 +1363,7 @@ export class LocalBackend { * Used by getProcessesResource — avoids legacy overview() dispatch. */ async queryProcesses(repoName?: string, limit = 50): Promise<{ processes: any[] }> { - const repo = this.resolveRepo(repoName); + const repo = await this.resolveRepo(repoName); await this.ensureInitialized(repo.id); try { @@ -1347,7 +1392,7 @@ export class LocalBackend { * Used by getClusterDetailResource. */ async queryClusterDetail(name: string, repoName?: string): Promise { - const repo = this.resolveRepo(repoName); + const repo = await this.resolveRepo(repoName); await this.ensureInitialized(repo.id); const escaped = name.replace(/'/g, "''"); @@ -1398,7 +1443,7 @@ export class LocalBackend { * Used by getProcessDetailResource. */ async queryProcessDetail(name: string, repoName?: string): Promise { - const repo = this.resolveRepo(repoName); + const repo = await this.resolveRepo(repoName); await this.ensureInitialized(repo.id); const escaped = name.replace(/'/g, "''"); diff --git a/gitnexus/src/mcp/resources.ts b/gitnexus/src/mcp/resources.ts index ffba25db2..95c7884dc 100644 --- a/gitnexus/src/mcp/resources.ts +++ b/gitnexus/src/mcp/resources.ts @@ -153,8 +153,8 @@ export async function readResource(uri: string, backend: LocalBackend): Promise< /** * Repos resource — list all indexed repositories */ -function getReposResource(backend: LocalBackend): string { - const repos = backend.listRepos(); +async function getReposResource(backend: LocalBackend): Promise { + const repos = await backend.listRepos(); if (repos.length === 0) { return 'repos: []\n# No repositories indexed. Run: gitnexus analyze'; @@ -187,7 +187,7 @@ function getReposResource(backend: LocalBackend): string { */ async function getContextResource(backend: LocalBackend, repoName?: string): Promise { // Resolve repo - const repo = backend.resolveRepo(repoName); + const repo = await backend.resolveRepo(repoName); const repoId = repo.name.toLowerCase(); const context = backend.getContext(repoId) || backend.getContext(); @@ -432,8 +432,8 @@ async function getProcessDetailResource(name: string, backend: LocalBackend, rep * Useful for `gitnexus setup` onboarding or dynamic content injection. */ async function getSetupResource(backend: LocalBackend): Promise { - const repos = backend.listRepos(); - + const repos = await backend.listRepos(); + if (repos.length === 0) { return '# GitNexus\n\nNo repositories indexed. Run: `npx gitnexus analyze` in a repository.'; }