ucn 3.8.10 → 3.8.12

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.
@@ -26,12 +26,13 @@ function traverseTree(node, callback, options) {
26
26
  * @param {string} code - Original source code
27
27
  * @returns {{ startLine: number, endLine: number, indent: number }}
28
28
  */
29
- function nodeToLocation(node, code) {
29
+ function nodeToLocation(node, codeOrLines) {
30
30
  const startLine = node.startPosition.row + 1; // tree-sitter is 0-indexed
31
31
  const endLine = node.endPosition.row + 1;
32
32
 
33
33
  // Calculate indent from start of line
34
- const lines = code.split('\n');
34
+ // Accept pre-split lines array to avoid repeated code.split('\n')
35
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
35
36
  const firstLine = lines[node.startPosition.row] || '';
36
37
  const indentMatch = firstLine.match(/^(\s*)/);
37
38
  const indent = indentMatch ? indentMatch[1].length : 0;
@@ -235,8 +236,8 @@ function parseJavaParam(param, info) {
235
236
  * @param {number} startLine - 1-indexed line number of the function/class
236
237
  * @returns {string|null} First line of docstring or null
237
238
  */
238
- function extractJSDocstring(code, startLine) {
239
- const lines = code.split('\n');
239
+ function extractJSDocstring(codeOrLines, startLine) {
240
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
240
241
  const lineIndex = startLine - 1;
241
242
  if (lineIndex <= 0) return null;
242
243
 
@@ -280,8 +281,8 @@ function extractJSDocstring(code, startLine) {
280
281
  * @param {number} defLine - 1-indexed line number of the def/class (not decorator)
281
282
  * @returns {string|null} First line of docstring or null
282
283
  */
283
- function extractPythonDocstring(code, defLine) {
284
- const lines = code.split('\n');
284
+ function extractPythonDocstring(codeOrLines, defLine) {
285
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
285
286
  // Python docstring is INSIDE the function, on lines after the def:
286
287
  let i = defLine; // Start after the def line (defLine is 1-indexed)
287
288
  // Skip to find the first non-empty line inside the function
@@ -316,8 +317,8 @@ function extractPythonDocstring(code, defLine) {
316
317
  * @param {number} startLine - 1-indexed line number of the function
317
318
  * @returns {string|null} First line of doc comment or null
318
319
  */
319
- function extractGoDocstring(code, startLine) {
320
- const lines = code.split('\n');
320
+ function extractGoDocstring(codeOrLines, startLine) {
321
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
321
322
  const lineIndex = startLine - 1;
322
323
  if (lineIndex <= 0) return null;
323
324
 
@@ -349,8 +350,8 @@ function extractGoDocstring(code, startLine) {
349
350
  * @param {number} startLine - 1-indexed line number of the item
350
351
  * @returns {string|null} First line of doc comment or null
351
352
  */
352
- function extractRustDocstring(code, startLine) {
353
- const lines = code.split('\n');
353
+ function extractRustDocstring(codeOrLines, startLine) {
354
+ const lines = Array.isArray(codeOrLines) ? codeOrLines : codeOrLines.split('\n');
354
355
  const lineIndex = startLine - 1;
355
356
  if (lineIndex <= 0) return null;
356
357
 
@@ -515,8 +516,86 @@ function findMatchesWithASTFilter(content, term, parser, options = {}) {
515
516
  return matches;
516
517
  }
517
518
 
519
+ /**
520
+ * Single-entry cache for flat node lists.
521
+ * During indexFile(), the same tree is traversed 5+ times (findFunctions,
522
+ * findClasses, findStateObjects, findImports, findExports). Building a flat
523
+ * list once and iterating it for each pass eliminates repeated recursive
524
+ * traversal overhead (namedChild object creation, function call overhead).
525
+ */
526
+ let _cachedRootNode = null;
527
+ let _cachedNodeList = null;
528
+ let _cachedSubtreeEnds = null;
529
+
530
+ function _buildNodeList(rootNode) {
531
+ const nodes = [];
532
+ const subtreeEnds = [];
533
+ const stack = [rootNode];
534
+ // Iterative DFS with subtreeEnd tracking
535
+ // We use a post-processing step to fill subtreeEnds
536
+ function collect(node) {
537
+ const idx = nodes.length;
538
+ nodes.push(node);
539
+ subtreeEnds.push(0);
540
+ for (let i = 0; i < node.namedChildCount; i++) {
541
+ collect(node.namedChild(i));
542
+ }
543
+ subtreeEnds[idx] = nodes.length;
544
+ }
545
+ collect(rootNode);
546
+ return { nodes, subtreeEnds };
547
+ }
548
+
549
+ /**
550
+ * Get or build a cached flat node list for the given tree.
551
+ * Returns { nodes: SyntaxNode[], subtreeEnds: number[] }.
552
+ * subtreeEnds[i] is the index past the last descendant of nodes[i],
553
+ * enabling O(1) subtree skipping (for 'return false' semantics).
554
+ */
555
+ function getCachedNodeList(rootNode) {
556
+ if (rootNode === _cachedRootNode && _cachedNodeList) {
557
+ return { nodes: _cachedNodeList, subtreeEnds: _cachedSubtreeEnds };
558
+ }
559
+ const { nodes, subtreeEnds } = _buildNodeList(rootNode);
560
+ _cachedRootNode = rootNode;
561
+ _cachedNodeList = nodes;
562
+ _cachedSubtreeEnds = subtreeEnds;
563
+ return { nodes, subtreeEnds };
564
+ }
565
+
566
+ /**
567
+ * Traverse a tree-sitter AST using a cached flat node list.
568
+ * Semantically equivalent to traverseTree() but ~3x faster when the same
569
+ * tree is traversed multiple times (which happens 5+ times per file during build).
570
+ * Supports 'return false' to skip a node's entire subtree.
571
+ *
572
+ * NOTE: Does not support onLeave callbacks. Use traverseTree() for those.
573
+ */
574
+ function traverseTreeCached(rootNode, callback) {
575
+ const { nodes, subtreeEnds } = getCachedNodeList(rootNode);
576
+ for (let i = 0; i < nodes.length; ) {
577
+ if (callback(nodes[i]) === false) {
578
+ i = subtreeEnds[i];
579
+ } else {
580
+ i++;
581
+ }
582
+ }
583
+ }
584
+
585
+ /**
586
+ * Clear the cached node list (call when the tree changes).
587
+ */
588
+ function clearNodeListCache() {
589
+ _cachedRootNode = null;
590
+ _cachedNodeList = null;
591
+ _cachedSubtreeEnds = null;
592
+ }
593
+
518
594
  module.exports = {
519
595
  traverseTree,
596
+ traverseTreeCached,
597
+ getCachedNodeList,
598
+ clearNodeListCache,
520
599
  nodeToLocation,
521
600
  extractParams,
522
601
  parseStructuredParams,
package/mcp/server.js CHANGED
@@ -114,12 +114,17 @@ const server = new McpServer({
114
114
  // TOOL HELPERS
115
115
  // ============================================================================
116
116
 
117
- const DEFAULT_OUTPUT_CHARS = 10000; // ~2.5K tokens — compact default for AI agents
117
+ const DEFAULT_OUTPUT_CHARS = 10000; // ~2.5K tokens — targeted commands (about, context, smart, etc.)
118
+ const BROAD_OUTPUT_CHARS = 3000; // ~750 tokens — broad commands where truncated listings are useless
118
119
  const MAX_OUTPUT_CHARS = 100000; // hard ceiling even with max_chars override
119
120
 
121
+ // Broad commands: output is project-wide, truncation means you need a filter, not more text
122
+ const BROAD_COMMANDS = new Set(['toc', 'entrypoints', 'diff_impact', 'affected_tests', 'deadcode', 'usages']);
123
+
120
124
  function toolResult(text, command, maxChars) {
121
125
  if (!text) return { content: [{ type: 'text', text: '(no output)' }] };
122
- const limit = Math.min(maxChars || DEFAULT_OUTPUT_CHARS, MAX_OUTPUT_CHARS);
126
+ const defaultLimit = BROAD_COMMANDS.has(command) ? BROAD_OUTPUT_CHARS : DEFAULT_OUTPUT_CHARS;
127
+ const limit = Math.min(maxChars || defaultLimit, MAX_OUTPUT_CHARS);
123
128
  if (text.length > limit) {
124
129
  const fullSize = text.length;
125
130
  const fullTokens = Math.round(fullSize / 4);
@@ -128,14 +133,15 @@ function toolResult(text, command, maxChars) {
128
133
  const lastNewline = truncated.lastIndexOf('\n');
129
134
  const cleanCut = lastNewline > limit * 0.8 ? truncated.substring(0, lastNewline) : truncated;
130
135
  // Command-specific narrowing hints
131
- let narrow = 'Use file=/in=/exclude= to narrow scope.';
132
- if (command === 'toc') {
133
- narrow = 'Use in= to scope to a subdirectory, or detailed=false for compact view.';
134
- } else if (command === 'diff_impact') {
135
- narrow = 'Use file= to scope to specific files/directories.';
136
- } else if (command === 'affected_tests') {
137
- narrow = 'Use file= to scope, exclude= to skip patterns.';
138
- }
136
+ const hints = {
137
+ toc: 'Use in= to scope to a subdirectory, or detailed=false for compact view.',
138
+ entrypoints: 'Use framework= to filter by framework, exclude= to skip patterns.',
139
+ diff_impact: 'Use file= to scope to specific files/directories.',
140
+ affected_tests: 'Use file= to scope, exclude= to skip patterns.',
141
+ deadcode: 'Use file= to scope, exclude= to skip patterns.',
142
+ usages: 'Use file= to scope to specific files.',
143
+ };
144
+ const narrow = hints[command] || 'Use file=/in=/exclude= to narrow scope.';
139
145
  return { content: [{ type: 'text', text: cleanCut + `\n\n... OUTPUT TRUNCATED: showing ${limit} of ${fullSize} chars. Full output would be ~${fullTokens} tokens. ${narrow} Or use all=true to see everything (warning: ~${fullTokens} tokens).` }] };
140
146
  }
141
147
  return { content: [{ type: 'text', text }] };
@@ -285,7 +291,7 @@ server.registerTool(
285
291
  class_name: z.string().optional().describe('Class name to scope method analysis (e.g. "MarketDataFetcher" for close)'),
286
292
  limit: z.number().optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, toc --detailed.'),
287
293
  max_files: z.number().optional().describe('Max files to index (default: 10000). Use for very large codebases.'),
288
- max_chars: z.number().optional().describe('Max output chars before truncation (default: 30000 ~7.5K tokens, max: 100000 ~25K tokens). Use all=true to bypass all caps, or set this for fine-grained control. Truncation message shows full size.'),
294
+ max_chars: z.number().optional().describe('Max output chars before truncation. Targeted commands (about, context, smart, etc.): 10K default. Broad commands (toc, entrypoints, deadcode, etc.): 3K default. Max: 100K. Use all=true to bypass all caps.'),
289
295
  // Structural search flags (search command)
290
296
  type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type. Triggers index-based search.'),
291
297
  param: z.string().optional().describe('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
@@ -308,9 +314,10 @@ server.registerTool(
308
314
  // all=true bypasses both formatter caps AND char truncation (parity with CLI --all)
309
315
  const maxChars = ep.all ? MAX_OUTPUT_CHARS : ep.maxChars;
310
316
 
311
- // Wrap toolResult to auto-inject maxChars from this request
312
- const tr = (text, cmd) => toolResult(text, cmd, maxChars);
317
+ // Wrap toolResult to auto-inject command + maxChars from this request
318
+ const tr = (text) => toolResult(text, command, maxChars);
313
319
 
320
+ let index = null; // Track for post-command cache save
314
321
  try {
315
322
  switch (command) {
316
323
 
@@ -321,7 +328,7 @@ server.registerTool(
321
328
  // ── Commands using shared executor ─────────────────────────
322
329
 
323
330
  case 'about': {
324
- const index = getIndex(project_dir, ep);
331
+ index = getIndex(project_dir, ep);
325
332
  const { ok, result, error } = execute(index, 'about', ep);
326
333
  if (!ok) return tr(error); // soft error — won't kill sibling calls
327
334
  return tr(output.formatAbout(result, {
@@ -332,7 +339,7 @@ server.registerTool(
332
339
  }
333
340
 
334
341
  case 'context': {
335
- const index = getIndex(project_dir, ep);
342
+ index = getIndex(project_dir, ep);
336
343
  const { ok, result: ctx, error } = execute(index, 'context', ep);
337
344
  if (!ok) return tr(error); // context uses soft error (not toolError)
338
345
  const { text, expandable } = output.formatContext(ctx, {
@@ -344,14 +351,14 @@ server.registerTool(
344
351
  }
345
352
 
346
353
  case 'impact': {
347
- const index = getIndex(project_dir, ep);
354
+ index = getIndex(project_dir, ep);
348
355
  const { ok, result, error } = execute(index, 'impact', ep);
349
356
  if (!ok) return tr(error); // soft error
350
357
  return tr(output.formatImpact(result));
351
358
  }
352
359
 
353
360
  case 'blast': {
354
- const index = getIndex(project_dir, ep);
361
+ index = getIndex(project_dir, ep);
355
362
  const { ok, result, error } = execute(index, 'blast', ep);
356
363
  if (!ok) return tr(error); // soft error
357
364
  return tr(output.formatBlast(result, {
@@ -360,14 +367,14 @@ server.registerTool(
360
367
  }
361
368
 
362
369
  case 'smart': {
363
- const index = getIndex(project_dir, ep);
370
+ index = getIndex(project_dir, ep);
364
371
  const { ok, result, error } = execute(index, 'smart', ep);
365
372
  if (!ok) return tr(error); // soft error
366
373
  return tr(output.formatSmart(result));
367
374
  }
368
375
 
369
376
  case 'trace': {
370
- const index = getIndex(project_dir, ep);
377
+ index = getIndex(project_dir, ep);
371
378
  const { ok, result, error } = execute(index, 'trace', ep);
372
379
  if (!ok) return tr(error); // soft error
373
380
  return tr(output.formatTrace(result, {
@@ -377,7 +384,7 @@ server.registerTool(
377
384
  }
378
385
 
379
386
  case 'reverse_trace': {
380
- const index = getIndex(project_dir, ep);
387
+ index = getIndex(project_dir, ep);
381
388
  const { ok, result, error } = execute(index, 'reverseTrace', ep);
382
389
  if (!ok) return tr(error);
383
390
  return tr(output.formatReverseTrace(result, {
@@ -386,7 +393,7 @@ server.registerTool(
386
393
  }
387
394
 
388
395
  case 'example': {
389
- const index = getIndex(project_dir, ep);
396
+ index = getIndex(project_dir, ep);
390
397
  const { ok, result, error } = execute(index, 'example', ep);
391
398
  if (!ok) return tr(error);
392
399
  if (!result) return tr(`No usage examples found for "${ep.name}".`);
@@ -394,7 +401,7 @@ server.registerTool(
394
401
  }
395
402
 
396
403
  case 'related': {
397
- const index = getIndex(project_dir, ep);
404
+ index = getIndex(project_dir, ep);
398
405
  const { ok, result, error } = execute(index, 'related', ep);
399
406
  if (!ok) return tr(error);
400
407
  if (!result) return tr(`Symbol "${ep.name}" not found.`);
@@ -407,7 +414,7 @@ server.registerTool(
407
414
  // ── Finding Code ────────────────────────────────────────────
408
415
 
409
416
  case 'find': {
410
- const index = getIndex(project_dir, ep);
417
+ index = getIndex(project_dir, ep);
411
418
  const { ok, result, error, note } = execute(index, 'find', ep);
412
419
  if (!ok) return tr(error); // soft error
413
420
  let text = output.formatFind(result, ep.name, ep.top);
@@ -416,7 +423,7 @@ server.registerTool(
416
423
  }
417
424
 
418
425
  case 'usages': {
419
- const index = getIndex(project_dir, ep);
426
+ index = getIndex(project_dir, ep);
420
427
  const { ok, result, error, note } = execute(index, 'usages', ep);
421
428
  if (!ok) return tr(error); // soft error
422
429
  let text = output.formatUsages(result, ep.name);
@@ -425,18 +432,18 @@ server.registerTool(
425
432
  }
426
433
 
427
434
  case 'toc': {
428
- const index = getIndex(project_dir, ep);
435
+ index = getIndex(project_dir, ep);
429
436
  const { ok, result, error, note } = execute(index, 'toc', ep);
430
437
  if (!ok) return tr(error); // soft error
431
438
  let text = output.formatToc(result, {
432
439
  topHint: 'Set top=N or use detailed=false for compact view.'
433
440
  });
434
441
  if (note) text += '\n\n' + note;
435
- return tr(text, 'toc');
442
+ return tr(text);
436
443
  }
437
444
 
438
445
  case 'search': {
439
- const index = getIndex(project_dir, ep);
446
+ index = getIndex(project_dir, ep);
440
447
  const { ok, result, error, structural } = execute(index, 'search', ep);
441
448
  if (!ok) return tr(error); // soft error
442
449
  if (structural) {
@@ -446,21 +453,21 @@ server.registerTool(
446
453
  }
447
454
 
448
455
  case 'tests': {
449
- const index = getIndex(project_dir, ep);
456
+ index = getIndex(project_dir, ep);
450
457
  const { ok, result, error } = execute(index, 'tests', ep);
451
458
  if (!ok) return tr(error); // soft error
452
459
  return tr(output.formatTests(result, ep.name));
453
460
  }
454
461
 
455
462
  case 'affected_tests': {
456
- const index = getIndex(project_dir, ep);
463
+ index = getIndex(project_dir, ep);
457
464
  const { ok, result, error } = execute(index, 'affectedTests', ep);
458
465
  if (!ok) return tr(error);
459
- return tr(output.formatAffectedTests(result, { all: ep.all }), 'affected_tests');
466
+ return tr(output.formatAffectedTests(result, { all: ep.all }));
460
467
  }
461
468
 
462
469
  case 'deadcode': {
463
- const index = getIndex(project_dir, ep);
470
+ index = getIndex(project_dir, ep);
464
471
  const { ok, result, error, note } = execute(index, 'deadcode', ep);
465
472
  if (!ok) return tr(error); // soft error
466
473
  const dcNote = note;
@@ -474,7 +481,7 @@ server.registerTool(
474
481
  }
475
482
 
476
483
  case 'entrypoints': {
477
- const index = getIndex(project_dir, ep);
484
+ index = getIndex(project_dir, ep);
478
485
  const { ok, result, error } = execute(index, 'entrypoints', ep);
479
486
  if (!ok) return tr(error);
480
487
  return tr(output.formatEntrypoints(result));
@@ -483,28 +490,28 @@ server.registerTool(
483
490
  // ── File Dependencies ───────────────────────────────────────
484
491
 
485
492
  case 'imports': {
486
- const index = getIndex(project_dir, ep);
493
+ index = getIndex(project_dir, ep);
487
494
  const { ok, result, error } = execute(index, 'imports', ep);
488
495
  if (!ok) return tr(error); // soft error
489
496
  return tr(output.formatImports(result, ep.file));
490
497
  }
491
498
 
492
499
  case 'exporters': {
493
- const index = getIndex(project_dir, ep);
500
+ index = getIndex(project_dir, ep);
494
501
  const { ok, result, error } = execute(index, 'exporters', ep);
495
502
  if (!ok) return tr(error); // soft error
496
503
  return tr(output.formatExporters(result, ep.file));
497
504
  }
498
505
 
499
506
  case 'file_exports': {
500
- const index = getIndex(project_dir, ep);
507
+ index = getIndex(project_dir, ep);
501
508
  const { ok, result, error } = execute(index, 'fileExports', ep);
502
509
  if (!ok) return tr(error); // soft error
503
510
  return tr(output.formatFileExports(result, ep.file));
504
511
  }
505
512
 
506
513
  case 'graph': {
507
- const index = getIndex(project_dir, ep);
514
+ index = getIndex(project_dir, ep);
508
515
  const { ok, result, error } = execute(index, 'graph', ep);
509
516
  if (!ok) return tr(error); // soft error
510
517
  return tr(output.formatGraph(result, {
@@ -516,7 +523,7 @@ server.registerTool(
516
523
  }
517
524
 
518
525
  case 'circular_deps': {
519
- const index = getIndex(project_dir, ep);
526
+ index = getIndex(project_dir, ep);
520
527
  const { ok, result, error } = execute(index, 'circularDeps', ep);
521
528
  if (!ok) return tr(error);
522
529
  return tr(output.formatCircularDeps(result));
@@ -525,44 +532,44 @@ server.registerTool(
525
532
  // ── Refactoring ─────────────────────────────────────────────
526
533
 
527
534
  case 'verify': {
528
- const index = getIndex(project_dir, ep);
535
+ index = getIndex(project_dir, ep);
529
536
  const { ok, result, error } = execute(index, 'verify', ep);
530
537
  if (!ok) return tr(error); // soft error
531
538
  return tr(output.formatVerify(result));
532
539
  }
533
540
 
534
541
  case 'plan': {
535
- const index = getIndex(project_dir, ep);
542
+ index = getIndex(project_dir, ep);
536
543
  const { ok, result, error } = execute(index, 'plan', ep);
537
544
  if (!ok) return tr(error); // soft error
538
545
  return tr(output.formatPlan(result));
539
546
  }
540
547
 
541
548
  case 'diff_impact': {
542
- const index = getIndex(project_dir, ep);
549
+ index = getIndex(project_dir, ep);
543
550
  const { ok, result, error } = execute(index, 'diffImpact', ep);
544
551
  if (!ok) return tr(error); // soft error — e.g. "not a git repo"
545
- return tr(output.formatDiffImpact(result, { all: ep.all }), 'diff_impact');
552
+ return tr(output.formatDiffImpact(result, { all: ep.all }));
546
553
  }
547
554
 
548
555
  // ── Other ───────────────────────────────────────────────────
549
556
 
550
557
  case 'typedef': {
551
- const index = getIndex(project_dir, ep);
558
+ index = getIndex(project_dir, ep);
552
559
  const { ok, result, error } = execute(index, 'typedef', ep);
553
560
  if (!ok) return tr(error); // soft error
554
561
  return tr(output.formatTypedef(result, ep.name));
555
562
  }
556
563
 
557
564
  case 'stacktrace': {
558
- const index = getIndex(project_dir, ep);
565
+ index = getIndex(project_dir, ep);
559
566
  const { ok, result, error } = execute(index, 'stacktrace', ep);
560
567
  if (!ok) return tr(error); // soft error
561
568
  return tr(output.formatStackTrace(result));
562
569
  }
563
570
 
564
571
  case 'api': {
565
- const index = getIndex(project_dir, ep);
572
+ index = getIndex(project_dir, ep);
566
573
  const { ok, result, error, note } = execute(index, 'api', ep);
567
574
  if (!ok) return tr(error); // soft error
568
575
  let apiText = output.formatApi(result, ep.file || '.');
@@ -571,7 +578,7 @@ server.registerTool(
571
578
  }
572
579
 
573
580
  case 'stats': {
574
- const index = getIndex(project_dir, ep);
581
+ index = getIndex(project_dir, ep);
575
582
  const { ok, result, error } = execute(index, 'stats', ep);
576
583
  if (!ok) return tr(error); // soft error
577
584
  return tr(output.formatStats(result, { top: ep.top || 0 }));
@@ -582,7 +589,7 @@ server.registerTool(
582
589
  case 'fn': {
583
590
  const err = requireName(ep.name);
584
591
  if (err) return err;
585
- const index = getIndex(project_dir, ep);
592
+ index = getIndex(project_dir, ep);
586
593
  const { ok, result, error } = execute(index, 'fn', ep);
587
594
  if (!ok) return tr(error); // soft error
588
595
  // MCP path security: validate all result files are within project root
@@ -600,7 +607,7 @@ server.registerTool(
600
607
  if (ep.maxLines !== undefined && (!Number.isInteger(ep.maxLines) || ep.maxLines < 1)) {
601
608
  return toolError(`Invalid max_lines: ${ep.maxLines}. Must be a positive integer.`);
602
609
  }
603
- const index = getIndex(project_dir, ep);
610
+ index = getIndex(project_dir, ep);
604
611
  const { ok, result, error } = execute(index, 'class', ep);
605
612
  if (!ok) return tr(error); // soft error (class not found)
606
613
  // MCP path security: validate all result files are within project root
@@ -613,7 +620,7 @@ server.registerTool(
613
620
  }
614
621
 
615
622
  case 'lines': {
616
- const index = getIndex(project_dir, ep);
623
+ index = getIndex(project_dir, ep);
617
624
  const { ok, result, error } = execute(index, 'lines', ep);
618
625
  if (!ok) return tr(error); // soft error
619
626
  // MCP path security: validate file is within project root
@@ -626,7 +633,7 @@ server.registerTool(
626
633
  if (ep.item === undefined || ep.item === null) {
627
634
  return toolError('Item number is required (e.g. item=1).');
628
635
  }
629
- const index = getIndex(project_dir, ep);
636
+ index = getIndex(project_dir, ep);
630
637
  const lookup = expandCacheInstance.lookup(index.root, ep.item);
631
638
  const { ok, result, error } = execute(index, 'expand', {
632
639
  match: lookup.match, itemNum: ep.item,
@@ -642,6 +649,15 @@ server.registerTool(
642
649
  }
643
650
  } catch (e) {
644
651
  return toolError(e.message);
652
+ } finally {
653
+ // Persist calls cache after command execution.
654
+ // getIndex() only saves after build (when callsCache is empty).
655
+ // Commands like context/about/impact populate callsCache lazily,
656
+ // so we save here to avoid re-parsing all files on every MCP session.
657
+ if (index && index.callsCacheDirty) {
658
+ try { index.saveCache(); } catch (_) { /* best-effort */ }
659
+ index.callsCacheDirty = false;
660
+ }
645
661
  }
646
662
  }
647
663
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ucn",
3
- "version": "3.8.10",
3
+ "version": "3.8.12",
4
4
  "mcpName": "io.github.mleoca/ucn",
5
5
  "description": "Code intelligence toolkit for AI agents — extract functions, trace call chains, find callers, detect dead code without reading entire files. Works as MCP server, CLI, or agent skill. Supports JS/TS, Python, Go, Rust, Java.",
6
6
  "main": "index.js",
@@ -1 +0,0 @@
1
- {"sessionId":"5e2a28ce-7f65-4369-a922-8d1d902f9d8f","pid":18086,"acquiredAt":1773363188494}