diff --git a/.mcp.json b/.mcp.json index da39e4ffa..9a3142d87 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,3 +1,9 @@ { - "mcpServers": {} + "mcpServers": { + "mcp-search": { + "type": "stdio", + "command": "bun", + "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"] + } + } } diff --git a/src/servers/mcp-server.ts b/src/servers/mcp-server.ts index 164f95a51..f258f25d7 100644 --- a/src/servers/mcp-server.ts +++ b/src/servers/mcp-server.ts @@ -104,7 +104,8 @@ const TOOL_ENDPOINT_MAP: Record = { */ async function callWorkerAPI( endpoint: string, - params: Record + params: Record, + timeoutMs?: number ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { logger.debug('SYSTEM', '→ Worker API', undefined, { endpoint, params }); @@ -119,7 +120,7 @@ async function callWorkerAPI( } const apiPath = `${endpoint}?${searchParams}`; - const response = await workerHttpRequest(apiPath); + const response = await workerHttpRequest(apiPath, timeoutMs ? { timeoutMs } : undefined); if (!response.ok) { const errorText = await response.text(); @@ -149,7 +150,8 @@ async function callWorkerAPI( */ async function callWorkerAPIPost( endpoint: string, - body: Record + body: Record, + timeoutMs?: number ): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> { logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint }); @@ -157,7 +159,8 @@ async function callWorkerAPIPost( const response = await workerHttpRequest(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) + body: JSON.stringify(body), + ...(timeoutMs ? { timeoutMs } : {}) }); if (!response.ok) { @@ -473,7 +476,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, additionalProperties: true }, handler: async (args: any) => { - return await callWorkerAPIPost('/api/corpus', args); + return await callWorkerAPIPost('/api/corpus', args, 120000); } }, { @@ -502,7 +505,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, handler: async (args: any) => { const { name, ...rest } = args; if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name'); - return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/prime`, rest); + return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/prime`, rest, 60000); } }, { @@ -520,7 +523,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, handler: async (args: any) => { const { name, ...rest } = args; if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name'); - return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/query`, rest); + return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/query`, rest, 60000); } }, { @@ -537,7 +540,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, handler: async (args: any) => { const { name, ...rest } = args; if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name'); - return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/rebuild`, rest); + return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/rebuild`, rest, 120000); } }, { @@ -554,7 +557,7 @@ NEVER fetch full details without filtering first. 10x token savings.`, handler: async (args: any) => { const { name, ...rest } = args; if (typeof name !== 'string' || name.trim() === '') throw new Error('Missing required argument: name'); - return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/reprime`, rest); + return await callWorkerAPIPost(`/api/corpus/${encodeURIComponent(name)}/reprime`, rest, 60000); } } ]; diff --git a/src/services/smart-file-read/parser.ts b/src/services/smart-file-read/parser.ts index 1adf22aa1..90e4878be 100644 --- a/src/services/smart-file-read/parser.ts +++ b/src/services/smart-file-read/parser.ts @@ -849,8 +849,57 @@ export function parseFile(content: string, filePath: string, projectRoot?: strin const grammarPath = resolveGrammarPathWithFallback(language, projectRoot); if (!grammarPath) { + // No tree-sitter grammar available — produce a basic fallback outline + // by scanning for lines that look like headers (e.g., Markdown-style #, + // ALL-CAPS lines, or lines ending with a colon that resemble section titles). + const fallbackSymbols: CodeSymbol[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed) continue; + + // Markdown-style headers + const headerMatch = trimmed.match(/^(#{1,6})\s+(.+)/); + if (headerMatch) { + fallbackSymbols.push({ + name: headerMatch[2], + kind: "section", + signature: trimmed, + lineStart: i + 1, + lineEnd: i + 1, + exported: false, + }); + continue; + } + + // ALL-CAPS lines (likely section headers in plain text) + if (trimmed.length >= 3 && trimmed.length <= 80 && /^[A-Z][A-Z0-9 _\-]+$/.test(trimmed)) { + fallbackSymbols.push({ + name: trimmed, + kind: "section", + signature: trimmed, + lineStart: i + 1, + lineEnd: i + 1, + exported: false, + }); + continue; + } + + // Lines ending with colon (section-title style) + if (trimmed.endsWith(":") && trimmed.length >= 3 && trimmed.length <= 80 && !trimmed.includes(" ")) { + fallbackSymbols.push({ + name: trimmed.slice(0, -1), + kind: "section", + signature: trimmed, + lineStart: i + 1, + lineEnd: i + 1, + exported: false, + }); + } + } + return { - filePath, language, symbols: [], imports: [], + filePath, language, symbols: fallbackSymbols, imports: [], totalLines: lines.length, foldedTokenEstimate: 50, }; } @@ -907,13 +956,10 @@ export function parseFilesBatch( for (const [language, groupFiles] of languageGroups) { const grammarPath = resolveGrammarPathWithFallback(language, projectRoot); if (!grammarPath) { - // No grammar — return empty results for these files + // No tree-sitter grammar — use fallback parser for these files for (const file of groupFiles) { - const lines = file.content.split("\n"); - results.set(file.relativePath, { - filePath: file.relativePath, language, symbols: [], imports: [], - totalLines: lines.length, foldedTokenEstimate: 50, - }); + const fallbackResult = parseFile(file.content, file.relativePath, projectRoot); + results.set(file.relativePath, fallbackResult); } continue; } diff --git a/src/services/sync/ChromaMcpManager.ts b/src/services/sync/ChromaMcpManager.ts index c293cbf08..1b7c82265 100644 --- a/src/services/sync/ChromaMcpManager.ts +++ b/src/services/sync/ChromaMcpManager.ts @@ -207,7 +207,7 @@ export class ChromaMcpManager { const args = [ '--python', pythonVersion, - 'chroma-mcp', + 'chroma-mcp==0.2.3', '--client-type', 'http', '--host', chromaHost, '--port', chromaPort @@ -233,7 +233,7 @@ export class ChromaMcpManager { // Local mode: persistent client with data directory return [ '--python', pythonVersion, - 'chroma-mcp', + 'chroma-mcp==0.2.3', '--client-type', 'persistent', '--data-dir', DEFAULT_CHROMA_DATA_DIR.replace(/\\/g, '/') ]; @@ -264,9 +264,14 @@ export class ChromaMcpManager { // Transport error: chroma-mcp subprocess likely died (e.g., killed by orphan reaper, // HNSW index corruption). Mark connection dead and retry once after reconnect (#1131). // Without this retry, callers see a one-shot error even though reconnect would succeed. + // IMPORTANT: Close transport BEFORE nulling to prevent subprocess leaks (#1925). this.connected = false; + const staleTransport = this.transport; + const staleClient = this.client; this.client = null; this.transport = null; + try { if (staleTransport) await staleTransport.close(); } catch { /* already dead */ } + try { if (staleClient) await staleClient.close(); } catch { /* already dead */ } logger.warn('CHROMA_MCP', `Transport error during "${toolName}", reconnecting and retrying once`, { error: transportError instanceof Error ? transportError.message : String(transportError) @@ -354,6 +359,26 @@ export class ChromaMcpManager { logger.info('CHROMA_MCP', 'chroma-mcp MCP connection stopped'); } + /** + * Synchronous shutdown — kills the chroma-mcp subprocess immediately. + * Called from process 'exit' handler where async operations are not possible. + * Ensures no orphaned chroma-mcp processes survive the worker. + */ + shutdownSync(): void { + const transportRef = this.transport as unknown as { _process?: import('child_process').ChildProcess } | null; + const childProcess = transportRef?._process; + if (childProcess?.pid && !childProcess.killed) { + try { + childProcess.kill('SIGKILL'); + logger.info('CHROMA_MCP', 'Killed chroma-mcp subprocess on shutdown', { pid: childProcess.pid }); + } catch { /* best effort */ } + } + this.client = null; + this.transport = null; + this.connected = false; + this.connecting = null; + } + /** * Reset the singleton instance (for testing). * Awaits stop() to prevent dual subprocesses. @@ -434,15 +459,26 @@ export class ChromaMcpManager { } } + /** Proxy env var names to strip -- chroma-mcp uses local persistent storage + * and doesn't need network proxies. SOCKS5 proxies in particular break the + * stdio handshake by interfering with Python's HTTP client initialization. */ + private static readonly PROXY_ENV_VARS_TO_STRIP = new Set([ + 'ALL_PROXY', 'all_proxy', + 'HTTP_PROXY', 'http_proxy', + 'HTTPS_PROXY', 'https_proxy', + 'NO_PROXY', 'no_proxy', + ]); + /** * Build subprocess environment with SSL certificate overrides for enterprise proxy compatibility. + * Strips proxy env vars that break chroma-mcp's stdio handshake (#1927). * If a combined cert bundle exists (Zscaler), injects SSL_CERT_FILE, REQUESTS_CA_BUNDLE, etc. * Otherwise returns a plain string-keyed copy of process.env. */ private getSpawnEnv(): Record { const baseEnv: Record = {}; for (const [key, value] of Object.entries(sanitizeEnv(process.env))) { - if (value !== undefined) { + if (value !== undefined && !ChromaMcpManager.PROXY_ENV_VARS_TO_STRIP.has(key)) { baseEnv[key] = value; } } @@ -482,3 +518,12 @@ export class ChromaMcpManager { }); } } + +// Safety net: kill chroma-mcp subprocess on process exit to prevent orphans. +// The 'exit' event is the last chance to clean up — only synchronous code runs here. +process.on('exit', () => { + const instance = ChromaMcpManager['instance']; + if (instance) { + instance.shutdownSync(); + } +});