diff --git a/package.json b/package.json index b82603e..787a580 100644 --- a/package.json +++ b/package.json @@ -167,6 +167,9 @@ "@modelcontextprotocol/sdk>@hono/node-server": "1.19.11", "@modelcontextprotocol/sdk>express-rate-limit": "8.2.2", "@huggingface/transformers>onnxruntime-node": "1.24.2", + "micromatch>picomatch": "2.3.2", + "anymatch>picomatch": "2.3.2", + "readdirp>picomatch": "2.3.2", "minimatch": "10.2.3", "rollup": "4.59.0", "hono@<4.12.7": ">=4.12.7" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a87837..55025da 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ overrides: '@modelcontextprotocol/sdk>@hono/node-server': 1.19.11 '@modelcontextprotocol/sdk>express-rate-limit': 8.2.2 '@huggingface/transformers>onnxruntime-node': 1.24.2 + micromatch>picomatch: 2.3.2 + anymatch>picomatch: 2.3.2 + readdirp>picomatch: 2.3.2 minimatch: 10.2.3 rollup: 4.59.0 hono@<4.12.7: '>=4.12.7' @@ -1942,12 +1945,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pkce-challenge@5.0.1: @@ -3165,7 +3168,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 apache-arrow@15.0.2: dependencies: @@ -3744,9 +3747,9 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.3): + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: - picomatch: 4.0.3 + picomatch: 4.0.4 file-entry-cache@8.0.0: dependencies: @@ -4166,7 +4169,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -4332,9 +4335,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pkce-challenge@5.0.1: {} @@ -4391,7 +4394,7 @@ snapshots: readdirp@3.6.0: dependencies: - picomatch: 2.3.1 + picomatch: 2.3.2 reflect.getprototypeof@1.0.10: dependencies: @@ -4711,8 +4714,8 @@ snapshots: tinyglobby@0.2.15: dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 tinyrainbow@3.0.3: {} @@ -4836,8 +4839,8 @@ snapshots: vite@7.3.0(@types/node@20.19.25)(tsx@4.21.0): dependencies: esbuild: 0.27.2 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 postcss: 8.5.6 rollup: 4.59.0 tinyglobby: 0.2.15 @@ -4860,7 +4863,7 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 3.10.0 tinybench: 2.9.0 tinyexec: 1.0.2 diff --git a/src/index.ts b/src/index.ts index e3dd141..aa8ddb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { createServer } from './server/factory.js'; import { startHttpServer } from './server/http.js'; +import { loadServerConfig } from './server/config.js'; import { CallToolRequestSchema, ListToolsRequestSchema, @@ -46,6 +47,7 @@ import { getProjectPathFromContextResourceUri, isContextResourceUri } from './resources/uri.js'; +import { EXCLUDED_GLOB_PATTERNS } from './constants/codebase-context.js'; import { discoverProjectsWithinRoot, findNearestProjectBoundary, @@ -102,6 +104,8 @@ function resolveRootPath(): string | undefined { const primaryRootPath = resolveRootPath(); const toolNames = new Set(TOOLS.map((tool) => tool.name)); const knownRoots = new Map(); +/** Roots loaded from config file — preserved across syncKnownRoots() refreshes. */ +const configRoots = new Map(); const discoveredProjectPaths = new Map(); let clientRootsEnabled = false; const projectSourcesByKey = new Map(); @@ -337,6 +341,13 @@ function syncKnownRoots(rootEntries: Array<{ rootPath: string; label?: string }> }); } + // Always include config-registered roots — config is additive (REPO-03) + for (const [rootKey, rootEntry] of configRoots.entries()) { + if (!nextRoots.has(rootKey)) { + nextRoots.set(rootKey, rootEntry); + } + } + for (const [rootKey, existingRoot] of knownRoots.entries()) { if (!nextRoots.has(rootKey)) { removeProject(existingRoot.rootPath); @@ -1240,6 +1251,9 @@ async function performIndexingOnce( let lastLoggedProgress = { phase: '', percentage: -1 }; const indexer = new CodebaseIndexer({ rootPath: project.rootPath, + ...(project.extraExcludePatterns?.length + ? { config: { exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.extraExcludePatterns] } } + : {}), incrementalOnly, onProgress: (progress) => { // Only log when phase or percentage actually changes (prevents duplicate logs) @@ -1587,7 +1601,33 @@ async function initProject( } } +async function applyServerConfig( + serverConfig: Awaited> +): Promise { + for (const proj of serverConfig?.projects ?? []) { + try { + const stats = await fs.stat(proj.root); + if (!stats.isDirectory()) { + console.error(`[config] Skipping non-directory project root: ${proj.root}`); + continue; + } + const rootKey = normalizeRootKey(proj.root); + configRoots.set(rootKey, { rootPath: proj.root }); + registerKnownRoot(proj.root); + if (proj.excludePatterns?.length) { + const project = getOrCreateProject(proj.root); + project.extraExcludePatterns = proj.excludePatterns; + } + } catch { + console.error(`[config] Skipping inaccessible project root: ${proj.root}`); + } + } +} + async function main() { + const serverConfig = await loadServerConfig(); + await applyServerConfig(serverConfig); + if (primaryRootPath) { // Validate bootstrap root path exists and is a directory when explicitly configured. try { @@ -1711,7 +1751,18 @@ export { performIndexing }; * Each connecting MCP client gets its own Server+Transport pair, * sharing the same module-level project state. */ -async function startHttp(port: number): Promise { +async function startHttp(explicitPort?: number): Promise { + const serverConfig = await loadServerConfig(); + await applyServerConfig(serverConfig); + + // Port resolution priority: CLI flag > env var > config file > built-in default (3100) + const portFromEnv = process.env.CODEBASE_CONTEXT_PORT + ? Number.parseInt(process.env.CODEBASE_CONTEXT_PORT, 10) + : undefined; + const resolvedEnvPort = portFromEnv && Number.isFinite(portFromEnv) ? portFromEnv : undefined; + const port = explicitPort ?? resolvedEnvPort ?? serverConfig?.server?.port ?? 3100; + const host = serverConfig?.server?.host ?? '127.0.0.1'; + // Validate bootstrap root the same way main() does if (primaryRootPath) { try { @@ -1730,6 +1781,7 @@ async function startHttp(port: number): Promise { name: 'codebase-context', version: PKG_VERSION, port, + host, registerHandlers, onSessionReady: (sessionServer) => { // Per-session roots change handler @@ -1803,20 +1855,14 @@ if (isDirectRun) { const httpFlag = process.argv.includes('--http') || process.env.CODEBASE_CONTEXT_HTTP === '1'; if (httpFlag) { + // Extract only the CLI flag value. Env var, config, and default + // are resolved inside startHttp() in priority order: flag > env > config > 3100. const portFlagIdx = process.argv.indexOf('--port'); const portFromFlag = portFlagIdx !== -1 ? Number.parseInt(process.argv[portFlagIdx + 1], 10) : undefined; - const portFromEnv = process.env.CODEBASE_CONTEXT_PORT - ? Number.parseInt(process.env.CODEBASE_CONTEXT_PORT, 10) - : undefined; - const port = - portFromFlag && Number.isFinite(portFromFlag) - ? portFromFlag - : portFromEnv && Number.isFinite(portFromEnv) - ? portFromEnv - : 3100; - - startHttp(port).catch((error) => { + const explicitPort = portFromFlag && Number.isFinite(portFromFlag) ? portFromFlag : undefined; + + startHttp(explicitPort).catch((error) => { console.error('Fatal:', error); process.exit(1); }); diff --git a/src/project-state.ts b/src/project-state.ts index 6b260dc..02f070c 100644 --- a/src/project-state.ts +++ b/src/project-state.ts @@ -17,6 +17,8 @@ export interface ProjectState { autoRefresh: AutoRefreshController; initPromise?: Promise; stopWatcher?: () => void; + /** Extra glob exclusion patterns from config file — merged with EXCLUDED_GLOB_PATTERNS at index time. */ + extraExcludePatterns?: string[]; } export function makePaths(rootPath: string): ToolPaths { diff --git a/src/server/config.ts b/src/server/config.ts new file mode 100644 index 0000000..3e2d83b --- /dev/null +++ b/src/server/config.ts @@ -0,0 +1,96 @@ +import os from 'node:os'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +export interface ProjectConfig { + root: string; + excludePatterns?: string[]; +} + +export interface ServerConfig { + projects?: ProjectConfig[]; + server?: { port?: number; host?: string }; +} + +function expandTilde(filePath: string): string { + if (filePath === '~' || filePath.startsWith('~/') || filePath.startsWith('~\\')) { + return path.join(os.homedir(), filePath.slice(1)); + } + return filePath; +} + +export async function loadServerConfig(): Promise { + const configPath = + process.env.CODEBASE_CONTEXT_CONFIG_PATH ?? + path.join(os.homedir(), '.codebase-context', 'config.json'); + + let raw: string; + try { + raw = await fs.readFile(configPath, 'utf8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + console.error(`[config] Failed to load config: ${(err as Error).message}`); + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + console.error(`[config] Failed to load config: ${(err as Error).message}`); + return null; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + + const config = parsed as Record; + const result: ServerConfig = {}; + + // Resolve projects + if (Array.isArray(config.projects)) { + result.projects = (config.projects as unknown[]) + .filter((p): p is Record => typeof p === 'object' && p !== null) + .map((p) => { + const rawRoot = typeof p.root === 'string' ? p.root.trim() : ''; + if (!rawRoot) { + console.error('[config] Skipping project entry with missing or empty root'); + return null; + } + const resolvedRoot = path.resolve(expandTilde(rawRoot)); + const proj: ProjectConfig = { root: resolvedRoot }; + if (Array.isArray(p.excludePatterns)) { + proj.excludePatterns = p.excludePatterns.filter( + (pattern): pattern is string => typeof pattern === 'string' + ); + } + return proj; + }) + .filter((project): project is ProjectConfig => project !== null); + } + + // Resolve server options + if (typeof config.server === 'object' && config.server !== null) { + const srv = config.server as Record; + result.server = {}; + + if (typeof srv.host === 'string') { + result.server.host = srv.host; + } + + if (srv.port !== undefined) { + const portValue = srv.port; + const portNum = typeof portValue === 'number' ? portValue : Number(portValue); + if (Number.isInteger(portNum) && portNum > 0 && portNum <= 65535) { + result.server.port = portNum; + } else { + console.error(`[config] Ignoring invalid server.port: ${portValue}`); + } + } + } + + return result; +} diff --git a/tests/server-config.test.ts b/tests/server-config.test.ts new file mode 100644 index 0000000..fc54534 --- /dev/null +++ b/tests/server-config.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { loadServerConfig } from '../src/server/config.js'; + +// Helper: write a temp config file and set CODEBASE_CONTEXT_CONFIG_PATH +async function withTempConfig(content: string, fn: (filePath: string) => Promise) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ccc-config-test-')); + const filePath = path.join(tmpDir, 'config.json'); + await fs.writeFile(filePath, content, 'utf8'); + try { + await fn(filePath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +describe('loadServerConfig', () => { + afterEach(() => { + delete process.env.CODEBASE_CONTEXT_CONFIG_PATH; + vi.restoreAllMocks(); + }); + + it('returns null silently when config file does not exist (ENOENT)', async () => { + const errorSpy = vi.spyOn(console, 'error'); + process.env.CODEBASE_CONTEXT_CONFIG_PATH = '/tmp/nonexistent-ccc-config-99999.json'; + const result = await loadServerConfig(); + expect(result).toBeNull(); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('returns null and logs to stderr on malformed JSON', async () => { + const errorSpy = vi.spyOn(console, 'error'); + await withTempConfig('{ invalid json }', async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).toBeNull(); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Failed to load config:/); + }); + }); + + it('returns null when top-level value is an array', async () => { + await withTempConfig('[]', async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).toBeNull(); + }); + }); + + it('returns null when top-level value is a string', async () => { + await withTempConfig('"just a string"', async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).toBeNull(); + }); + }); + + it('resolves ~/my-repo to an absolute path using os.homedir()', async () => { + const config = JSON.stringify({ + projects: [{ root: '~/my-repo' }] + }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).not.toBeNull(); + expect(result!.projects).toHaveLength(1); + const resolved = result!.projects![0].root; + expect(path.isAbsolute(resolved)).toBe(true); + expect(resolved).toBe(path.join(os.homedir(), 'my-repo')); + }); + }); + + it('resolves a relative path via path.resolve()', async () => { + const config = JSON.stringify({ + projects: [{ root: 'relative/path' }] + }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).not.toBeNull(); + const resolved = result!.projects![0].root; + expect(path.isAbsolute(resolved)).toBe(true); + expect(resolved).toBe(path.resolve('relative/path')); + }); + }); + + it('skips project entries with missing or empty roots instead of resolving cwd', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ + projects: [{}, { root: ' ' }, { root: 'valid-root' }] + }); + + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + + expect(result).not.toBeNull(); + expect(result!.projects).toEqual([{ root: path.resolve('valid-root') }]); + expect(errorSpy).toHaveBeenCalledTimes(2); + expect(errorSpy.mock.calls[0][0]).toMatch( + /\[config\] Skipping project entry with missing or empty root/ + ); + expect(errorSpy.mock.calls[1][0]).toMatch( + /\[config\] Skipping project entry with missing or empty root/ + ); + }); + }); + + it('returns valid config for well-formed input with projects and server.port', async () => { + // Use absolute paths that are valid on all platforms + const projA = path.join(os.tmpdir(), 'ccc-test-proj-a'); + const projB = path.join(os.tmpdir(), 'ccc-test-proj-b'); + const config = JSON.stringify({ + projects: [ + { root: projA, excludePatterns: ['**/dist/**'] }, + { root: projB } + ], + server: { port: 5199, host: '0.0.0.0' } + }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).not.toBeNull(); + expect(result!.projects).toHaveLength(2); + expect(result!.projects![0].root).toBe(path.resolve(projA)); + expect(result!.projects![0].excludePatterns).toEqual(['**/dist/**']); + expect(result!.projects![1].root).toBe(path.resolve(projB)); + expect(result!.server?.port).toBe(5199); + expect(result!.server?.host).toBe('0.0.0.0'); + }); + }); + + it('drops server.port with a warning when value is 0', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ server: { port: 0 } }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).not.toBeNull(); + expect(result!.server?.port).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: 0/); + }); + }); + + it('drops server.port with a warning when value is negative', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ server: { port: -1 } }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result!.server?.port).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: -1/); + }); + }); + + it('drops server.port with a warning when value is a non-numeric string', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ server: { port: 'abc' } }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result!.server?.port).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: abc/); + }); + }); + + it('drops server.port with a warning when value exceeds 65535', async () => { + const errorSpy = vi.spyOn(console, 'error'); + const config = JSON.stringify({ server: { port: 65536 } }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result!.server?.port).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledOnce(); + expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: 65536/); + }); + }); + + it('respects CODEBASE_CONTEXT_CONFIG_PATH env var', async () => { + const config = JSON.stringify({ server: { port: 4242 } }); + await withTempConfig(config, async (filePath) => { + process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath; + const result = await loadServerConfig(); + expect(result).not.toBeNull(); + expect(result!.server?.port).toBe(4242); + }); + }); +});