From 54ea9f6c497f1c7ad4c2f0199b4a951af0a51c62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 00:52:00 -0700 Subject: [PATCH] feat: add multi-repo MCP support with global registry (Phase 2.5) Add a global registry at ~/.codegraph/registry.json so AI agents can query multiple codebases from a single MCP session. Projects are auto-registered on build; an optional `repo` param on every MCP tool lets agents switch context. - New src/registry.js with CRUD operations (load/save/register/ unregister/list/resolve) and atomic writes - Add optional `repo` param to all 11 existing MCP tools - Add 12th MCP tool `list_repos` to enumerate registered repos - Add `codegraph registry list|add|remove` CLI commands - Auto-register projects after `codegraph build` (non-fatal) - Export registry functions from programmatic API - 18 unit tests for registry, 4 new MCP tests, 4 CLI integration tests - Update ROADMAP.md and README.md to mark Phase 2.5 complete --- CLAUDE.md | 1 + README.md | 22 +++- ROADMAP.md | 27 ++-- src/builder.js | 9 +- src/cli.js | 51 ++++++++ src/index.js | 10 ++ src/mcp.js | 46 ++++++- src/registry.js | 95 ++++++++++++++ tests/integration/cli.test.js | 60 +++++++++ tests/unit/mcp.test.js | 110 ++++++++++++++++ tests/unit/registry.test.js | 227 ++++++++++++++++++++++++++++++++++ 11 files changed, 643 insertions(+), 15 deletions(-) create mode 100644 src/registry.js create mode 100644 tests/unit/registry.test.js 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(); + }); +});