Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
35 changes: 19 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

70 changes: 58 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -46,6 +47,7 @@ import {
getProjectPathFromContextResourceUri,
isContextResourceUri
} from './resources/uri.js';
import { EXCLUDED_GLOB_PATTERNS } from './constants/codebase-context.js';
import {
discoverProjectsWithinRoot,
findNearestProjectBoundary,
Expand Down Expand Up @@ -102,6 +104,8 @@ function resolveRootPath(): string | undefined {
const primaryRootPath = resolveRootPath();
const toolNames = new Set(TOOLS.map((tool) => tool.name));
const knownRoots = new Map<string, { rootPath: string; label?: string }>();
/** Roots loaded from config file — preserved across syncKnownRoots() refreshes. */
const configRoots = new Map<string, { rootPath: string }>();
const discoveredProjectPaths = new Map<string, string>();
let clientRootsEnabled = false;
const projectSourcesByKey = new Map<string, ProjectDescriptor['source']>();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1587,7 +1601,33 @@ async function initProject(
}
}

async function applyServerConfig(
serverConfig: Awaited<ReturnType<typeof loadServerConfig>>
): Promise<void> {
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 {
Expand Down Expand Up @@ -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<void> {
async function startHttp(explicitPort?: number): Promise<void> {
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 {
Expand All @@ -1730,6 +1781,7 @@ async function startHttp(port: number): Promise<void> {
name: 'codebase-context',
version: PKG_VERSION,
port,
host,
registerHandlers,
onSessionReady: (sessionServer) => {
// Per-session roots change handler
Expand Down Expand Up @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions src/project-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface ProjectState {
autoRefresh: AutoRefreshController;
initPromise?: Promise<void>;
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 {
Expand Down
96 changes: 96 additions & 0 deletions src/server/config.ts
Original file line number Diff line number Diff line change
@@ -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<ServerConfig | null> {
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<string, unknown>;
const result: ServerConfig = {};

// Resolve projects
if (Array.isArray(config.projects)) {
result.projects = (config.projects as unknown[])
.filter((p): p is Record<string, unknown> => 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<string, unknown>;
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;
}
Loading
Loading