Skip to content

Commit 3c8c273

Browse files
authored
Merge pull request #80 from PatrickSys/feat/http-transport
feat: add HTTP transport mode and server config file support
2 parents c9bf17f + c637bf5 commit 3c8c273

File tree

6 files changed

+371
-28
lines changed

6 files changed

+371
-28
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@
167167
"@modelcontextprotocol/sdk>@hono/node-server": "1.19.11",
168168
"@modelcontextprotocol/sdk>express-rate-limit": "8.2.2",
169169
"@huggingface/transformers>onnxruntime-node": "1.24.2",
170+
"micromatch>picomatch": "2.3.2",
171+
"anymatch>picomatch": "2.3.2",
172+
"readdirp>picomatch": "2.3.2",
170173
"minimatch": "10.2.3",
171174
"rollup": "4.59.0",
172175
"hono@<4.12.7": ">=4.12.7"

pnpm-lock.yaml

Lines changed: 19 additions & 16 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/index.ts

Lines changed: 58 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,14 @@ 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 = portFromFlag && Number.isFinite(portFromFlag) ? portFromFlag : undefined;
1864+
1865+
startHttp(explicitPort).catch((error) => {
18201866
console.error('Fatal:', error);
18211867
process.exit(1);
18221868
});

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: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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.trim() : '';
59+
if (!rawRoot) {
60+
console.error('[config] Skipping project entry with missing or empty root');
61+
return null;
62+
}
63+
const resolvedRoot = path.resolve(expandTilde(rawRoot));
64+
const proj: ProjectConfig = { root: resolvedRoot };
65+
if (Array.isArray(p.excludePatterns)) {
66+
proj.excludePatterns = p.excludePatterns.filter(
67+
(pattern): pattern is string => typeof pattern === 'string'
68+
);
69+
}
70+
return proj;
71+
})
72+
.filter((project): project is ProjectConfig => project !== null);
73+
}
74+
75+
// Resolve server options
76+
if (typeof config.server === 'object' && config.server !== null) {
77+
const srv = config.server as Record<string, unknown>;
78+
result.server = {};
79+
80+
if (typeof srv.host === 'string') {
81+
result.server.host = srv.host;
82+
}
83+
84+
if (srv.port !== undefined) {
85+
const portValue = srv.port;
86+
const portNum = typeof portValue === 'number' ? portValue : Number(portValue);
87+
if (Number.isInteger(portNum) && portNum > 0 && portNum <= 65535) {
88+
result.server.port = portNum;
89+
} else {
90+
console.error(`[config] Ignoring invalid server.port: ${portValue}`);
91+
}
92+
}
93+
}
94+
95+
return result;
96+
}

0 commit comments

Comments
 (0)