Skip to content
Closed
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
8 changes: 7 additions & 1 deletion .mcp.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
{
"mcpServers": {}
"mcpServers": {
"mcp-search": {
"type": "stdio",
"command": "bun",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 No fallback if CLAUDE_PLUGIN_ROOT is unset

"${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs" relies on Claude Code expanding the env var at load time. Every shell command in hooks.json has an explicit fallback chain ([ -z "$_R" ] && _R=$(ls -dt ...)) for the case where CLAUDE_PLUGIN_ROOT isn't populated. This MCP entry has no equivalent fallback, so if the variable is absent the literal string is passed as the executable path and the MCP server silently fails to start. Documenting the requirement or adding a wrapper script with fallback logic (matching the hooks pattern) would make the registration more robust.

Fix in Claude Code

"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/mcp-server.cjs"]
}
}
}
21 changes: 12 additions & 9 deletions src/servers/mcp-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ const TOOL_ENDPOINT_MAP: Record<string, string> = {
*/
async function callWorkerAPI(
endpoint: string,
params: Record<string, any>
params: Record<string, any>,
timeoutMs?: number
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
logger.debug('SYSTEM', '→ Worker API', undefined, { endpoint, params });

Expand All @@ -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();
Expand Down Expand Up @@ -149,15 +150,17 @@ async function callWorkerAPI(
*/
async function callWorkerAPIPost(
endpoint: string,
body: Record<string, any>
body: Record<string, any>,
timeoutMs?: number
): Promise<{ content: Array<{ type: 'text'; text: string }>; isError?: boolean }> {
logger.debug('HTTP', 'Worker API request (POST)', undefined, { endpoint });

try {
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) {
Expand Down Expand Up @@ -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);
}
},
{
Expand Down Expand Up @@ -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);
}
},
{
Expand All @@ -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);
}
},
{
Expand All @@ -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);
}
},
{
Expand All @@ -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);
}
}
];
Expand Down
60 changes: 53 additions & 7 deletions src/services/smart-file-read/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Comment on lines 901 to 904
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 foldedTokenEstimate ignores actual symbol count

The fallback path returns a hardcoded foldedTokenEstimate: 50 regardless of how many sections were detected. For a large .txt or configuration file with many headers, this underestimates the folded view and can throw off token-budget calculations downstream. The old parseFilesBatch code also used 50, so this is not a regression — but now that parseFile is called directly, computing an estimate from fallbackSymbols.length would be more accurate at almost no extra cost.

Suggested change
return {
filePath, language, symbols: [], imports: [],
filePath, language, symbols: fallbackSymbols, imports: [],
totalLines: lines.length, foldedTokenEstimate: 50,
};
return {
filePath, language, symbols: fallbackSymbols, imports: [],
totalLines: lines.length,
foldedTokenEstimate: Math.max(50, fallbackSymbols.length * 10),
};

Fix in Claude Code

}
Expand Down Expand Up @@ -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;
}
Expand Down
51 changes: 48 additions & 3 deletions src/services/sync/ChromaMcpManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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, '/')
];
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
Comment on lines +368 to +369
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Fragile private-API cast for _process

_process is not part of StdioClientTransport's public contract — it's an internal property accessed via as unknown as { _process? }. If a future version of @modelcontextprotocol/sdk renames or removes this field, shutdownSync will silently become a no-op (no error, no kill, orphaned subprocess). The same cast already exists at line 505 for registerManagedProcess, so this is consistent with the existing pattern, but consider adding a fallback sentinel so any silent failures surface in logs:

if (!childProcess) {
  logger.warn('CHROMA_MCP', 'shutdownSync: _process not available on transport (MCP SDK may have changed)');
}

Fix in Claude Code

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.
Expand Down Expand Up @@ -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<string, string> {
const baseEnv: Record<string, string> = {};
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;
}
}
Expand Down Expand Up @@ -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();
}
});
Loading