codesearch-mcp 0.0.8-alpha → 0.0.9-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -64,15 +64,14 @@ function printSummary(allStats) {
64
64
  console.log('TOTAL'.padEnd(cols.name), String(total.indexed).padStart(cols.num), String(total.skipped).padStart(cols.num), String(total.failed).padStart(cols.num), String(total.chunks).padStart(cols.num), String(total.elapsedSec.toFixed(1)).padStart(cols.num));
65
65
  // Reprint warnings at the end — progress bar activity scrolls per-project warnings out of
66
66
  // view, so we collect them on IndexStats and surface them here as an explicit section.
67
- const projectsWithWarnings = allStats.filter(s => s.warnings.length > 0);
68
- if (projectsWithWarnings.length > 0) {
69
- console.log('\n=== Warnings ===');
70
- for (const s of projectsWithWarnings) {
71
- console.log(`\n${s.serviceName}:`);
72
- for (const w of s.warnings)
73
- console.log(` ⚠ ${w}`);
74
- }
75
- }
67
+ // const projectsWithWarnings = allStats.filter(s => s.warnings.length > 0);
68
+ // if (projectsWithWarnings.length > 0) {
69
+ // console.log('\n=== Warnings ===');
70
+ // for (const s of projectsWithWarnings) {
71
+ // console.log(`\n${s.serviceName}:`);
72
+ // for (const w of s.warnings) console.log(` ⚠ ${w}`);
73
+ // }
74
+ // }
76
75
  }
77
76
  async function main() {
78
77
  const argv1 = path.basename(process.argv[1] ?? '');
@@ -236,6 +236,7 @@ async function scanAndIndex(dir, serviceName, force = false, signal, preCollecte
236
236
  bar.increment();
237
237
  }
238
238
  bar.stop();
239
+ process.stdout.write('\x1B[J'); // erase any blank lines cli-progress left below the bar
239
240
  stats.elapsedSec = Math.round((Date.now() - startTime) / 100) / 10;
240
241
  return stats;
241
242
  }
package/dist/src/mcp.js CHANGED
@@ -64,8 +64,11 @@ You have access to a local semantic code search server. Use these tools to navig
64
64
 
65
65
  1. Use \`list_projects\` to discover available projects before filtering by \`project\`.
66
66
  2. Use \`search_codebase\` to find relevant code by meaning.
67
- 3. If a result looks **truncated or incomplete** (e.g. you see only the middle of a function), call \`read_file_range\` with the returned \`file_path\` and lines around \`startLine\` to get the full picture.
67
+ 3. If a result looks **truncated or incomplete** (e.g. you see only the middle of a function), call \`read_file_range\` with the returned \`file_path\` and \`start_line\` set to the **Line** number shown in the result. Example: if the result shows **Line:** 150, pass \`start_line: 150\`.
68
68
  4. For exploratory searches where you expect results to span chunk boundaries, pass \`include_neighbors: true\` to automatically include the chunk before and after each match.
69
+ 5. Use \`find_symbol\` when you know the exact name of a function or class and want its definition location immediately — faster than semantic search.
70
+ 6. Use \`list_files\` to explore project structure (src/, components/, etc.) before diving into files.
71
+ 7. Use \`read_file\` to read a complete file when you have its absolute path from \`search_codebase\` results.
69
72
 
70
73
  ## When to use read_file_range vs include_neighbors
71
74
 
@@ -84,6 +87,9 @@ Lower score = better match (cosine distance). Results above the configured thres
84
87
 
85
88
  - **get_dependencies** — show what files a given file imports (direct or transitive). Use when you need to understand what a file depends on.
86
89
  - **get_dependents** — show which files import a given file (reverse graph). Use before refactoring to find all callers and avoid breaking changes.
90
+ - **find_symbol** — find where a symbol (function, class, type, interface, variable) is defined by exact name. Faster than semantic search when you know the name.
91
+ - **list_files** — browse the directory tree of a project path. Use to understand project structure before reading files.
92
+ - **read_file** — read a complete file by absolute path with line numbers. Truncates at max_lines with a hint to continue.
87
93
 
88
94
  Both tools require \`file_path\` (as returned by \`search_codebase\`) and \`project\` (from \`list_projects\`). Pass \`transitive: true\` for the full dependency chain.
89
95
  `.trim();
@@ -153,8 +159,8 @@ async function startMCP() {
153
159
  type: 'object',
154
160
  properties: {
155
161
  file_path: { type: 'string', description: 'File path as returned by search_codebase' },
156
- start_line: { type: 'number', description: 'First line to read (1-based, inclusive). Default: 1' },
157
- end_line: { type: 'number', description: 'Last line to read (1-based, inclusive). Default: start_line + 99' },
162
+ start_line: { type: 'number', description: 'First line to read (1-based). Use the **Line** number from search_codebase results. Default: 1.' },
163
+ end_line: { type: 'number', description: 'Last line to read (1-based, inclusive). Default: start_line + 99.' },
158
164
  },
159
165
  required: ['file_path'],
160
166
  },
@@ -187,6 +193,45 @@ async function startMCP() {
187
193
  required: ['file_path', 'project'],
188
194
  },
189
195
  },
196
+ {
197
+ name: 'find_symbol',
198
+ description: 'Find TypeScript/JavaScript symbol definitions (functions, classes, types, interfaces, variables) by exact name. Use when you know the symbol name and want its definition location fast.',
199
+ inputSchema: {
200
+ type: 'object',
201
+ properties: {
202
+ name: { type: 'string', description: 'Exact symbol name (case-sensitive, whole-word match)' },
203
+ project: { type: 'string', description: 'Limit search to this project (optional). Use list_projects to find names.' },
204
+ kind: { type: 'string', enum: ['function', 'class', 'type', 'interface', 'variable', 'all'], description: 'Symbol kind filter. Default: all.' },
205
+ },
206
+ required: ['name'],
207
+ },
208
+ },
209
+ {
210
+ name: 'list_files',
211
+ description: 'List files and directories in a project path as an indented tree. Use to understand project structure before reading files.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ path: { type: 'string', description: 'Directory path relative to the project root (e.g. "src/components" or "." for root)' },
216
+ project: { type: 'string', description: 'Project name (from list_projects)' },
217
+ depth: { type: 'number', description: 'Tree depth: 1 = immediate children only, up to 5. Default: 2.' },
218
+ pattern: { type: 'string', description: 'Optional filename filter (substring or extension like ".ts"). Only matching files are shown.' },
219
+ },
220
+ required: ['path', 'project'],
221
+ },
222
+ },
223
+ {
224
+ name: 'read_file',
225
+ description: 'Read a file from the workspace by absolute path (as returned by search_codebase). Returns contents with line numbers. Truncates at max_lines with a hint to continue with read_file_range.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ file_path: { type: 'string', description: 'Absolute path to the file (as returned in filePath by search_codebase)' },
230
+ max_lines: { type: 'number', description: 'Maximum lines to return (default 300, max 1000)' },
231
+ },
232
+ required: ['file_path'],
233
+ },
234
+ },
190
235
  ],
191
236
  }));
192
237
  server.setRequestHandler(types_js_1.CallToolRequestSchema, async (req) => {
@@ -254,12 +299,11 @@ async function startMCP() {
254
299
  return { content: [{ type: 'text', text: message }] };
255
300
  }
256
301
  const numbered = range.lines.map((line, i) => `${startLine + i}: ${line}`).join('\n');
257
- return {
258
- content: [{
259
- type: 'text',
260
- text: `**File:** ${range.filePath}\n**Lines:** ${range.startLine}–${range.endLine}\n\`\`\`\n${numbered}\n\`\`\``,
261
- }],
262
- };
302
+ let text = `**File:** ${range.filePath}\n**Lines:** ${range.startLine}–${range.endLine}\n\`\`\`\n${numbered}\n\`\`\``;
303
+ if (range.hasMore) {
304
+ text += `\n\n[file has more lines — call read_file_range again with start_line: ${range.endLine + 1}]`;
305
+ }
306
+ return { content: [{ type: 'text', text }] };
263
307
  }
264
308
  if (req.params.name === 'get_dependencies') {
265
309
  const filePath = String(req.params.arguments?.file_path);
@@ -301,6 +345,54 @@ async function startMCP() {
301
345
  }
302
346
  return { content: [{ type: 'text', text: header + fallbackLines.join('\n') }] };
303
347
  }
348
+ if (req.params.name === 'find_symbol') {
349
+ const name = String(req.params.arguments?.name ?? '');
350
+ const project = req.params.arguments?.project ? String(req.params.arguments.project) : null;
351
+ const rawKind = req.params.arguments?.kind;
352
+ const validKinds = ['function', 'class', 'type', 'interface', 'variable', 'all'];
353
+ const kind = validKinds.includes(rawKind) ? rawKind : 'all';
354
+ const { hits, truncated } = (0, searcher_1.findSymbol)(name, project, kind);
355
+ if (hits.length === 0) {
356
+ return { content: [{ type: 'text', text: `No definitions found for symbol: ${name}` }] };
357
+ }
358
+ const lines = [];
359
+ if (truncated)
360
+ lines.push(`*(truncated to ${hits.length} results)*\n`);
361
+ for (const h of hits) {
362
+ lines.push(`**${h.kind}** \`${h.filePath}\` line ${h.line}, col ${h.column}`);
363
+ lines.push(`\`\`\`\n${h.signature}\n\`\`\``);
364
+ }
365
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
366
+ }
367
+ if (req.params.name === 'list_files') {
368
+ const relPath = String(req.params.arguments?.path ?? '.');
369
+ const project = String(req.params.arguments?.project ?? '');
370
+ const rawDepth = Number(req.params.arguments?.depth);
371
+ const depth = isNaN(rawDepth) || rawDepth < 1 ? 2 : Math.min(5, rawDepth);
372
+ const pattern = req.params.arguments?.pattern ? String(req.params.arguments.pattern) : null;
373
+ const { tree, error } = (0, searcher_1.listFiles)(relPath, project, depth, pattern);
374
+ if (error) {
375
+ return { content: [{ type: 'text', text: `list_files error: ${error}` }] };
376
+ }
377
+ return { content: [{ type: 'text', text: `**${project}/${relPath}**\n\`\`\`\n${tree}\n\`\`\`` }] };
378
+ }
379
+ if (req.params.name === 'read_file') {
380
+ const filePath = String(req.params.arguments?.file_path ?? '');
381
+ const rawMax = Number(req.params.arguments?.max_lines);
382
+ const maxLines = isNaN(rawMax) || rawMax < 1 ? 300 : Math.min(1000, rawMax);
383
+ const result = await (0, searcher_1.readFileAbsolute)(filePath, maxLines);
384
+ if (!result.ok) {
385
+ const message = result.reason === 'invalid_path' ? `Invalid path (outside workspace): ${filePath}` :
386
+ `File not found on disk: ${filePath}`;
387
+ return { content: [{ type: 'text', text: message }] };
388
+ }
389
+ const numbered = result.lines.map((line, i) => `${i + 1}: ${line}`).join('\n');
390
+ let text = `**File:** ${result.filePath}\n**Lines:** 1–${result.endLine}\n\`\`\`\n${numbered}\n\`\`\``;
391
+ if (result.hasMore) {
392
+ text += `\n\n[truncated: file has more lines — use read_file_range with file_path: "${result.filePath}", start_line: ${result.endLine + 1} to continue]`;
393
+ }
394
+ return { content: [{ type: 'text', text }] };
395
+ }
304
396
  throw new Error(`Unknown tool: ${req.params.name}`);
305
397
  }
306
398
  catch (err) {
@@ -19,6 +19,7 @@ export type FileRangeResult = {
19
19
  startLine: number;
20
20
  endLine: number;
21
21
  lines: string[];
22
+ hasMore: boolean;
22
23
  } | {
23
24
  ok: false;
24
25
  reason: 'not_indexed' | 'not_on_disk' | 'invalid_path';
@@ -53,4 +54,21 @@ export declare function grepSymbol(symbol: string, project: string, excludeFile:
53
54
  matches: GrepMatch[];
54
55
  truncated: boolean;
55
56
  };
57
+ export type SymbolKind = 'function' | 'class' | 'type' | 'interface' | 'variable' | 'all';
58
+ export interface SymbolHit {
59
+ filePath: string;
60
+ line: number;
61
+ column: number;
62
+ kind: Exclude<SymbolKind, 'all'>;
63
+ signature: string;
64
+ }
65
+ export declare function findSymbol(name: string, project: string | null, kind: SymbolKind): {
66
+ hits: SymbolHit[];
67
+ truncated: boolean;
68
+ };
69
+ export declare function listFiles(relativePath: string, project: string, depth: number, pattern: string | null): {
70
+ tree: string;
71
+ error?: string;
72
+ };
73
+ export declare function readFileAbsolute(absPath: string, maxLines: number): Promise<FileRangeResult>;
56
74
  export declare function readFileRange(filePath: string, startLine: number, endLine: number): Promise<FileRangeResult>;
@@ -40,6 +40,9 @@ exports.fetchNeighbors = fetchNeighbors;
40
40
  exports.getDependencies = getDependencies;
41
41
  exports.getDependents = getDependents;
42
42
  exports.grepSymbol = grepSymbol;
43
+ exports.findSymbol = findSymbol;
44
+ exports.listFiles = listFiles;
45
+ exports.readFileAbsolute = readFileAbsolute;
43
46
  exports.readFileRange = readFileRange;
44
47
  const fs = __importStar(require("fs"));
45
48
  const path = __importStar(require("path"));
@@ -240,6 +243,171 @@ function grepSymbol(symbol, project, excludeFile) {
240
243
  });
241
244
  return { matches: all.slice(0, GREP_MAX_RESULTS), truncated: all.length > GREP_MAX_RESULTS };
242
245
  }
246
+ const FIND_SYMBOL_LIMIT = 50;
247
+ const FIND_SYMBOL_RAW_LIMIT = FIND_SYMBOL_LIMIT * 10;
248
+ function buildFindSymbolPattern(name, kind) {
249
+ const patterns = {
250
+ function: `(export[[:space:]]+)?(async[[:space:]]+)?function[[:space:]]+${name}[[:space:](<]`,
251
+ class: `(export[[:space:]]+)?(abstract[[:space:]]+)?class[[:space:]]+${name}[[:space:]{<]`,
252
+ type: `(export[[:space:]]+)?type[[:space:]]+${name}[[:space:]=<]`,
253
+ interface: `(export[[:space:]]+)?interface[[:space:]]+${name}[[:space:]{<]`,
254
+ variable: `(export[[:space:]]+)?(const|let|var)[[:space:]]+${name}[[:space:]=:]`,
255
+ };
256
+ if (kind === 'all')
257
+ return Object.values(patterns).join('|');
258
+ return patterns[kind];
259
+ }
260
+ function inferKind(line, name) {
261
+ const before = line.slice(0, line.indexOf(name));
262
+ if (/\bfunction\s+$/.test(before))
263
+ return 'function';
264
+ if (/\bclass\s+$/.test(before))
265
+ return 'class';
266
+ if (/\binterface\s+$/.test(before))
267
+ return 'interface';
268
+ if (/\btype\s+$/.test(before))
269
+ return 'type';
270
+ return 'variable';
271
+ }
272
+ function findSymbol(name, project, kind) {
273
+ if (!/^[\w$]+$/.test(name))
274
+ throw new Error('Invalid symbol name');
275
+ const searchDir = project
276
+ ? path.join(config_1.config.workspaceRoot, project)
277
+ : config_1.config.workspaceRoot;
278
+ const pattern = buildFindSymbolPattern(name, kind);
279
+ const result = (0, child_process_1.spawnSync)('grep', [
280
+ '-rn', '-E',
281
+ '-m', String(FIND_SYMBOL_RAW_LIMIT),
282
+ '--include=*.ts', '--include=*.tsx', '--include=*.js', '--include=*.jsx',
283
+ ...GREP_EXCLUDE_DIRS,
284
+ pattern,
285
+ searchDir,
286
+ ], { encoding: 'utf-8', maxBuffer: 10 * 1024 * 1024 });
287
+ if (result.error) {
288
+ console.error(`[findSymbol] grep failed: ${result.error.message}`);
289
+ return { hits: [], truncated: false };
290
+ }
291
+ if (!result.stdout)
292
+ return { hits: [], truncated: false };
293
+ const hits = [];
294
+ for (const raw of result.stdout.split('\n')) {
295
+ if (hits.length >= FIND_SYMBOL_LIMIT)
296
+ break;
297
+ // grep -n format: "/abs/path/file.ts:42: export function Foo(...)"
298
+ // File paths can contain colons on some systems, so split carefully:
299
+ // find first colon after a path separator, use lastIndexOf on the line number part
300
+ const m = raw.match(/^(.+?):(\d+):(.*)$/);
301
+ if (!m)
302
+ continue;
303
+ const [, absFile, lineNumStr, matchedLine] = m;
304
+ const lineNum = parseInt(lineNumStr, 10);
305
+ if (isNaN(lineNum))
306
+ continue;
307
+ const col = matchedLine.indexOf(name);
308
+ if (col === -1)
309
+ continue;
310
+ hits.push({
311
+ filePath: path.relative(config_1.config.workspaceRoot, absFile),
312
+ line: lineNum,
313
+ column: col + 1,
314
+ kind: inferKind(matchedLine, name),
315
+ signature: matchedLine.trim(),
316
+ });
317
+ }
318
+ return { hits, truncated: hits.length >= FIND_SYMBOL_LIMIT };
319
+ }
320
+ // ── list_files ────────────────────────────────────────────────────────────────
321
+ function walkDir(absDir, currentDepth, maxDepth, pattern, lines) {
322
+ let entries;
323
+ try {
324
+ entries = fs.readdirSync(absDir, { withFileTypes: true });
325
+ }
326
+ catch {
327
+ return;
328
+ }
329
+ entries.sort((a, b) => {
330
+ const ad = a.isDirectory() ? 0 : 1;
331
+ const bd = b.isDirectory() ? 0 : 1;
332
+ if (ad !== bd)
333
+ return ad - bd;
334
+ return a.name.localeCompare(b.name);
335
+ });
336
+ for (const entry of entries) {
337
+ if (file_utils_1.IGNORE_DIRS.has(entry.name))
338
+ continue;
339
+ const indent = ' '.repeat(currentDepth);
340
+ if (entry.isDirectory()) {
341
+ lines.push(`${indent}${entry.name}/`);
342
+ if (currentDepth < maxDepth) {
343
+ walkDir(path.join(absDir, entry.name), currentDepth + 1, maxDepth, pattern, lines);
344
+ }
345
+ }
346
+ else {
347
+ if (pattern && !entry.name.includes(pattern))
348
+ continue;
349
+ lines.push(`${indent}${entry.name}`);
350
+ }
351
+ }
352
+ }
353
+ function listFiles(relativePath, project, depth, pattern) {
354
+ const projectRoot = path.join(config_1.config.workspaceRoot, project);
355
+ const resolvedTarget = path.resolve(projectRoot, relativePath);
356
+ const resolvedProject = path.resolve(projectRoot);
357
+ if (resolvedTarget !== resolvedProject && !resolvedTarget.startsWith(resolvedProject + path.sep)) {
358
+ return { tree: '', error: 'Path escapes project root' };
359
+ }
360
+ if (!fs.existsSync(resolvedTarget)) {
361
+ return { tree: '', error: `Path not found: ${relativePath}` };
362
+ }
363
+ if (!fs.statSync(resolvedTarget).isDirectory()) {
364
+ return { tree: '', error: `Not a directory: ${relativePath}` };
365
+ }
366
+ const clampedDepth = Math.min(5, Math.max(1, depth));
367
+ const lines = [];
368
+ walkDir(resolvedTarget, 0, clampedDepth - 1, pattern, lines);
369
+ if (lines.length === 0) {
370
+ return { tree: pattern ? `No files matching "${pattern}" found.` : '(empty directory)' };
371
+ }
372
+ return { tree: lines.join('\n') };
373
+ }
374
+ // ── readFileAbsolute ──────────────────────────────────────────────────────────
375
+ // Variant of readFileRange that accepts an absolute path and skips the DB index check.
376
+ // Used by the read_file MCP tool, which receives absolute paths from search_codebase.
377
+ async function readFileAbsolute(absPath, maxLines) {
378
+ const resolvedRoot = path.resolve(config_1.config.workspaceRoot);
379
+ const resolved = path.resolve(absPath);
380
+ if (resolved !== resolvedRoot && !resolved.startsWith(resolvedRoot + path.sep)) {
381
+ return { ok: false, reason: 'invalid_path' };
382
+ }
383
+ if (!fs.existsSync(resolved))
384
+ return { ok: false, reason: 'not_on_disk' };
385
+ const lines = [];
386
+ const stream = fs.createReadStream(resolved, { encoding: 'utf-8' });
387
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
388
+ let lineNo = 0;
389
+ let hasMore = false;
390
+ try {
391
+ for await (const line of rl) {
392
+ lineNo++;
393
+ if (lineNo <= maxLines)
394
+ lines.push(line);
395
+ if (lineNo >= maxLines) {
396
+ hasMore = true;
397
+ rl.close();
398
+ stream.destroy();
399
+ break;
400
+ }
401
+ }
402
+ }
403
+ catch (err) {
404
+ rl.close();
405
+ stream.destroy();
406
+ throw err;
407
+ }
408
+ const relPath = path.relative(resolvedRoot, resolved);
409
+ return { ok: true, filePath: relPath, startLine: 1, endLine: lineNo, lines, hasMore };
410
+ }
243
411
  // Read a line range from a file that is present in the index
244
412
  async function readFileRange(filePath, startLine, endLine) {
245
413
  // Defensive: reject paths that escape the workspace root (e.g. "../../etc/passwd")
@@ -260,17 +428,19 @@ async function readFileRange(filePath, startLine, endLine) {
260
428
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
261
429
  let lineNo = 0;
262
430
  let lastRead = 0;
431
+ let hasMore = false;
263
432
  for await (const line of rl) {
264
433
  lineNo++;
265
434
  lastRead = lineNo;
266
435
  if (lineNo >= startLine && lineNo <= endLine)
267
436
  lines.push(line);
268
437
  if (lineNo >= endLine) {
438
+ hasMore = true;
269
439
  rl.close();
270
440
  stream.destroy();
271
441
  break;
272
442
  }
273
443
  }
274
444
  // If we broke out early, lastRead is endLine. If file was shorter, lastRead is file length.
275
- return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines };
445
+ return { ok: true, filePath, startLine, endLine: Math.min(endLine, lastRead), lines, hasMore };
276
446
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codesearch-mcp",
3
- "version": "0.0.8-alpha",
3
+ "version": "0.0.9-alpha",
4
4
  "description": "Local semantic codebase search via MCP — indexes projects into pgvector, exposes search to Claude",
5
5
  "license": "MIT",
6
6
  "main": "dist/app.js",