Skip to content

Commit 08539c6

Browse files
committed
feat: add server config file support for pre-registering projects
Reads ~/.codebase-context/config.json on startup to pre-register project roots and per-project exclude pattern overrides without requiring a connected MCP client. Config roots are additive — they survive syncKnownRoots() refreshes from client root changes. - src/server/config.ts: new module to load/parse config file - src/project-state.ts: add extraExcludePatterns field - src/index.ts: wire applyServerConfig() in main() and startHttp(), preserve configRoots in syncKnownRoots(), merge extraExcludePatterns in performIndexingOnce() - tests/server-config.test.ts: 11 unit tests covering all edge paths
1 parent e031a56 commit 08539c6

4 files changed

Lines changed: 311 additions & 12 deletions

File tree

src/index.ts

Lines changed: 59 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
1313
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
1414
import { createServer } from './server/factory.js';
1515
import { startHttpServer } from './server/http.js';
16+
import { loadServerConfig } from './server/config.js';
1617
import {
1718
CallToolRequestSchema,
1819
ListToolsRequestSchema,
@@ -46,6 +47,7 @@ import {
4647
getProjectPathFromContextResourceUri,
4748
isContextResourceUri
4849
} from './resources/uri.js';
50+
import { EXCLUDED_GLOB_PATTERNS } from './constants/codebase-context.js';
4951
import {
5052
discoverProjectsWithinRoot,
5153
findNearestProjectBoundary,
@@ -102,6 +104,8 @@ function resolveRootPath(): string | undefined {
102104
const primaryRootPath = resolveRootPath();
103105
const toolNames = new Set(TOOLS.map((tool) => tool.name));
104106
const knownRoots = new Map<string, { rootPath: string; label?: string }>();
107+
/** Roots loaded from config file — preserved across syncKnownRoots() refreshes. */
108+
const configRoots = new Map<string, { rootPath: string }>();
105109
const discoveredProjectPaths = new Map<string, string>();
106110
let clientRootsEnabled = false;
107111
const projectSourcesByKey = new Map<string, ProjectDescriptor['source']>();
@@ -337,6 +341,13 @@ function syncKnownRoots(rootEntries: Array<{ rootPath: string; label?: string }>
337341
});
338342
}
339343

344+
// Always include config-registered roots — config is additive (REPO-03)
345+
for (const [rootKey, rootEntry] of configRoots.entries()) {
346+
if (!nextRoots.has(rootKey)) {
347+
nextRoots.set(rootKey, rootEntry);
348+
}
349+
}
350+
340351
for (const [rootKey, existingRoot] of knownRoots.entries()) {
341352
if (!nextRoots.has(rootKey)) {
342353
removeProject(existingRoot.rootPath);
@@ -1240,6 +1251,9 @@ async function performIndexingOnce(
12401251
let lastLoggedProgress = { phase: '', percentage: -1 };
12411252
const indexer = new CodebaseIndexer({
12421253
rootPath: project.rootPath,
1254+
...(project.extraExcludePatterns?.length
1255+
? { config: { exclude: [...EXCLUDED_GLOB_PATTERNS, ...project.extraExcludePatterns] } }
1256+
: {}),
12431257
incrementalOnly,
12441258
onProgress: (progress) => {
12451259
// Only log when phase or percentage actually changes (prevents duplicate logs)
@@ -1587,7 +1601,33 @@ async function initProject(
15871601
}
15881602
}
15891603

1604+
async function applyServerConfig(
1605+
serverConfig: Awaited<ReturnType<typeof loadServerConfig>>
1606+
): Promise<void> {
1607+
for (const proj of serverConfig?.projects ?? []) {
1608+
try {
1609+
const stats = await fs.stat(proj.root);
1610+
if (!stats.isDirectory()) {
1611+
console.error(`[config] Skipping non-directory project root: ${proj.root}`);
1612+
continue;
1613+
}
1614+
const rootKey = normalizeRootKey(proj.root);
1615+
configRoots.set(rootKey, { rootPath: proj.root });
1616+
registerKnownRoot(proj.root);
1617+
if (proj.excludePatterns?.length) {
1618+
const project = getOrCreateProject(proj.root);
1619+
project.extraExcludePatterns = proj.excludePatterns;
1620+
}
1621+
} catch {
1622+
console.error(`[config] Skipping inaccessible project root: ${proj.root}`);
1623+
}
1624+
}
1625+
}
1626+
15901627
async function main() {
1628+
const serverConfig = await loadServerConfig();
1629+
await applyServerConfig(serverConfig);
1630+
15911631
if (primaryRootPath) {
15921632
// Validate bootstrap root path exists and is a directory when explicitly configured.
15931633
try {
@@ -1711,7 +1751,18 @@ export { performIndexing };
17111751
* Each connecting MCP client gets its own Server+Transport pair,
17121752
* sharing the same module-level project state.
17131753
*/
1714-
async function startHttp(port: number): Promise<void> {
1754+
async function startHttp(explicitPort?: number): Promise<void> {
1755+
const serverConfig = await loadServerConfig();
1756+
await applyServerConfig(serverConfig);
1757+
1758+
// Port resolution priority: CLI flag > env var > config file > built-in default (3100)
1759+
const portFromEnv = process.env.CODEBASE_CONTEXT_PORT
1760+
? Number.parseInt(process.env.CODEBASE_CONTEXT_PORT, 10)
1761+
: undefined;
1762+
const resolvedEnvPort = portFromEnv && Number.isFinite(portFromEnv) ? portFromEnv : undefined;
1763+
const port = explicitPort ?? resolvedEnvPort ?? serverConfig?.server?.port ?? 3100;
1764+
const host = serverConfig?.server?.host ?? '127.0.0.1';
1765+
17151766
// Validate bootstrap root the same way main() does
17161767
if (primaryRootPath) {
17171768
try {
@@ -1730,6 +1781,7 @@ async function startHttp(port: number): Promise<void> {
17301781
name: 'codebase-context',
17311782
version: PKG_VERSION,
17321783
port,
1784+
host,
17331785
registerHandlers,
17341786
onSessionReady: (sessionServer) => {
17351787
// Per-session roots change handler
@@ -1803,20 +1855,15 @@ if (isDirectRun) {
18031855
const httpFlag = process.argv.includes('--http') || process.env.CODEBASE_CONTEXT_HTTP === '1';
18041856

18051857
if (httpFlag) {
1858+
// Extract only the CLI flag value. Env var, config, and default
1859+
// are resolved inside startHttp() in priority order: flag > env > config > 3100.
18061860
const portFlagIdx = process.argv.indexOf('--port');
18071861
const portFromFlag =
18081862
portFlagIdx !== -1 ? Number.parseInt(process.argv[portFlagIdx + 1], 10) : undefined;
1809-
const portFromEnv = process.env.CODEBASE_CONTEXT_PORT
1810-
? Number.parseInt(process.env.CODEBASE_CONTEXT_PORT, 10)
1811-
: undefined;
1812-
const port =
1813-
portFromFlag && Number.isFinite(portFromFlag)
1814-
? portFromFlag
1815-
: portFromEnv && Number.isFinite(portFromEnv)
1816-
? portFromEnv
1817-
: 3100;
1818-
1819-
startHttp(port).catch((error) => {
1863+
const explicitPort =
1864+
portFromFlag && Number.isFinite(portFromFlag) ? portFromFlag : undefined;
1865+
1866+
startHttp(explicitPort).catch((error) => {
18201867
console.error('Fatal:', error);
18211868
process.exit(1);
18221869
});

src/project-state.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface ProjectState {
1717
autoRefresh: AutoRefreshController;
1818
initPromise?: Promise<void>;
1919
stopWatcher?: () => void;
20+
/** Extra glob exclusion patterns from config file — merged with EXCLUDED_GLOB_PATTERNS at index time. */
21+
extraExcludePatterns?: string[];
2022
}
2123

2224
export function makePaths(rootPath: string): ToolPaths {

src/server/config.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import os from 'node:os';
2+
import { promises as fs } from 'node:fs';
3+
import path from 'node:path';
4+
5+
export interface ProjectConfig {
6+
root: string;
7+
excludePatterns?: string[];
8+
}
9+
10+
export interface ServerConfig {
11+
projects?: ProjectConfig[];
12+
server?: { port?: number; host?: string };
13+
}
14+
15+
function expandTilde(filePath: string): string {
16+
if (filePath === '~' || filePath.startsWith('~/') || filePath.startsWith('~\\')) {
17+
return path.join(os.homedir(), filePath.slice(1));
18+
}
19+
return filePath;
20+
}
21+
22+
export async function loadServerConfig(): Promise<ServerConfig | null> {
23+
const configPath =
24+
process.env.CODEBASE_CONTEXT_CONFIG_PATH ??
25+
path.join(os.homedir(), '.codebase-context', 'config.json');
26+
27+
let raw: string;
28+
try {
29+
raw = await fs.readFile(configPath, 'utf8');
30+
} catch (err) {
31+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
32+
return null;
33+
}
34+
console.error(`[config] Failed to load config: ${(err as Error).message}`);
35+
return null;
36+
}
37+
38+
let parsed: unknown;
39+
try {
40+
parsed = JSON.parse(raw);
41+
} catch (err) {
42+
console.error(`[config] Failed to load config: ${(err as Error).message}`);
43+
return null;
44+
}
45+
46+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
47+
return null;
48+
}
49+
50+
const config = parsed as Record<string, unknown>;
51+
const result: ServerConfig = {};
52+
53+
// Resolve projects
54+
if (Array.isArray(config.projects)) {
55+
result.projects = (config.projects as unknown[])
56+
.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null)
57+
.map((p) => {
58+
const rawRoot = typeof p.root === 'string' ? p.root : '';
59+
const resolvedRoot = path.resolve(expandTilde(rawRoot));
60+
const proj: ProjectConfig = { root: resolvedRoot };
61+
if (Array.isArray(p.excludePatterns)) {
62+
proj.excludePatterns = p.excludePatterns.filter(
63+
(pattern): pattern is string => typeof pattern === 'string'
64+
);
65+
}
66+
return proj;
67+
});
68+
}
69+
70+
// Resolve server options
71+
if (typeof config.server === 'object' && config.server !== null) {
72+
const srv = config.server as Record<string, unknown>;
73+
result.server = {};
74+
75+
if (typeof srv.host === 'string') {
76+
result.server.host = srv.host;
77+
}
78+
79+
if (srv.port !== undefined) {
80+
const portValue = srv.port;
81+
const portNum = typeof portValue === 'number' ? portValue : Number(portValue);
82+
if (Number.isInteger(portNum) && portNum > 0) {
83+
result.server.port = portNum;
84+
} else {
85+
console.error(`[config] Ignoring invalid server.port: ${portValue}`);
86+
}
87+
}
88+
}
89+
90+
return result;
91+
}

tests/server-config.test.ts

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it, expect, afterEach, vi } from 'vitest';
2+
import { promises as fs } from 'node:fs';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
import { loadServerConfig } from '../src/server/config.js';
6+
7+
// Helper: write a temp config file and set CODEBASE_CONTEXT_CONFIG_PATH
8+
async function withTempConfig(content: string, fn: (filePath: string) => Promise<void>) {
9+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ccc-config-test-'));
10+
const filePath = path.join(tmpDir, 'config.json');
11+
await fs.writeFile(filePath, content, 'utf8');
12+
try {
13+
await fn(filePath);
14+
} finally {
15+
await fs.rm(tmpDir, { recursive: true, force: true });
16+
}
17+
}
18+
19+
describe('loadServerConfig', () => {
20+
afterEach(() => {
21+
delete process.env.CODEBASE_CONTEXT_CONFIG_PATH;
22+
vi.restoreAllMocks();
23+
});
24+
25+
it('returns null silently when config file does not exist (ENOENT)', async () => {
26+
const errorSpy = vi.spyOn(console, 'error');
27+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = '/tmp/nonexistent-ccc-config-99999.json';
28+
const result = await loadServerConfig();
29+
expect(result).toBeNull();
30+
expect(errorSpy).not.toHaveBeenCalled();
31+
});
32+
33+
it('returns null and logs to stderr on malformed JSON', async () => {
34+
const errorSpy = vi.spyOn(console, 'error');
35+
await withTempConfig('{ invalid json }', async (filePath) => {
36+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
37+
const result = await loadServerConfig();
38+
expect(result).toBeNull();
39+
expect(errorSpy).toHaveBeenCalledOnce();
40+
expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Failed to load config:/);
41+
});
42+
});
43+
44+
it('returns null when top-level value is an array', async () => {
45+
await withTempConfig('[]', async (filePath) => {
46+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
47+
const result = await loadServerConfig();
48+
expect(result).toBeNull();
49+
});
50+
});
51+
52+
it('returns null when top-level value is a string', async () => {
53+
await withTempConfig('"just a string"', async (filePath) => {
54+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
55+
const result = await loadServerConfig();
56+
expect(result).toBeNull();
57+
});
58+
});
59+
60+
it('resolves ~/my-repo to an absolute path using os.homedir()', async () => {
61+
const config = JSON.stringify({
62+
projects: [{ root: '~/my-repo' }]
63+
});
64+
await withTempConfig(config, async (filePath) => {
65+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
66+
const result = await loadServerConfig();
67+
expect(result).not.toBeNull();
68+
expect(result!.projects).toHaveLength(1);
69+
const resolved = result!.projects![0].root;
70+
expect(path.isAbsolute(resolved)).toBe(true);
71+
expect(resolved).toBe(path.join(os.homedir(), 'my-repo'));
72+
});
73+
});
74+
75+
it('resolves a relative path via path.resolve()', async () => {
76+
const config = JSON.stringify({
77+
projects: [{ root: 'relative/path' }]
78+
});
79+
await withTempConfig(config, async (filePath) => {
80+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
81+
const result = await loadServerConfig();
82+
expect(result).not.toBeNull();
83+
const resolved = result!.projects![0].root;
84+
expect(path.isAbsolute(resolved)).toBe(true);
85+
expect(resolved).toBe(path.resolve('relative/path'));
86+
});
87+
});
88+
89+
it('returns valid config for well-formed input with projects and server.port', async () => {
90+
// Use absolute paths that are valid on all platforms
91+
const projA = path.join(os.tmpdir(), 'ccc-test-proj-a');
92+
const projB = path.join(os.tmpdir(), 'ccc-test-proj-b');
93+
const config = JSON.stringify({
94+
projects: [
95+
{ root: projA, excludePatterns: ['**/dist/**'] },
96+
{ root: projB }
97+
],
98+
server: { port: 5199, host: '0.0.0.0' }
99+
});
100+
await withTempConfig(config, async (filePath) => {
101+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
102+
const result = await loadServerConfig();
103+
expect(result).not.toBeNull();
104+
expect(result!.projects).toHaveLength(2);
105+
expect(result!.projects![0].root).toBe(path.resolve(projA));
106+
expect(result!.projects![0].excludePatterns).toEqual(['**/dist/**']);
107+
expect(result!.projects![1].root).toBe(path.resolve(projB));
108+
expect(result!.server?.port).toBe(5199);
109+
expect(result!.server?.host).toBe('0.0.0.0');
110+
});
111+
});
112+
113+
it('drops server.port with a warning when value is 0', async () => {
114+
const errorSpy = vi.spyOn(console, 'error');
115+
const config = JSON.stringify({ server: { port: 0 } });
116+
await withTempConfig(config, async (filePath) => {
117+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
118+
const result = await loadServerConfig();
119+
expect(result).not.toBeNull();
120+
expect(result!.server?.port).toBeUndefined();
121+
expect(errorSpy).toHaveBeenCalledOnce();
122+
expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: 0/);
123+
});
124+
});
125+
126+
it('drops server.port with a warning when value is negative', async () => {
127+
const errorSpy = vi.spyOn(console, 'error');
128+
const config = JSON.stringify({ server: { port: -1 } });
129+
await withTempConfig(config, async (filePath) => {
130+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
131+
const result = await loadServerConfig();
132+
expect(result!.server?.port).toBeUndefined();
133+
expect(errorSpy).toHaveBeenCalledOnce();
134+
expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: -1/);
135+
});
136+
});
137+
138+
it('drops server.port with a warning when value is a non-numeric string', async () => {
139+
const errorSpy = vi.spyOn(console, 'error');
140+
const config = JSON.stringify({ server: { port: 'abc' } });
141+
await withTempConfig(config, async (filePath) => {
142+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
143+
const result = await loadServerConfig();
144+
expect(result!.server?.port).toBeUndefined();
145+
expect(errorSpy).toHaveBeenCalledOnce();
146+
expect(errorSpy.mock.calls[0][0]).toMatch(/\[config\] Ignoring invalid server\.port: abc/);
147+
});
148+
});
149+
150+
it('respects CODEBASE_CONTEXT_CONFIG_PATH env var', async () => {
151+
const config = JSON.stringify({ server: { port: 4242 } });
152+
await withTempConfig(config, async (filePath) => {
153+
process.env.CODEBASE_CONTEXT_CONFIG_PATH = filePath;
154+
const result = await loadServerConfig();
155+
expect(result).not.toBeNull();
156+
expect(result!.server?.port).toBe(4242);
157+
});
158+
});
159+
});

0 commit comments

Comments
 (0)