feat: MCP hot-reload — discover newly indexed repos without restart

listRepos() now re-reads ~/.gitnexus/registry.json on every call.
resolveRepo() retries with a registry refresh on cache miss.
Extracted refreshRepos() from init() for reuse. All callers updated
with await for the new async signatures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
abhigyanpatwari 2026-02-21 17:07:57 +05:30
parent 33aa774f03
commit d1fb34d636
4 changed files with 68 additions and 23 deletions

View file

@ -308,7 +308,7 @@ export async function evalServerCommand(options?: EvalServerOptions): Promise<vo
process.exit(1);
}
const repos = backend.listRepos();
const repos = await backend.listRepos();
console.error(`GitNexus eval-server: ${repos.length} repo(s) loaded: ${repos.map(r => r.name).join(', ')}`);
let idleTimer: ReturnType<typeof setTimeout> | null = null;

View file

@ -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)

View file

@ -76,10 +76,23 @@ export class LocalBackend {
* Returns true if at least one repo is available.
*/
async init(): Promise<boolean> {
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<void> {
const entries = await listRegisteredRepos({ validate: true });
const freshIds = new Set<string>();
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<RepoHandle> {
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<Array<{ name: string; path: string; indexedAt: string; lastCommit: string; stats?: any }>> {
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<any> {
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<any> {
const repo = this.resolveRepo(repoName);
const repo = await this.resolveRepo(repoName);
await this.ensureInitialized(repo.id);
const escaped = name.replace(/'/g, "''");

View file

@ -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<string> {
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<string> {
// 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<string> {
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.';
}