diff --git a/CLAUDE.md b/CLAUDE.md
index 59e5454c..2d3b7bb1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,6 +52,7 @@ JS source is plain JavaScript (ES modules) in `src/`. No transpilation step. The
| `config.js` | `.codegraphrc.json` loading, env overrides, `apiKeyCommand` secret resolution |
| `constants.js` | `EXTENSIONS` (derived from parser registry) and `IGNORE_DIRS` constants |
| `native.js` | Native napi-rs addon loader with WASM fallback |
+| `registry.js` | Global repo registry (`~/.codegraph/registry.json`) for multi-repo MCP |
| `resolve.js` | Import resolution (supports native batch mode) |
| `logger.js` | Structured logging (`warn`, `debug`, `info`, `error`) |
diff --git a/README.md b/README.md
index b36d6ded..d51cd3da 100644
--- a/README.md
+++ b/README.md
@@ -128,7 +128,7 @@ codegraph deps src/index.ts # file-level import/export map
| 📤 | **Export** | DOT (Graphviz), Mermaid, and JSON graph export |
| 🧠| **Semantic search** | Embeddings-powered natural language search with multi-query RRF ranking |
| 👀 | **Watch mode** | Incrementally update the graph as files change |
-| 🤖 | **MCP server** | Model Context Protocol integration for AI assistants |
+| 🤖 | **MCP server** | 12-tool MCP server with multi-repo support for AI assistants |
| 🔒 | **Fully local** | No network calls, no data exfiltration, SQLite-backed |
## 📦 Commands
@@ -213,6 +213,20 @@ A single trailing semicolon is ignored (falls back to single-query mode). The `-
The model used during `embed` is stored in the database, so `search` auto-detects it — no need to pass `--model` when searching.
+### Multi-Repo Registry
+
+Manage a global registry of codegraph-enabled projects. AI agents can query any registered repo from a single MCP session using the `repo` parameter.
+
+```bash
+codegraph registry list # List all registered repos
+codegraph registry list --json # JSON output
+codegraph registry add
# Register a project directory
+codegraph registry add -n my-name # Custom name
+codegraph registry remove # Unregister
+```
+
+`codegraph build` auto-registers the project — no manual setup needed.
+
### AI Integration
```bash
@@ -310,12 +324,14 @@ Benchmarked on a ~3,200-file TypeScript project:
### MCP Server
-Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server, so AI assistants can query your dependency graph directly:
+Codegraph includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server with 12 tools, so AI assistants can query your dependency graph directly:
```bash
codegraph mcp
```
+All MCP tools accept an optional `repo` parameter to target any registered repository. Use `list_repos` to see available repos. When `repo` is omitted, the local `.codegraph/graph.db` is used (backwards compatible).
+
### CLAUDE.md / Agent Instructions
Add this to your project's `CLAUDE.md` to help AI agents use codegraph:
@@ -468,7 +484,7 @@ const { results: fused } = await multiSearchData(
See **[ROADMAP.md](ROADMAP.md)** for the full development roadmap. Current plan:
1. ~~**Rust Core**~~ — **Complete** (v1.3.0) — native tree-sitter parsing via napi-rs, parallel multi-core parsing, incremental re-parsing, import resolution & cycle detection in Rust
-2. ~~**Foundation Hardening**~~ — **Complete** (v1.4.0) — parser registry, 11-tool MCP server, test coverage 62%→75%, `apiKeyCommand` secret resolution
+2. ~~**Foundation Hardening**~~ — **Complete** (v1.4.0) — parser registry, 12-tool MCP server with multi-repo support, test coverage 62%→75%, `apiKeyCommand` secret resolution, global repo registry
3. **Intelligent Embeddings** — LLM-generated descriptions, hybrid search
4. **Natural Language Queries** — `codegraph ask` command, conversational sessions
5. **Expanded Language Support** — 8 new languages (12 → 20)
diff --git a/ROADMAP.md b/ROADMAP.md
index 59116748..32620e5f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -13,7 +13,7 @@ Codegraph is a strong local-first code graph CLI. This roadmap describes planned
| Phase | Theme | Key Deliverables | Status |
|-------|-------|-----------------|--------|
| [**1**](#phase-1--rust-core) | Rust Core | Rust parsing engine via napi-rs, parallel parsing, incremental tree-sitter, JS orchestration layer | **Complete** (v1.3.0) |
-| [**2**](#phase-2--foundation-hardening) | Foundation Hardening | Parser registry, complete MCP, test coverage, enhanced config, multi-repo MCP | **Partial** — core complete (v1.4.0), 2.5 planned |
+| [**2**](#phase-2--foundation-hardening) | Foundation Hardening | Parser registry, complete MCP, test coverage, enhanced config, multi-repo MCP | **Complete** (v1.4.0) |
| [**3**](#phase-3--intelligent-embeddings) | Intelligent Embeddings | LLM-generated descriptions, hybrid search | Planned |
| [**4**](#phase-4--natural-language-queries) | Natural Language Queries | `ask` command, conversational sessions | Planned |
| [**5**](#phase-5--expanded-language-support) | Expanded Language Support | 8 new languages (12 → 20), parser utilities | Planned |
@@ -171,19 +171,19 @@ New configuration options in `.codegraphrc.json`:
**Affected files:** `src/config.js`
-### 2.5 — Multi-Repo MCP
+### 2.5 — Multi-Repo MCP ✅
Support querying multiple codebases from a single MCP server instance.
-- Registry file at `~/.codegraph/registry.json` mapping repo names to their `.codegraph/graph.db` paths
-- Lazy DB connections — only opened when a repo is first queried
-- Add optional `repo` parameter to all MCP tools to target a specific repository
-- Auto-registration: `codegraph build` adds the current project to the registry
-- New CLI commands: `codegraph registry list|add|remove` for manual management
-- Default behavior: when `repo` is omitted, use the local `.codegraph/graph.db` (backwards compatible)
+- ✅ Registry file at `~/.codegraph/registry.json` mapping repo names to their `.codegraph/graph.db` paths
+- ✅ Add optional `repo` parameter to all 11 MCP tools to target a specific repository
+- ✅ New `list_repos` MCP tool (12th tool) to enumerate registered repositories
+- ✅ Auto-registration: `codegraph build` adds the current project to the registry
+- ✅ New CLI commands: `codegraph registry list|add|remove` for manual management
+- ✅ Default behavior: when `repo` is omitted, use the local `.codegraph/graph.db` (backwards compatible)
**New files:** `src/registry.js`
-**Affected files:** `src/mcp.js`, `src/cli.js`, `src/builder.js`
+**Affected files:** `src/mcp.js`, `src/cli.js`, `src/builder.js`, `src/index.js`
---
@@ -481,6 +481,15 @@ codegraph viz
---
+## Watch List
+
+Technology changes to monitor that may unlock future improvements.
+
+- **`node:sqlite` (Node.js built-in)** — **primary target.** Zero native dependencies, eliminates C++ addon breakage on Node major releases (`better-sqlite3` already broken on Node 24/25). Currently Stability 1.1 (Active Development) as of Node 25.x. Adopt when it reaches Stability 2, or use as a fallback alongside `better-sqlite3` (dual-engine pattern like native/WASM parsing). Backed by the Node.js project — no startup risk.
+- **`libsql` (SQLite fork by Turso)** — monitor only. Drop-in `better-sqlite3` replacement with built-in DiskANN vector search. However, Turso is pivoting engineering focus to Limbo (full Rust SQLite rewrite), leaving libsql as legacy. Pre-1.0 (v0.5.x) with uncertain long-term maintenance. Low switching cost (API-compatible, data is standard SQLite), but not worth adopting until the Turso/Limbo situation clarifies.
+
+---
+
## Contributing
Want to help? Contributions to any phase are welcome. See [CONTRIBUTING](README.md#-contributing) for setup instructions.
diff --git a/src/builder.js b/src/builder.js
index a9bf74a4..35317b97 100644
--- a/src/builder.js
+++ b/src/builder.js
@@ -4,7 +4,7 @@ import path from 'node:path';
import { loadConfig } from './config.js';
import { EXTENSIONS, IGNORE_DIRS, normalizePath } from './constants.js';
import { initSchema, openDb } from './db.js';
-import { warn } from './logger.js';
+import { debug, warn } from './logger.js';
import { getActiveEngine, parseFilesAuto } from './parser.js';
import { computeConfidence, resolveImportPath, resolveImportsBatch } from './resolve.js';
@@ -543,4 +543,11 @@ export async function buildGraph(rootDir, opts = {}) {
console.log(`Graph built: ${nodeCount} nodes, ${edgeCount} edges`);
console.log(`Stored in ${dbPath}`);
db.close();
+
+ try {
+ const { registerRepo } = await import('./registry.js');
+ registerRepo(rootDir);
+ } catch (err) {
+ debug(`Auto-registration failed: ${err.message}`);
+ }
}
diff --git a/src/cli.js b/src/cli.js
index 0f4865e0..ff9373d7 100644
--- a/src/cli.js
+++ b/src/cli.js
@@ -19,6 +19,7 @@ import {
moduleMap,
queryName,
} from './queries.js';
+import { listRepos, REGISTRY_PATH, registerRepo, unregisterRepo } from './registry.js';
import { watchProject } from './watcher.js';
const program = new Command();
@@ -191,6 +192,56 @@ program
await startMCPServer(opts.db);
});
+// ─── Registry commands ──────────────────────────────────────────────────
+
+const registry = program.command('registry').description('Manage the multi-repo project registry');
+
+registry
+ .command('list')
+ .description('List all registered repositories')
+ .option('-j, --json', 'Output as JSON')
+ .action((opts) => {
+ const repos = listRepos();
+ if (opts.json) {
+ console.log(JSON.stringify(repos, null, 2));
+ } else if (repos.length === 0) {
+ console.log(`No repositories registered.\nRegistry: ${REGISTRY_PATH}`);
+ } else {
+ console.log(`Registered repositories (${REGISTRY_PATH}):\n`);
+ for (const r of repos) {
+ const dbExists = fs.existsSync(r.dbPath);
+ const status = dbExists ? '' : ' [DB missing]';
+ console.log(` ${r.name}${status}`);
+ console.log(` Path: ${r.path}`);
+ console.log(` DB: ${r.dbPath}`);
+ console.log();
+ }
+ }
+ });
+
+registry
+ .command('add ')
+ .description('Register a project directory')
+ .option('-n, --name ', 'Custom name (defaults to directory basename)')
+ .action((dir, opts) => {
+ const absDir = path.resolve(dir);
+ const { name, entry } = registerRepo(absDir, opts.name);
+ console.log(`Registered "${name}" → ${entry.path}`);
+ });
+
+registry
+ .command('remove ')
+ .description('Unregister a repository by name')
+ .action((name) => {
+ const removed = unregisterRepo(name);
+ if (removed) {
+ console.log(`Removed "${name}" from registry.`);
+ } else {
+ console.error(`Repository "${name}" not found in registry.`);
+ process.exit(1);
+ }
+ });
+
// ─── Embedding commands ─────────────────────────────────────────────────
program
diff --git a/src/index.js b/src/index.js
index 3e441d71..f1df2118 100644
--- a/src/index.js
+++ b/src/index.js
@@ -46,5 +46,15 @@ export {
moduleMapData,
queryNameData,
} from './queries.js';
+// Registry (multi-repo)
+export {
+ listRepos,
+ loadRegistry,
+ REGISTRY_PATH,
+ registerRepo,
+ resolveRepoDbPath,
+ saveRegistry,
+ unregisterRepo,
+} from './registry.js';
// Watch mode
export { watchProject } from './watcher.js';
diff --git a/src/mcp.js b/src/mcp.js
index daba5932..7b5b61c5 100644
--- a/src/mcp.js
+++ b/src/mcp.js
@@ -9,6 +9,13 @@ import { createRequire } from 'node:module';
import { findCycles } from './cycles.js';
import { findDbPath } from './db.js';
+const REPO_PROP = {
+ repo: {
+ type: 'string',
+ description: 'Repository name from the registry (omit for local project)',
+ },
+};
+
const TOOLS = [
{
name: 'query_function',
@@ -22,6 +29,7 @@ const TOOLS = [
description: 'Traversal depth for transitive callers',
default: 2,
},
+ ...REPO_PROP,
},
required: ['name'],
},
@@ -33,6 +41,7 @@ const TOOLS = [
type: 'object',
properties: {
file: { type: 'string', description: 'File path (partial match supported)' },
+ ...REPO_PROP,
},
required: ['file'],
},
@@ -44,6 +53,7 @@ const TOOLS = [
type: 'object',
properties: {
file: { type: 'string', description: 'File path to analyze' },
+ ...REPO_PROP,
},
required: ['file'],
},
@@ -53,7 +63,9 @@ const TOOLS = [
description: 'Detect circular dependencies in the codebase',
inputSchema: {
type: 'object',
- properties: {},
+ properties: {
+ ...REPO_PROP,
+ },
},
},
{
@@ -63,6 +75,7 @@ const TOOLS = [
type: 'object',
properties: {
limit: { type: 'number', description: 'Number of top files to show', default: 20 },
+ ...REPO_PROP,
},
},
},
@@ -75,6 +88,7 @@ const TOOLS = [
name: { type: 'string', description: 'Function/method/class name (partial match)' },
depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
+ ...REPO_PROP,
},
required: ['name'],
},
@@ -89,6 +103,7 @@ const TOOLS = [
name: { type: 'string', description: 'Function/method/class name (partial match)' },
depth: { type: 'number', description: 'Max traversal depth', default: 5 },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
+ ...REPO_PROP,
},
required: ['name'],
},
@@ -103,6 +118,7 @@ const TOOLS = [
ref: { type: 'string', description: 'Git ref to diff against (default: HEAD)' },
depth: { type: 'number', description: 'Transitive caller depth', default: 3 },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
+ ...REPO_PROP,
},
},
},
@@ -116,6 +132,7 @@ const TOOLS = [
query: { type: 'string', description: 'Natural language search query' },
limit: { type: 'number', description: 'Max results to return', default: 15 },
min_score: { type: 'number', description: 'Minimum similarity score (0-1)', default: 0.2 },
+ ...REPO_PROP,
},
required: ['query'],
},
@@ -136,6 +153,7 @@ const TOOLS = [
description: 'File-level graph (true) or function-level (false)',
default: true,
},
+ ...REPO_PROP,
},
required: ['format'],
},
@@ -150,9 +168,18 @@ const TOOLS = [
file: { type: 'string', description: 'Filter by file path (partial match)' },
pattern: { type: 'string', description: 'Filter by function name (partial match)' },
no_tests: { type: 'boolean', description: 'Exclude test files', default: false },
+ ...REPO_PROP,
},
},
},
+ {
+ name: 'list_repos',
+ description: 'List all repositories registered in the codegraph registry',
+ inputSchema: {
+ type: 'object',
+ properties: {},
+ },
+ },
];
export { TOOLS };
@@ -200,9 +227,19 @@ export async function startMCPServer(customDbPath) {
server.setRequestHandler('tools/call', async (request) => {
const { name, arguments: args } = request.params;
- const dbPath = customDbPath || undefined;
try {
+ let dbPath = customDbPath || undefined;
+ if (args.repo) {
+ const { resolveRepoDbPath } = await import('./registry.js');
+ const resolved = resolveRepoDbPath(args.repo);
+ if (!resolved)
+ throw new Error(
+ `Repository "${args.repo}" not found in registry or its database is missing.`,
+ );
+ dbPath = resolved;
+ }
+
let result;
switch (name) {
case 'query_function':
@@ -296,6 +333,11 @@ export async function startMCPServer(customDbPath) {
noTests: args.no_tests,
});
break;
+ case 'list_repos': {
+ const { listRepos } = await import('./registry.js');
+ result = { repos: listRepos() };
+ break;
+ }
default:
return { content: [{ type: 'text', text: `Unknown tool: ${name}` }], isError: true };
}
diff --git a/src/registry.js b/src/registry.js
new file mode 100644
index 00000000..a0b1f1ee
--- /dev/null
+++ b/src/registry.js
@@ -0,0 +1,95 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { debug, warn } from './logger.js';
+
+export const REGISTRY_PATH = path.join(os.homedir(), '.codegraph', 'registry.json');
+
+/**
+ * Load the registry from disk.
+ * Returns `{ repos: {} }` on missing or corrupt file.
+ */
+export function loadRegistry(registryPath = REGISTRY_PATH) {
+ try {
+ const raw = fs.readFileSync(registryPath, 'utf-8');
+ const data = JSON.parse(raw);
+ if (!data || typeof data.repos !== 'object') return { repos: {} };
+ return data;
+ } catch {
+ return { repos: {} };
+ }
+}
+
+/**
+ * Persist the registry to disk (atomic write via temp + rename).
+ * Creates the parent directory if needed.
+ */
+export function saveRegistry(registry, registryPath = REGISTRY_PATH) {
+ const dir = path.dirname(registryPath);
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+ const tmp = `${registryPath}.tmp.${process.pid}`;
+ fs.writeFileSync(tmp, JSON.stringify(registry, null, 2), 'utf-8');
+ fs.renameSync(tmp, registryPath);
+}
+
+/**
+ * Register a project directory. Idempotent.
+ * Name defaults to `path.basename(rootDir)`.
+ */
+export function registerRepo(rootDir, name, registryPath = REGISTRY_PATH) {
+ const absRoot = path.resolve(rootDir);
+ const repoName = name || path.basename(absRoot);
+ const registry = loadRegistry(registryPath);
+
+ registry.repos[repoName] = {
+ path: absRoot,
+ dbPath: path.join(absRoot, '.codegraph', 'graph.db'),
+ addedAt: new Date().toISOString(),
+ };
+
+ saveRegistry(registry, registryPath);
+ debug(`Registered repo "${repoName}" at ${absRoot}`);
+ return { name: repoName, entry: registry.repos[repoName] };
+}
+
+/**
+ * Remove a repo from the registry. Returns false if not found.
+ */
+export function unregisterRepo(name, registryPath = REGISTRY_PATH) {
+ const registry = loadRegistry(registryPath);
+ if (!registry.repos[name]) return false;
+ delete registry.repos[name];
+ saveRegistry(registry, registryPath);
+ return true;
+}
+
+/**
+ * List all registered repos, sorted by name.
+ */
+export function listRepos(registryPath = REGISTRY_PATH) {
+ const registry = loadRegistry(registryPath);
+ return Object.entries(registry.repos)
+ .map(([name, entry]) => ({
+ name,
+ path: entry.path,
+ dbPath: entry.dbPath,
+ addedAt: entry.addedAt,
+ }))
+ .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+/**
+ * Resolve a repo name to its database path.
+ * Returns undefined if the repo is not found or its DB file is missing.
+ */
+export function resolveRepoDbPath(name, registryPath = REGISTRY_PATH) {
+ const registry = loadRegistry(registryPath);
+ const entry = registry.repos[name];
+ if (!entry) return undefined;
+ if (!fs.existsSync(entry.dbPath)) {
+ warn(`Registry: database missing for "${name}" at ${entry.dbPath}`);
+ return undefined;
+ }
+ return entry.dbPath;
+}
diff --git a/tests/integration/cli.test.js b/tests/integration/cli.test.js
index 77bbbfc8..662443c2 100644
--- a/tests/integration/cli.test.js
+++ b/tests/integration/cli.test.js
@@ -158,3 +158,63 @@ describe('CLI smoke tests', () => {
expect(out).toContain('Usage');
});
});
+
+// ─── Registry CLI ───────────────────────────────────────────────────────
+
+describe('Registry CLI commands', () => {
+ let tmpHome;
+
+ /** Run CLI with isolated HOME to avoid touching real registry */
+ function runReg(...args) {
+ return execFileSync('node', [CLI, ...args], {
+ cwd: tmpDir,
+ encoding: 'utf-8',
+ timeout: 30_000,
+ env: { ...process.env, HOME: tmpHome, USERPROFILE: tmpHome },
+ });
+ }
+
+ beforeAll(() => {
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-reghome-'));
+ });
+
+ afterAll(() => {
+ if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true });
+ });
+
+ test('registry list shows empty when no repos registered', () => {
+ const out = runReg('registry', 'list');
+ expect(out).toContain('No repositories registered');
+ });
+
+ test('registry add + list --json shows added repo', () => {
+ runReg('registry', 'add', tmpDir, '-n', 'test-proj');
+ const out = runReg('registry', 'list', '--json');
+ const repos = JSON.parse(out);
+ expect(repos).toHaveLength(1);
+ expect(repos[0].name).toBe('test-proj');
+ expect(repos[0].path).toBe(tmpDir);
+ });
+
+ test('registry remove removes a repo', () => {
+ // Ensure it exists from previous test (or add it)
+ try {
+ runReg('registry', 'add', tmpDir, '-n', 'to-remove');
+ } catch {
+ /* already exists */
+ }
+
+ const out = runReg('registry', 'remove', 'to-remove');
+ expect(out).toContain('Removed');
+ });
+
+ test('registry remove nonexistent exits with error', () => {
+ try {
+ runReg('registry', 'remove', 'nonexistent-repo');
+ throw new Error('Expected command to fail');
+ } catch (err) {
+ expect(err.status).toBe(1);
+ expect(err.stderr || err.stdout).toContain('not found');
+ }
+ });
+});
diff --git a/tests/unit/mcp.test.js b/tests/unit/mcp.test.js
index 177279b9..3081465c 100644
--- a/tests/unit/mcp.test.js
+++ b/tests/unit/mcp.test.js
@@ -20,6 +20,7 @@ const ALL_TOOL_NAMES = [
'semantic_search',
'export_graph',
'list_functions',
+ 'list_repos',
];
// ─── TOOLS schema ──────────────────────────────────────────────────
@@ -111,6 +112,24 @@ describe('TOOLS', () => {
expect(lf.inputSchema.properties).toHaveProperty('pattern');
expect(lf.inputSchema.properties).toHaveProperty('no_tests');
});
+
+ it('every tool except list_repos has optional repo property', () => {
+ for (const tool of TOOLS) {
+ if (tool.name === 'list_repos') continue;
+ expect(tool.inputSchema.properties).toHaveProperty('repo');
+ expect(tool.inputSchema.properties.repo.type).toBe('string');
+ // repo must never be required
+ if (tool.inputSchema.required) {
+ expect(tool.inputSchema.required).not.toContain('repo');
+ }
+ }
+ });
+
+ it('list_repos tool exists with no required params', () => {
+ const lr = TOOLS.find((t) => t.name === 'list_repos');
+ expect(lr).toBeDefined();
+ expect(lr.inputSchema.required).toBeUndefined();
+ });
});
// ─── startMCPServer handler logic ────────────────────────────────────
@@ -351,4 +370,95 @@ describe('startMCPServer handler dispatch', () => {
vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');
vi.doUnmock('../../src/queries.js');
});
+
+ it('resolves repo param via registry', async () => {
+ const handlers = {};
+
+ vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({
+ Server: class MockServer {
+ setRequestHandler(name, handler) {
+ handlers[name] = handler;
+ }
+ async connect() {}
+ },
+ }));
+ vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
+ StdioServerTransport: class MockTransport {},
+ }));
+ vi.doMock('../../src/registry.js', () => ({
+ resolveRepoDbPath: vi.fn((name) =>
+ name === 'my-project' ? '/resolved/path/.codegraph/graph.db' : undefined,
+ ),
+ }));
+
+ const queryMock = vi.fn(() => ({ query: 'test', results: [] }));
+ vi.doMock('../../src/queries.js', () => ({
+ queryNameData: queryMock,
+ impactAnalysisData: vi.fn(),
+ moduleMapData: vi.fn(),
+ fileDepsData: vi.fn(),
+ fnDepsData: vi.fn(),
+ fnImpactData: vi.fn(),
+ diffImpactData: vi.fn(),
+ listFunctionsData: vi.fn(),
+ }));
+
+ const { startMCPServer } = await import('../../src/mcp.js');
+ await startMCPServer();
+
+ const result = await handlers['tools/call']({
+ params: { name: 'query_function', arguments: { name: 'test', repo: 'my-project' } },
+ });
+ expect(result.isError).toBeUndefined();
+ expect(queryMock).toHaveBeenCalledWith('test', '/resolved/path/.codegraph/graph.db');
+
+ vi.doUnmock('@modelcontextprotocol/sdk/server/index.js');
+ vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');
+ vi.doUnmock('../../src/registry.js');
+ vi.doUnmock('../../src/queries.js');
+ });
+
+ it('returns error when repo not found in registry', async () => {
+ const handlers = {};
+
+ vi.doMock('@modelcontextprotocol/sdk/server/index.js', () => ({
+ Server: class MockServer {
+ setRequestHandler(name, handler) {
+ handlers[name] = handler;
+ }
+ async connect() {}
+ },
+ }));
+ vi.doMock('@modelcontextprotocol/sdk/server/stdio.js', () => ({
+ StdioServerTransport: class MockTransport {},
+ }));
+ vi.doMock('../../src/registry.js', () => ({
+ resolveRepoDbPath: vi.fn(() => undefined),
+ }));
+ vi.doMock('../../src/queries.js', () => ({
+ queryNameData: vi.fn(),
+ impactAnalysisData: vi.fn(),
+ moduleMapData: vi.fn(),
+ fileDepsData: vi.fn(),
+ fnDepsData: vi.fn(),
+ fnImpactData: vi.fn(),
+ diffImpactData: vi.fn(),
+ listFunctionsData: vi.fn(),
+ }));
+
+ const { startMCPServer } = await import('../../src/mcp.js');
+ await startMCPServer();
+
+ const result = await handlers['tools/call']({
+ params: { name: 'query_function', arguments: { name: 'test', repo: 'unknown-repo' } },
+ });
+ expect(result.isError).toBe(true);
+ expect(result.content[0].text).toContain('unknown-repo');
+ expect(result.content[0].text).toContain('not found');
+
+ vi.doUnmock('@modelcontextprotocol/sdk/server/index.js');
+ vi.doUnmock('@modelcontextprotocol/sdk/server/stdio.js');
+ vi.doUnmock('../../src/registry.js');
+ vi.doUnmock('../../src/queries.js');
+ });
});
diff --git a/tests/unit/registry.test.js b/tests/unit/registry.test.js
new file mode 100644
index 00000000..f59596cc
--- /dev/null
+++ b/tests/unit/registry.test.js
@@ -0,0 +1,227 @@
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import {
+ listRepos,
+ loadRegistry,
+ REGISTRY_PATH,
+ registerRepo,
+ resolveRepoDbPath,
+ saveRegistry,
+ unregisterRepo,
+} from '../../src/registry.js';
+
+let tmpDir;
+let registryPath;
+
+beforeEach(() => {
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-registry-'));
+ registryPath = path.join(tmpDir, '.codegraph', 'registry.json');
+});
+
+afterEach(() => {
+ fs.rmSync(tmpDir, { recursive: true, force: true });
+});
+
+// ─── REGISTRY_PATH ──────────────────────────────────────────────────
+
+describe('REGISTRY_PATH', () => {
+ it('points to ~/.codegraph/registry.json', () => {
+ expect(REGISTRY_PATH).toBe(path.join(os.homedir(), '.codegraph', 'registry.json'));
+ });
+});
+
+// ─── loadRegistry ───────────────────────────────────────────────────
+
+describe('loadRegistry', () => {
+ it('returns empty repos on missing file', () => {
+ const reg = loadRegistry(registryPath);
+ expect(reg).toEqual({ repos: {} });
+ });
+
+ it('parses valid JSON', () => {
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
+ fs.writeFileSync(
+ registryPath,
+ JSON.stringify({
+ repos: {
+ myapp: {
+ path: '/tmp/myapp',
+ dbPath: '/tmp/myapp/.codegraph/graph.db',
+ addedAt: '2026-01-01T00:00:00.000Z',
+ },
+ },
+ }),
+ );
+ const reg = loadRegistry(registryPath);
+ expect(reg.repos.myapp.path).toBe('/tmp/myapp');
+ });
+
+ it('returns empty repos on corrupt JSON', () => {
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
+ fs.writeFileSync(registryPath, 'not json {{{');
+ const reg = loadRegistry(registryPath);
+ expect(reg).toEqual({ repos: {} });
+ });
+
+ it('returns empty repos when "repos" key is missing', () => {
+ fs.mkdirSync(path.dirname(registryPath), { recursive: true });
+ fs.writeFileSync(registryPath, JSON.stringify({ version: 1 }));
+ const reg = loadRegistry(registryPath);
+ expect(reg).toEqual({ repos: {} });
+ });
+});
+
+// ─── saveRegistry ───────────────────────────────────────────────────
+
+describe('saveRegistry', () => {
+ it('creates directory and writes valid JSON', () => {
+ const registry = {
+ repos: {
+ test: {
+ path: '/test',
+ dbPath: '/test/.codegraph/graph.db',
+ addedAt: '2026-01-01T00:00:00.000Z',
+ },
+ },
+ };
+ saveRegistry(registry, registryPath);
+
+ expect(fs.existsSync(registryPath)).toBe(true);
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
+ expect(data.repos.test.path).toBe('/test');
+ });
+
+ it('overwrites existing file atomically', () => {
+ const reg1 = {
+ repos: { a: { path: '/a', dbPath: '/a/db', addedAt: '2026-01-01T00:00:00.000Z' } },
+ };
+ const reg2 = {
+ repos: { b: { path: '/b', dbPath: '/b/db', addedAt: '2026-01-02T00:00:00.000Z' } },
+ };
+ saveRegistry(reg1, registryPath);
+ saveRegistry(reg2, registryPath);
+
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
+ expect(data.repos.b).toBeDefined();
+ expect(data.repos.a).toBeUndefined();
+ });
+});
+
+// ─── registerRepo ───────────────────────────────────────────────────
+
+describe('registerRepo', () => {
+ it('defaults name from basename', () => {
+ const dir = path.join(tmpDir, 'my-project');
+ fs.mkdirSync(dir, { recursive: true });
+
+ const { name, entry } = registerRepo(dir, undefined, registryPath);
+ expect(name).toBe('my-project');
+ expect(entry.path).toBe(dir);
+ expect(entry.dbPath).toBe(path.join(dir, '.codegraph', 'graph.db'));
+ });
+
+ it('uses custom name when provided', () => {
+ const dir = path.join(tmpDir, 'my-project');
+ fs.mkdirSync(dir, { recursive: true });
+
+ const { name } = registerRepo(dir, 'custom-name', registryPath);
+ expect(name).toBe('custom-name');
+ });
+
+ it('is idempotent (re-registering updates entry)', () => {
+ const dir = path.join(tmpDir, 'proj');
+ fs.mkdirSync(dir, { recursive: true });
+
+ registerRepo(dir, 'proj', registryPath);
+ registerRepo(dir, 'proj', registryPath);
+
+ const reg = loadRegistry(registryPath);
+ expect(Object.keys(reg.repos)).toHaveLength(1);
+ });
+
+ it('sets addedAt as ISO string', () => {
+ const dir = path.join(tmpDir, 'proj');
+ fs.mkdirSync(dir, { recursive: true });
+
+ const { entry } = registerRepo(dir, 'proj', registryPath);
+ expect(entry.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
+ });
+});
+
+// ─── unregisterRepo ─────────────────────────────────────────────────
+
+describe('unregisterRepo', () => {
+ it('removes and returns true', () => {
+ const dir = path.join(tmpDir, 'proj');
+ fs.mkdirSync(dir, { recursive: true });
+ registerRepo(dir, 'proj', registryPath);
+
+ const removed = unregisterRepo('proj', registryPath);
+ expect(removed).toBe(true);
+
+ const reg = loadRegistry(registryPath);
+ expect(reg.repos.proj).toBeUndefined();
+ });
+
+ it('returns false if not found', () => {
+ const removed = unregisterRepo('nonexistent', registryPath);
+ expect(removed).toBe(false);
+ });
+});
+
+// ─── listRepos ──────────────────────────────────────────────────────
+
+describe('listRepos', () => {
+ it('returns empty array when no repos registered', () => {
+ const repos = listRepos(registryPath);
+ expect(repos).toEqual([]);
+ });
+
+ it('returns repos sorted by name', () => {
+ const dirA = path.join(tmpDir, 'aaa');
+ const dirZ = path.join(tmpDir, 'zzz');
+ const dirM = path.join(tmpDir, 'mmm');
+ fs.mkdirSync(dirA, { recursive: true });
+ fs.mkdirSync(dirZ, { recursive: true });
+ fs.mkdirSync(dirM, { recursive: true });
+
+ registerRepo(dirZ, 'zzz', registryPath);
+ registerRepo(dirA, 'aaa', registryPath);
+ registerRepo(dirM, 'mmm', registryPath);
+
+ const repos = listRepos(registryPath);
+ expect(repos.map((r) => r.name)).toEqual(['aaa', 'mmm', 'zzz']);
+ });
+});
+
+// ─── resolveRepoDbPath ──────────────────────────────────────────────
+
+describe('resolveRepoDbPath', () => {
+ it('returns dbPath when DB exists', () => {
+ const dir = path.join(tmpDir, 'proj');
+ const dbDir = path.join(dir, '.codegraph');
+ const dbFile = path.join(dbDir, 'graph.db');
+ fs.mkdirSync(dbDir, { recursive: true });
+ fs.writeFileSync(dbFile, '');
+
+ registerRepo(dir, 'proj', registryPath);
+ const result = resolveRepoDbPath('proj', registryPath);
+ expect(result).toBe(dbFile);
+ });
+
+ it('returns undefined when name not found', () => {
+ const result = resolveRepoDbPath('nonexistent', registryPath);
+ expect(result).toBeUndefined();
+ });
+
+ it('returns undefined and warns when DB is missing', () => {
+ const dir = path.join(tmpDir, 'proj');
+ fs.mkdirSync(dir, { recursive: true });
+
+ registerRepo(dir, 'proj', registryPath);
+ const result = resolveRepoDbPath('proj', registryPath);
+ expect(result).toBeUndefined();
+ });
+});