milens 0.7.0 → 0.7.1

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.
Files changed (182) hide show
  1. package/.agents/skills/adapters/SKILL.md +54 -51
  2. package/.agents/skills/analyzer/SKILL.md +16 -19
  3. package/.agents/skills/apps/SKILL.md +60 -68
  4. package/.agents/skills/docs/SKILL.md +5 -25
  5. package/.agents/skills/milens/SKILL.md +6 -6
  6. package/.agents/skills/orchestrator/SKILL.md +8 -10
  7. package/.agents/skills/parser/SKILL.md +9 -11
  8. package/.agents/skills/root/SKILL.md +25 -29
  9. package/.agents/skills/scripts/SKILL.md +13 -16
  10. package/.agents/skills/security/SKILL.md +4 -9
  11. package/.agents/skills/server/SKILL.md +28 -25
  12. package/.agents/skills/store/SKILL.md +17 -21
  13. package/.agents/skills/test/SKILL.md +14 -19
  14. package/.agents/skills/ui/SKILL.md +1 -1
  15. package/README.md +12 -3
  16. package/adapters/README.md +144 -107
  17. package/adapters/claude-code/.claude/settings.json.hooks-snippet.json +26 -0
  18. package/adapters/claude-code/.claude-plugin/plugin.json +11 -0
  19. package/adapters/claude-code/.mcp.json +9 -0
  20. package/adapters/codex/.codex/config.toml +3 -0
  21. package/adapters/copilot/.vscode/mcp.json +10 -0
  22. package/adapters/cursor/.cursor/mcp.json +9 -0
  23. package/adapters/cursor/.cursorrules +69 -9
  24. package/adapters/gemini/.gemini/settings.json +9 -0
  25. package/dist/agents-md.d.ts +0 -1
  26. package/dist/agents-md.js +2 -2
  27. package/dist/agents-md.js.map +1 -1
  28. package/dist/analyzer/config.d.ts +0 -1
  29. package/dist/analyzer/engine.d.ts +0 -1
  30. package/dist/analyzer/engine.js +42 -40
  31. package/dist/analyzer/engine.js.map +1 -1
  32. package/dist/analyzer/enrich.d.ts +0 -1
  33. package/dist/analyzer/enrich.js +13 -0
  34. package/dist/analyzer/enrich.js.map +1 -1
  35. package/dist/analyzer/resolver.d.ts +0 -1
  36. package/dist/analyzer/resolver.js +48 -4
  37. package/dist/analyzer/resolver.js.map +1 -1
  38. package/dist/analyzer/review.d.ts +2 -2
  39. package/dist/analyzer/review.js +7 -59
  40. package/dist/analyzer/review.js.map +1 -1
  41. package/dist/analyzer/risk.d.ts +20 -0
  42. package/dist/analyzer/risk.js +76 -0
  43. package/dist/analyzer/risk.js.map +1 -0
  44. package/dist/analyzer/scanner.d.ts +0 -1
  45. package/dist/analyzer/scanner.js +2 -4
  46. package/dist/analyzer/scanner.js.map +1 -1
  47. package/dist/analyzer/scope-resolver.d.ts +0 -1
  48. package/dist/analyzer/scope-resolver.js +1 -1
  49. package/dist/analyzer/scope-resolver.js.map +1 -1
  50. package/dist/build-info.d.ts +2 -0
  51. package/dist/build-info.js +4 -0
  52. package/dist/build-info.js.map +1 -0
  53. package/dist/cli.d.ts +0 -1
  54. package/dist/cli.js +142 -16
  55. package/dist/cli.js.map +1 -1
  56. package/dist/metrics.d.ts +0 -1
  57. package/dist/orchestrator/orchestrator.d.ts +0 -1
  58. package/dist/orchestrator/orchestrator.js +16 -1
  59. package/dist/orchestrator/orchestrator.js.map +1 -1
  60. package/dist/orchestrator/reporter.d.ts +0 -1
  61. package/dist/parser/extract.d.ts +0 -1
  62. package/dist/parser/extract.js +53 -28
  63. package/dist/parser/extract.js.map +1 -1
  64. package/dist/parser/lang-css.d.ts +0 -1
  65. package/dist/parser/lang-go.d.ts +0 -1
  66. package/dist/parser/lang-html.d.ts +0 -1
  67. package/dist/parser/lang-java.d.ts +0 -1
  68. package/dist/parser/lang-js.d.ts +0 -1
  69. package/dist/parser/lang-md.d.ts +0 -1
  70. package/dist/parser/lang-php.d.ts +0 -1
  71. package/dist/parser/lang-py.d.ts +0 -1
  72. package/dist/parser/lang-ruby.d.ts +0 -1
  73. package/dist/parser/lang-rust.d.ts +0 -1
  74. package/dist/parser/lang-ts.d.ts +0 -1
  75. package/dist/parser/lang-vue.d.ts +0 -1
  76. package/dist/parser/language-provider.d.ts +0 -1
  77. package/dist/parser/languages.d.ts +0 -1
  78. package/dist/parser/loader.d.ts +0 -1
  79. package/dist/security/deps.d.ts +0 -1
  80. package/dist/security/rules.d.ts +0 -1
  81. package/dist/security/rules.js +13 -12
  82. package/dist/security/rules.js.map +1 -1
  83. package/dist/server/hooks.d.ts +0 -1
  84. package/dist/server/mcp-prompts.d.ts +0 -1
  85. package/dist/server/mcp-prompts.js +509 -523
  86. package/dist/server/mcp-prompts.js.map +1 -1
  87. package/dist/server/mcp.d.ts +0 -1
  88. package/dist/server/mcp.js +156 -655
  89. package/dist/server/mcp.js.map +1 -1
  90. package/dist/server/test-plan.d.ts +0 -1
  91. package/dist/server/tools/deps.d.ts +28 -0
  92. package/dist/server/tools/deps.js +2 -0
  93. package/dist/server/tools/deps.js.map +1 -0
  94. package/dist/server/tools/resources.d.ts +3 -0
  95. package/dist/server/tools/resources.js +103 -0
  96. package/dist/server/tools/resources.js.map +1 -0
  97. package/dist/server/tools/security.d.ts +3 -0
  98. package/dist/server/tools/security.js +176 -0
  99. package/dist/server/tools/security.js.map +1 -0
  100. package/dist/server/tools/session.d.ts +3 -0
  101. package/dist/server/tools/session.js +140 -0
  102. package/dist/server/tools/session.js.map +1 -0
  103. package/dist/server/tools/testing.d.ts +3 -0
  104. package/dist/server/tools/testing.js +200 -0
  105. package/dist/server/tools/testing.js.map +1 -0
  106. package/dist/server/watcher.d.ts +0 -1
  107. package/dist/server/watcher.js +1 -1
  108. package/dist/server/watcher.js.map +1 -1
  109. package/dist/skills.d.ts +0 -1
  110. package/dist/skills.js +8 -2
  111. package/dist/skills.js.map +1 -1
  112. package/dist/store/annotations.d.ts +1 -1
  113. package/dist/store/annotations.js +14 -3
  114. package/dist/store/annotations.js.map +1 -1
  115. package/dist/store/confidence.d.ts +0 -1
  116. package/dist/store/confidence.js +2 -0
  117. package/dist/store/confidence.js.map +1 -1
  118. package/dist/store/db.d.ts +9 -1
  119. package/dist/store/db.js +87 -20
  120. package/dist/store/db.js.map +1 -1
  121. package/dist/store/registry.d.ts +0 -1
  122. package/dist/store/vectors.d.ts +0 -1
  123. package/dist/types.d.ts +7 -1
  124. package/dist/ui/progress.d.ts +0 -1
  125. package/dist/uninstall.d.ts +0 -1
  126. package/dist/utils.d.ts +2 -1
  127. package/dist/utils.js +0 -0
  128. package/dist/utils.js.map +1 -1
  129. package/package.json +11 -8
  130. package/dist/agents-md.d.ts.map +0 -1
  131. package/dist/analyzer/config.d.ts.map +0 -1
  132. package/dist/analyzer/engine.d.ts.map +0 -1
  133. package/dist/analyzer/enrich.d.ts.map +0 -1
  134. package/dist/analyzer/resolver.d.ts.map +0 -1
  135. package/dist/analyzer/review.d.ts.map +0 -1
  136. package/dist/analyzer/scanner.d.ts.map +0 -1
  137. package/dist/analyzer/scope-resolver.d.ts.map +0 -1
  138. package/dist/analyzer/testplan.d.ts +0 -59
  139. package/dist/analyzer/testplan.d.ts.map +0 -1
  140. package/dist/analyzer/testplan.js +0 -218
  141. package/dist/analyzer/testplan.js.map +0 -1
  142. package/dist/cli.d.ts.map +0 -1
  143. package/dist/metrics.d.ts.map +0 -1
  144. package/dist/orchestrator/orchestrator.d.ts.map +0 -1
  145. package/dist/orchestrator/reporter.d.ts.map +0 -1
  146. package/dist/parser/extract.d.ts.map +0 -1
  147. package/dist/parser/lang-css.d.ts.map +0 -1
  148. package/dist/parser/lang-go.d.ts.map +0 -1
  149. package/dist/parser/lang-html.d.ts.map +0 -1
  150. package/dist/parser/lang-java.d.ts.map +0 -1
  151. package/dist/parser/lang-js.d.ts.map +0 -1
  152. package/dist/parser/lang-md.d.ts.map +0 -1
  153. package/dist/parser/lang-php.d.ts.map +0 -1
  154. package/dist/parser/lang-py.d.ts.map +0 -1
  155. package/dist/parser/lang-ruby.d.ts.map +0 -1
  156. package/dist/parser/lang-rust.d.ts.map +0 -1
  157. package/dist/parser/lang-ts.d.ts.map +0 -1
  158. package/dist/parser/lang-vue.d.ts.map +0 -1
  159. package/dist/parser/language-provider.d.ts.map +0 -1
  160. package/dist/parser/languages.d.ts.map +0 -1
  161. package/dist/parser/loader.d.ts.map +0 -1
  162. package/dist/security/deps.d.ts.map +0 -1
  163. package/dist/security/rules.d.ts.map +0 -1
  164. package/dist/server/hooks.d.ts.map +0 -1
  165. package/dist/server/mcp-prompts.d.ts.map +0 -1
  166. package/dist/server/mcp.d.ts.map +0 -1
  167. package/dist/server/test-plan.d.ts.map +0 -1
  168. package/dist/server/watcher.d.ts.map +0 -1
  169. package/dist/skills.d.ts.map +0 -1
  170. package/dist/store/annotations.d.ts.map +0 -1
  171. package/dist/store/confidence.d.ts.map +0 -1
  172. package/dist/store/db.d.ts.map +0 -1
  173. package/dist/store/registry.d.ts.map +0 -1
  174. package/dist/store/vectors.d.ts.map +0 -1
  175. package/dist/types.d.ts.map +0 -1
  176. package/dist/ui/progress.d.ts.map +0 -1
  177. package/dist/uninstall.d.ts.map +0 -1
  178. package/dist/utils.d.ts.map +0 -1
  179. package/docs/README.md +0 -25
  180. package/docs/diagram1.svg +0 -1
  181. package/docs/diagram2.svg +0 -1
  182. package/docs/diagram3.svg +0 -1
@@ -1,12 +1,12 @@
1
- import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
3
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
4
  import { z } from 'zod';
5
5
  import { createServer } from 'node:http';
6
6
  import { randomUUID } from 'node:crypto';
7
- import { resolve, relative, join, dirname, basename } from 'node:path';
7
+ import { resolve, relative, join, dirname } from 'node:path';
8
8
  import { execFileSync } from 'node:child_process';
9
- import { readFileSync, readdirSync, statSync, mkdirSync, existsSync, writeFileSync } from 'node:fs';
9
+ import { readFileSync, readdirSync, statSync, mkdirSync } from 'node:fs';
10
10
  import { homedir } from 'node:os';
11
11
  import ignore from 'ignore';
12
12
  import { Database } from '../store/db.js';
@@ -14,14 +14,18 @@ import { RepoRegistry } from '../store/registry.js';
14
14
  import { getParser, loadLanguage } from '../parser/loader.js';
15
15
  import { ALL_LANGS } from '../parser/languages.js';
16
16
  import { fileURLToPath } from 'node:url';
17
- import { generateTestPlan } from './test-plan.js';
18
17
  import { AnnotationStore } from '../store/annotations.js';
19
18
  import { registerAllPrompts } from './mcp-prompts.js';
20
- import { loadRules } from '../security/rules.js';
21
- import { HookManager, defaultOnSessionStart, defaultOnSessionEnd, defaultOnPreCommit, defaultOnFileChange, defaultOnPreCompact, defaultOnPostCompact } from './hooks.js';
22
19
  import { Orchestrator } from '../orchestrator/orchestrator.js';
20
+ import { registerResources } from './tools/resources.js';
21
+ import { countDependentFiles } from '../analyzer/risk.js';
22
+ import { registerSessionTools } from './tools/session.js';
23
+ import { registerTestingTools } from './tools/testing.js';
24
+ import { registerSecurityTools } from './tools/security.js';
23
25
  import { FileWatcher } from './watcher.js';
24
26
  import { reviewPr } from '../analyzer/review.js';
27
+ import { globToRegex } from '../utils.js';
28
+ import { BUILD_SHA, BUILT_AT } from '../build-info.js';
25
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
30
  const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
27
31
  // ── Lazy DB connection with idle eviction ──
@@ -147,7 +151,10 @@ class SessionGuard {
147
151
  }
148
152
  }
149
153
  // ── Tool usage tracking ──
150
- // Estimated tokens an agent would spend WITHOUT milens (manual exploration cost per tool)
154
+ // Hand-picked heuristic estimates of tokens an agent would spend WITHOUT milens
155
+ // (manual exploration cost per tool). Not derived from measurement — these feed
156
+ // the "tokens saved" numbers in usage tracking/dashboards, so treat them as a
157
+ // rough directional signal, not a validated benchmark.
151
158
  const TOKEN_SAVINGS_MULTIPLIER = {
152
159
  query: 3, // vs 3+ separate grep/file reads
153
160
  grep: 2, // vs terminal grep + manual filtering
@@ -328,7 +335,11 @@ function matchesScope(lineText, scope) {
328
335
  // definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
329
336
  return /^(export\s+)?(async\s+)?(function|class|interface|type|enum|struct|trait|const|let|var|def|fn|pub\s+fn|pub\s+struct|pub\s+enum|module)\s/.test(trimmed);
330
337
  }
331
- /** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
338
+ /**
339
+ * Validate user-supplied regex is safe from catastrophic backtracking (ReDoS).
340
+ * This is a heuristic blocklist of known-dangerous constructs, not a formal
341
+ * proof of safety — it catches common patterns but isn't exhaustive.
342
+ */
332
343
  function safeRegex(pattern, flags) {
333
344
  if (pattern.length > 200)
334
345
  throw new Error('Pattern too long');
@@ -355,14 +366,6 @@ function safeRegex(pattern, flags) {
355
366
  throw new Error('Unsafe regex pattern');
356
367
  return new RegExp(pattern, flags);
357
368
  }
358
- function globToRegex(glob) {
359
- const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
360
- .replace(/\*\*/g, '§STARSTAR§')
361
- .replace(/\*/g, '[^/]*')
362
- .replace(/§STARSTAR§/g, '.*')
363
- .replace(/\?/g, '.');
364
- return new RegExp(`^${escaped}$`, 'i');
365
- }
366
369
  function loadGrepIgnoreRules(rootPath) {
367
370
  const ig = ignore();
368
371
  ig.add(['node_modules', 'dist', 'build', '.git', '__pycache__', 'vendor', 'target']);
@@ -389,7 +392,7 @@ RULE: Before opening ANY file to understand code, call the appropriate milens to
389
392
 
390
393
  AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
391
394
 
392
- TOKEN SAVINGS: Using milens first = 70% fewer tokens, zero missed dependencies.
395
+ TOKEN SAVINGS: Using milens first typically means far fewer tokens than manual exploration, and fewer missed dependencies — impact/context track code-level references; pair with grep for templates/configs/docs.
393
396
 
394
397
  milens — code intelligence engine. Indexes codebases into symbol graphs.
395
398
 
@@ -438,6 +441,9 @@ export function createMcpServer(rootPath) {
438
441
  const toolCallCounts = new Map(); // In-memory counter per repo
439
442
  const trackDb = getTrackingDb();
440
443
  const guard = new SessionGuard();
444
+ // On Windows, the same path can arrive as "c:\..." or "C:\..." depending on the
445
+ // caller — without normalizing the drive letter, those resolve to different
446
+ // registry keys and split one repo's index across two entries.
441
447
  function normalizePath(p) {
442
448
  const abs = resolve(p);
443
449
  if (process.platform === 'win32') {
@@ -487,7 +493,11 @@ export function createMcpServer(rootPath) {
487
493
  return toolCallCounts.get(root) ?? 0;
488
494
  }
489
495
  const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
490
- // Auto-wrap every tool handler with usage tracking + background decay tick
496
+ // Auto-wrap every tool handler with usage tracking + background decay tick.
497
+ // This replaces `server.tool` itself, so every `server.tool(...)` call below —
498
+ // including the ones inside registerTestingTools/registerSessionTools/
499
+ // registerSecurityTools/registerResources in other files, which all receive
500
+ // this same `server` instance — goes through this wrapper too.
491
501
  const origTool = server.tool.bind(server);
492
502
  let lastDecayTick = 0;
493
503
  const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
@@ -521,13 +531,15 @@ export function createMcpServer(rootPath) {
521
531
  }
522
532
  return origTool(...args);
523
533
  });
524
- // ── Selective tool profiles (W4) ──
534
+ // ── Selective tool profiles ──
525
535
  const profile = process.env.MILENS_PROFILE || undefined;
526
536
  if (profile && profile !== 'full') {
527
537
  const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
528
538
  const standard = new Set([...minimal, 'domains', 'repos', 'explain_relationship', 'find_dead_code', 'get_type_hierarchy', 'trace', 'routes', 'smart_context', 'review_pr', 'review_symbol', 'test_coverage_gaps', 'test_plan', 'test_impact', 'session_start', 'recall']);
529
539
  const allowed = profile === 'minimal' ? minimal : standard;
530
- // Wrap server.tool again to gate by profile
540
+ // Wrap the tracking-wrapped server.tool again, so profile gating sits on
541
+ // top: disabled tools still get registered (via the no-op handler below)
542
+ // and still get tracked, they just short-circuit to the disabled message.
531
543
  const profileWrappedTool = server.tool.bind(server);
532
544
  server.tool = ((...args) => {
533
545
  const toolName = args[0];
@@ -673,7 +685,8 @@ export function createMcpServer(rootPath) {
673
685
  lines.push(`No ${direction} deps found.`);
674
686
  }
675
687
  else {
676
- const depth1Count = refs.filter(r => r.depth === 1).length;
688
+ // Dedupe depth-1 by calling file same real dependent, same blast radius
689
+ const depth1Count = countDependentFiles(db, sym.id).count;
677
690
  lines.push(`${direction} (${refs.length} symbols, depth-1: ${depth1Count}):`);
678
691
  lines.push(fmtImpact(refs, detail));
679
692
  // Strict mode: hard stop if depth-1 dependents > 5
@@ -698,7 +711,7 @@ export function createMcpServer(rootPath) {
698
711
  const stats = lazy.getCachedStats();
699
712
  const unresolved = db.getUnresolvedStats();
700
713
  const coverage = db.getTestCoverage();
701
- let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
714
+ let text = `repo: ${root}\nbuild: ${BUILD_SHA} (built: ${BUILT_AT})\nversion: ${PKG_VERSION}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
702
715
  if (unresolved.imports > 0 || unresolved.calls > 0) {
703
716
  text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
704
717
  }
@@ -719,13 +732,13 @@ export function createMcpServer(rootPath) {
719
732
  if (staleFiles.length > 0) {
720
733
  text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
721
734
  }
722
- // Accuracy report — confidence distribution of resolved links
735
+ // Resolution confidence distribution (heuristic self-ratings, not validated accuracy)
723
736
  const conf = db.getConfidenceDistribution();
724
737
  if (conf.total > 0) {
725
738
  const highPct = Math.round(conf.high / conf.total * 100);
726
739
  const medPct = Math.round(conf.medium / conf.total * 100);
727
740
  const lowPct = Math.round(conf.low / conf.total * 100);
728
- text += `\naccuracy: ${conf.total} links — ≥0.9: ${conf.high} (${highPct}%) | 0.7-0.9: ${conf.medium} (${medPct}%) | <0.7: ${conf.low} (${lowPct}%)`;
741
+ text += `\nresolution confidence: ${conf.total} links — ≥0.9: ${conf.high} (${highPct}%) | 0.7-0.9: ${conf.medium} (${medPct}%) | <0.7: ${conf.low} (${lowPct}%)`;
729
742
  if (lowPct > 15) {
730
743
  text += `\n⚠ ${lowPct}% low-confidence links — consider re-analyzing with \`--force\` or reviewing unresolved calls`;
731
744
  }
@@ -755,7 +768,7 @@ export function createMcpServer(rootPath) {
755
768
  return { content: [{ type: 'text', text: lines.join('\n') }] };
756
769
  });
757
770
  // ── Tool: overview ──
758
- server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file. Saves 70% tokens vs reading files individually. Preferred before editing/deleting/renaming a symbol.', {
771
+ server.tool('overview', 'ONE call replaces 3-5 file reads. Combined context + impact + grep. Use BEFORE reading any source file. Typically far fewer tokens than reading files individually. Preferred before editing/deleting/renaming a symbol.', {
759
772
  name: z.string().describe('Symbol name'),
760
773
  repo: z.string().optional(),
761
774
  depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
@@ -855,7 +868,8 @@ export function createMcpServer(rootPath) {
855
868
  lines.push(` domains: ${summary.domains.join(', ')}`);
856
869
  }
857
870
  if (summary.staleCount > 0) {
858
- lines.push(` ⏳ ${summary.staleCount} stale files`);
871
+ const stalePct = summary.files > 0 ? Math.round(summary.staleCount / summary.files * 100) : 0;
872
+ lines.push(` ⏳ ${summary.staleCount} stale files (>24h)${stalePct > 20 ? ' — ⚠ run `milens analyze` to refresh' : ''}`);
859
873
  }
860
874
  if (!pools.has(entry.rootPath))
861
875
  tempDb.close();
@@ -874,6 +888,9 @@ export function createMcpServer(rootPath) {
874
888
  repo: z.string().optional(),
875
889
  }, async ({ ref, repo }) => {
876
890
  const { db, root } = getDb(repo);
891
+ // `ref` is passed straight to `git diff` below. Restrict it to characters
892
+ // valid in a git ref so it can't be crafted as a flag (e.g. a leading "-")
893
+ // and get interpreted as a git option instead of a revision.
877
894
  if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
878
895
  return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
879
896
  }
@@ -940,11 +957,11 @@ export function createMcpServer(rootPath) {
940
957
  const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
941
958
  lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
942
959
  for (const sym of displaySyms) {
943
- const upstream = db.findUpstream(sym.id, 1);
960
+ const depsCount = countDependentFiles(db, sym.id).count;
944
961
  totalChanged++;
945
- if (upstream.length > 0) {
946
- lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${upstream.length} direct dependents`);
947
- totalAffected += upstream.length;
962
+ if (depsCount > 0) {
963
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${depsCount} direct dependents`);
964
+ totalAffected += depsCount;
948
965
  }
949
966
  else {
950
967
  lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
@@ -962,11 +979,19 @@ export function createMcpServer(rootPath) {
962
979
  repo: z.string().optional(),
963
980
  }, async ({ from, to, repo }) => {
964
981
  const { db } = getDb(repo);
982
+ const fromSyms = db.findSymbolByName(from);
983
+ const toSyms = db.findSymbolByName(to);
984
+ if (fromSyms.length === 0) {
985
+ return { content: [{ type: 'text', text: `Symbol "${from}" not found in index. Try \`grep\`.` }] };
986
+ }
987
+ if (toSyms.length === 0) {
988
+ return { content: [{ type: 'text', text: `Symbol "${to}" not found in index. Try \`grep\`.` }] };
989
+ }
965
990
  const path = db.findPath(from, to);
966
991
  if (!path) {
967
992
  return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
968
993
  }
969
- const fromSym = db.findSymbolByName(from)[0];
994
+ const fromSym = fromSyms[0];
970
995
  const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
971
996
  for (const { symbol, depth, via } of path) {
972
997
  lines.push(` ${'→'.repeat(depth)} [${via}] ${fmtSymbol(symbol)}`);
@@ -981,12 +1006,24 @@ export function createMcpServer(rootPath) {
981
1006
  }, async ({ kind, limit, repo }) => {
982
1007
  const { db } = getDb(repo);
983
1008
  const dead = db.findDeadCode(kind, limit);
984
- if (dead.length === 0) {
1009
+ const testOnly = db.findTestOnlyReferenced(limit);
1010
+ const lines = [];
1011
+ if (dead.length === 0 && testOnly.length === 0) {
985
1012
  return { content: [{ type: 'text', text: 'No unreferenced exported symbols found.' }] };
986
1013
  }
987
- const lines = [`${dead.length} unreferenced exported symbols (code-level only — verify with grep before removing):\n`];
988
- for (const sym of dead) {
989
- lines.push(fmtSymbol(sym));
1014
+ if (dead.length > 0) {
1015
+ lines.push(`${dead.length} unreferenced exported symbols (code-level only verify with grep before removing):\n`);
1016
+ for (const sym of dead) {
1017
+ lines.push(fmtSymbol(sym));
1018
+ }
1019
+ }
1020
+ if (testOnly.length > 0) {
1021
+ if (dead.length > 0)
1022
+ lines.push('');
1023
+ lines.push(`─── Symbols referenced only by test files (likely orphaned) — ${testOnly.length} found:\n`);
1024
+ for (const sym of testOnly) {
1025
+ lines.push(fmtSymbol(sym));
1026
+ }
990
1027
  }
991
1028
  return { content: [{ type: 'text', text: lines.join('\n') }] };
992
1029
  });
@@ -1161,10 +1198,7 @@ export function createMcpServer(rootPath) {
1161
1198
  for (const sym of symbols) {
1162
1199
  sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
1163
1200
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1164
- const depsCount = incoming.filter(l => {
1165
- const from = db.findSymbolById(l.fromId);
1166
- return from && !isTestFilePath(from.filePath);
1167
- }).length;
1201
+ const depsCount = countDependentFiles(db, sym.id, { excludeTestFiles: true }).count;
1168
1202
  if (session_id)
1169
1203
  guard.recordCheck(session_id, name);
1170
1204
  // Hard stop: if > 5 non-test dependents and no confirmation
@@ -1254,7 +1288,7 @@ export function createMcpServer(rootPath) {
1254
1288
  const traces = db.traceToEntrypoints(sym.id, depth);
1255
1289
  sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
1256
1290
  if (traces.length === 0) {
1257
- sections.push('No call chains found (symbol may be an entrypoint itself or unreachable).');
1291
+ sections.push('No call chain found — verify with `context()` before treating this symbol as dead code.');
1258
1292
  }
1259
1293
  else {
1260
1294
  for (let i = 0; i < traces.length; i++) {
@@ -1300,7 +1334,9 @@ export function createMcpServer(rootPath) {
1300
1334
  }, async ({ repo, framework, limit }) => {
1301
1335
  const root = resolveRoot(repo);
1302
1336
  const { db } = getDb(repo);
1303
- // Route patterns for different frameworks
1337
+ // Route patterns for different frameworks. Best-effort, single-line regex
1338
+ // matching — route declarations split across multiple lines (e.g. a decorator
1339
+ // and its path on separate lines) will not be detected.
1304
1340
  const routePatterns = [
1305
1341
  { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
1306
1342
  { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
@@ -1610,7 +1646,7 @@ export function createMcpServer(rootPath) {
1610
1646
  return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
1611
1647
  }
1612
1648
  });
1613
- // ═══ codebase_summary ═══
1649
+ // ── Tool: codebase_summary ──
1614
1650
  server.tool('codebase_summary', '500-token project overview. Use at session start INSTEAD of reading README, exploring directory structure, or reading multiple files to understand the codebase. Returns domains, key symbols, coverage %.', { repo: z.string().optional() }, async ({ repo }) => {
1615
1651
  const { db } = getDb(repo);
1616
1652
  const summary = db.getCodebaseSummary();
@@ -1638,7 +1674,7 @@ export function createMcpServer(rootPath) {
1638
1674
  }
1639
1675
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1640
1676
  });
1641
- // ═══ review_pr ═══
1677
+ // ── Tool: review_pr ──
1642
1678
  server.tool('review_pr', 'PR risk assessment: git diff -> affected symbols with risk scores (LOW/MEDIUM/HIGH/CRITICAL).', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
1643
1679
  const { db, root } = getDb(repo);
1644
1680
  const result = reviewPr(db, root, ref);
@@ -1664,7 +1700,7 @@ export function createMcpServer(rootPath) {
1664
1700
  lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
1665
1701
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1666
1702
  });
1667
- // ═══ review_symbol ═══
1703
+ // ── Tool: review_symbol ──
1668
1704
  server.tool('review_symbol', 'Deep-dive single symbol risk: role, heat, dependents, test status, risk level.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
1669
1705
  const { db, root } = getDb(repo);
1670
1706
  const syms = db.findSymbolByName(name);
@@ -1672,9 +1708,19 @@ export function createMcpServer(rootPath) {
1672
1708
  return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1673
1709
  const lines = [];
1674
1710
  for (const sym of syms) {
1675
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1711
+ const incomingRaw = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1712
+ // Dedupe by calling file — imports + calls from the same file is 1 real dependent
1713
+ const seenCallerFiles = new Set();
1714
+ const incoming = incomingRaw.filter(l => {
1715
+ const from = db.findSymbolById(l.fromId);
1716
+ const key = from?.filePath ?? l.fromId;
1717
+ if (seenCallerFiles.has(key))
1718
+ return false;
1719
+ seenCallerFiles.add(key);
1720
+ return true;
1721
+ });
1676
1722
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1677
- const depsCount = incoming.length;
1723
+ const depsCount = countDependentFiles(db, sym.id).count;
1678
1724
  const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
1679
1725
  const outCount = outgoing.length;
1680
1726
  const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
@@ -1702,208 +1748,9 @@ export function createMcpServer(rootPath) {
1702
1748
  }
1703
1749
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1704
1750
  });
1705
- // ═══ test_coverage_gaps ═══
1706
- server.tool('test_coverage_gaps', 'Untested exported symbols sorted by risk. Prioritize writing tests for these.', { limit: z.number().optional().default(20), repo: z.string().optional() }, async ({ limit, repo }) => {
1707
- const { db } = getDb(repo);
1708
- const coverage = db.getTestCoverage();
1709
- const gaps = db.getTestCoverageGaps(limit);
1710
- const lines = [`Test Coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${coverage.exportedProductionSymbols > 0 ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0}%) from ${coverage.testFiles} test files\n`];
1711
- if (gaps.length === 0) {
1712
- lines.push('All exported symbols have test coverage!');
1713
- }
1714
- else {
1715
- lines.push(`Top ${gaps.length} untested symbols:\n`);
1716
- for (const g of gaps) {
1717
- const incoming = db.getIncomingLinks(g.id).filter(l => l.type !== 'contains');
1718
- const risk = (g.heat ?? 0) > 80 ? 'CRITICAL' : (g.heat ?? 0) > 50 ? 'HIGH' : (g.heat ?? 0) > 30 ? 'MEDIUM' : 'LOW';
1719
- lines.push(` ${g.name} [${g.kind}] ${g.filePath}:${g.startLine} — heat:${g.heat ?? 0} deps:${incoming.length} risk:${risk}`);
1720
- }
1721
- }
1722
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1723
- });
1724
- // ═══ test_impact ═══
1725
- server.tool('test_impact', 'Map changed code -> which test files to run. Use after making changes.', { ref: z.string().optional().default('HEAD'), repo: z.string().optional() }, async ({ ref, repo }) => {
1726
- const { db, root } = getDb(repo);
1727
- let changedFiles = [];
1728
- try {
1729
- const { execSync } = await import('node:child_process');
1730
- const diff = execSync(`git diff --name-only ${ref}`, { cwd: root, encoding: 'utf-8' }).trim();
1731
- changedFiles = diff ? diff.split('\n').filter(Boolean) : [];
1732
- }
1733
- catch { }
1734
- if (changedFiles.length === 0)
1735
- return { content: [{ type: 'text', text: 'No changed files.' }] };
1736
- const changedIds = [];
1737
- const changedNames = [];
1738
- for (const file of changedFiles) {
1739
- for (const sym of db.getSymbolsByFile(file)) {
1740
- changedIds.push(sym.id);
1741
- changedNames.push(sym.name);
1742
- }
1743
- }
1744
- if (changedIds.length === 0)
1745
- return { content: [{ type: 'text', text: 'No symbols in changed files.' }] };
1746
- const impact = db.getTestImpact(changedIds);
1747
- const lines = [`Changed symbols (${changedNames.length}): ${changedNames.join(', ')}`];
1748
- lines.push(`\nAffected test files (${impact.testFiles.length}):`);
1749
- for (const f of impact.testFiles)
1750
- lines.push(` ${f}`);
1751
- if (impact.testFiles.length > 0) {
1752
- lines.push(`\nSuggested command: npx vitest run ${impact.testFiles.join(' ')}`);
1753
- }
1754
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1755
- });
1756
- // ═══ test_plan ═══
1757
- server.tool('test_plan', 'Generate a test strategy for a symbol: mock plan + >=3 test scenarios.', { name: z.string(), repo: z.string().optional() }, async ({ name, repo }) => {
1758
- const { db, root } = getDb(repo);
1759
- const plan = generateTestPlan(db, name, root);
1760
- if (!plan)
1761
- return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1762
- return { content: [{ type: 'text', text: plan.planText }] };
1763
- });
1764
- // ═══ annotate ═══
1765
- server.tool('annotate', 'Record a note about a symbol for future sessions. Use after discovering bugs, patterns, or important caveats.', {
1766
- symbol: z.string(),
1767
- key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']),
1768
- value: z.string(),
1769
- agent: z.string().optional(),
1770
- session_id: z.string().optional(),
1771
- confidence: z.number().optional().default(0.5),
1772
- }, async ({ symbol, key, value, agent, session_id, confidence }) => {
1773
- const { db } = getDb();
1774
- const store = new AnnotationStore(db.connection);
1775
- const ann = store.annotate(symbol, key, value, { agent, sessionId: session_id });
1776
- return { content: [{ type: 'text', text: `Annotation saved: ${ann.id}\n symbol: ${ann.symbol}\n key: ${ann.key}\n confidence: ${ann.confidence}` }] };
1777
- });
1778
- // ═══ recall ═══
1779
- server.tool('recall', 'Retrieve annotations saved in previous sessions. Filter by symbol, key, or agent.', {
1780
- symbol: z.string().optional(), key: z.enum(['note', 'bug', 'security', 'architecture', 'workflow', 'test', 'dependency', 'refactor']).optional(),
1781
- agent: z.string().optional(), limit: z.number().optional().default(50),
1782
- }, async ({ symbol, key, agent, limit }) => {
1783
- const { db } = getDb();
1784
- const store = new AnnotationStore(db.connection);
1785
- const results = store.recall({ symbol, key, agent, limit });
1786
- if (results.length === 0)
1787
- return { content: [{ type: 'text', text: 'No annotations found.' }] };
1788
- const lines = [`${results.length} annotation(s):\n`];
1789
- for (const a of results) {
1790
- lines.push(`[${a.key}] ${a.symbol} — ${a.value.slice(0, 120)}`);
1791
- lines.push(` confidence: ${a.confidence.toFixed(1)} | agent: ${a.agent ?? '?'} | ${a.updatedAt}\n`);
1792
- }
1793
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1794
- });
1795
- // ═══ session_start ═══
1796
- server.tool('session_start', 'Start a new session. Returns a session ID to use with annotate, session_end, and handoff.', { agent: z.string().describe('Agent name (e.g. vibe-coder, reviewer)') }, async ({ agent }) => {
1797
- const { db, root, dbPath } = getDb();
1798
- const store = new AnnotationStore(db.connection);
1799
- const sessionId = store.sessionStart(agent);
1800
- let hookOutput = '';
1801
- try {
1802
- const manager = new HookManager();
1803
- const config = manager.loadConfig(root);
1804
- if (config.enabled && config.onSessionStart) {
1805
- hookOutput = await defaultOnSessionStart({ agent, sessionId, rootPath: root }, dbPath);
1806
- }
1807
- }
1808
- catch { /* hooks are best-effort */ }
1809
- const text = `Session started: ${sessionId}\nAgent: ${agent}\nUse this ID with annotate() and session_end().`;
1810
- return { content: [{ type: 'text', text: hookOutput ? `${hookOutput}\n\n${text}` : text }] };
1811
- });
1812
- // ═══ session_context ═══
1813
- server.tool('session_context', 'Get metadata about a session: annotations, tool calls, duration.', { session_id: z.string() }, async ({ session_id }) => {
1814
- const { db, root } = getDb();
1815
- const store = new AnnotationStore(db.connection);
1816
- const ctx = store.sessionContext(session_id);
1817
- if (!ctx.session)
1818
- return { content: [{ type: 'text', text: `Session "${session_id}" not found.` }] };
1819
- const s = ctx.session;
1820
- const liveCalls = getToolCallCount(root);
1821
- const displayCalls = s.toolCallsCount > 0 ? s.toolCallsCount : liveCalls;
1822
- const lines = [
1823
- `Session: ${s.id}`,
1824
- `Agent: ${s.agent} | Status: ${s.status}`,
1825
- `Started: ${s.startedAt} | Ended: ${s.endedAt ?? 'in progress'}`,
1826
- `Tool calls: ${displayCalls}${s.toolCallsCount === 0 ? ' (live count, resets on restart)' : ''} | Annotations: ${s.annotationsCount}`,
1827
- ];
1828
- if (s.context)
1829
- lines.push(`Context: ${s.context}`);
1830
- if (ctx.annotations.length > 0) {
1831
- lines.push(`\nAnnotations (${ctx.annotations.length}):`);
1832
- for (const a of ctx.annotations) {
1833
- lines.push(` [${a.key}] ${a.symbol}: ${a.value.slice(0, 80)}`);
1834
- }
1835
- }
1836
- return { content: [{ type: 'text', text: lines.join('\n') }] };
1837
- });
1838
- // ═══ session_end ═══
1839
- server.tool('session_end', 'End a session and record its stats. Shows audit trail: which symbols were safety-checked vs total edit operations. Use at the end of every session.', { session_id: z.string(), status: z.enum(['completed', 'failed']).optional().default('completed') }, async ({ session_id, status }) => {
1840
- const { db, root, dbPath } = getDb();
1841
- const store = new AnnotationStore(db.connection);
1842
- const summary = store.sessionEnd(session_id, status);
1843
- // Audit trail from SessionGuard
1844
- const audit = guard.getAudit(session_id);
1845
- guard.clear(session_id);
1846
- let hookOutput = '';
1847
- try {
1848
- const ctx = store.sessionContext(session_id);
1849
- const manager = new HookManager();
1850
- const config = manager.loadConfig(root);
1851
- if (config.enabled && config.onSessionEnd) {
1852
- hookOutput = await defaultOnSessionEnd({ agent: ctx.session.agent, sessionId: session_id, rootPath: root }, dbPath);
1853
- }
1854
- }
1855
- catch { /* hooks are best-effort */ }
1856
- const lines = [
1857
- `Session ended: ${session_id}`,
1858
- `Status: ${status}`,
1859
- `Annotations: ${summary.annotationCount}`,
1860
- `───`,
1861
- `Audit Trail:`,
1862
- ` safety checks performed: ${audit.checked.length}`,
1863
- audit.checked.length > 0 ? ` symbols checked: ${audit.checked.join(', ')}` : ' ⚠ no symbols were checked via guard_edit_check',
1864
- audit.editOps > 0 ? ` edit operations reported: ${audit.editOps}` : null,
1865
- ].filter(Boolean);
1866
- const text = lines.join('\n');
1867
- return { content: [{ type: 'text', text: hookOutput ? `${text}\n\n${hookOutput}` : text }] };
1868
- });
1869
- // ═══ handoff ═══
1870
- server.tool('handoff', 'Transfer context from one agent session to another. Ends the source session and creates a new one for the target agent.', {
1871
- from_session: z.string(), to_agent: z.string(),
1872
- context: z.string().describe('Summary of what was done, key decisions, and caveats for the next agent'),
1873
- }, async ({ from_session, to_agent, context }) => {
1874
- const { db } = getDb();
1875
- const store = new AnnotationStore(db.connection);
1876
- const result = store.handoff(from_session, to_agent, context);
1877
- return { content: [{ type: 'text', text: `Handoff complete.\nNew session: ${result.newSessionId}\nAgent: ${to_agent}\nAnnotations copied: ${result.annotationsCopied}` }] };
1878
- });
1879
- // ═══ pre_commit_check ═══
1880
- server.tool('pre_commit_check', 'Run pre-commit risk analysis: detect_changes + review_pr + dead code + coverage gaps. Use before committing.', { repo: z.string().optional().describe('Repository root path') }, async ({ repo }) => {
1881
- const { root } = getDb(repo);
1882
- const report = await defaultOnPreCommit(root);
1883
- return { content: [{ type: 'text', text: report }] };
1884
- });
1885
- // ═══ hook_onFileChange ═══
1886
- server.tool('hook_onFileChange', 'Trigger the onFileChange hook. Call this when files are modified to get impact summary.', {
1887
- files: z.array(z.string()).describe('List of changed file paths'),
1888
- repo: z.string().optional(),
1889
- }, async ({ files, repo }) => {
1890
- const { root } = getDb(repo);
1891
- const report = await defaultOnFileChange(files, root);
1892
- return { content: [{ type: 'text', text: report }] };
1893
- });
1894
- // ═══ hook_preCompact ═══
1895
- server.tool('hook_preCompact', 'Trigger pre-compaction hook. Saves a metrics snapshot before context window compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1896
- const { root, dbPath } = getDb(repo);
1897
- const report = await defaultOnPreCompact(root, dbPath);
1898
- return { content: [{ type: 'text', text: report }] };
1899
- });
1900
- // ═══ hook_postCompact ═══
1901
- server.tool('hook_postCompact', 'Trigger post-compaction hook. Recalls annotations to restore context after compaction.', { repo: z.string().optional() }, async ({ repo }) => {
1902
- const { root } = getDb(repo);
1903
- const report = await defaultOnPostCompact(root);
1904
- return { content: [{ type: 'text', text: report }] };
1905
- });
1906
- // ═══ semantic_search ═══
1751
+ registerTestingTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1752
+ registerSessionTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1753
+ // ── Tool: semantic_search ──
1907
1754
  server.tool('semantic_search', 'Search symbols by semantic meaning (falls back to FTS5 keyword search when embeddings unavailable).', { query: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ query, limit, repo }) => {
1908
1755
  const { db } = getDb(repo);
1909
1756
  const results = db.searchSymbols(query, limit);
@@ -1916,7 +1763,7 @@ export function createMcpServer(rootPath) {
1916
1763
  }
1917
1764
  return { content: [{ type: 'text', text: `No results for "${query}".\n\nTip: Run \`milens analyze --embeddings\` to enable semantic (vector) search. Currently using FTS5 keyword fallback which only matches exact symbol names.` }] };
1918
1765
  });
1919
- // ═══ find_similar ═══
1766
+ // ── Tool: find_similar ──
1920
1767
  server.tool('find_similar', 'Find symbols topologically similar to a given symbol (shared callers/callees). Useful for finding patterns to copy or refactor together.', { name: z.string(), limit: z.number().optional().default(10), repo: z.string().optional() }, async ({ name, limit, repo }) => {
1921
1768
  const { db } = getDb(repo);
1922
1769
  const syms = db.findSymbolByName(name);
@@ -1931,111 +1778,16 @@ export function createMcpServer(rootPath) {
1931
1778
  }
1932
1779
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1933
1780
  });
1934
- // ══════════════════════════════════════════════
1935
1781
  // ── MCP Resources ──
1936
- // ══════════════════════════════════════════════
1937
- // ── Resource: milens://symbol/{name} ──
1938
- server.resource('symbol', new ResourceTemplate('milens://symbol/{name}', { list: undefined }), { description: 'Symbol context: definition, incoming refs, outgoing deps, role/heat metadata' }, async (uri, { name }) => {
1939
- const { db } = getDb();
1940
- const symbols = db.findSymbolByName(name);
1941
- if (symbols.length === 0) {
1942
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `"${name}" not found.` }] };
1943
- }
1944
- const lines = [];
1945
- for (const sym of symbols) {
1946
- lines.push(`${fmtSymbol(sym, 'L2')}${sym.exported ? ' (exported)' : ''}`);
1947
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1948
- if (incoming.length > 0) {
1949
- lines.push(`incoming (${incoming.length}):`);
1950
- for (const l of incoming) {
1951
- const from = db.findSymbolById(l.fromId);
1952
- lines.push(` ${l.type}: ${from ? fmtSymbol(from) : l.fromId}`);
1953
- }
1954
- }
1955
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1956
- if (outgoing.length > 0) {
1957
- lines.push(`outgoing (${outgoing.length}):`);
1958
- for (const l of outgoing) {
1959
- const to = db.findSymbolById(l.toId);
1960
- lines.push(` ${l.type}: ${to ? fmtSymbol(to) : l.toId}`);
1961
- }
1962
- }
1963
- lines.push('');
1964
- }
1965
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1966
- });
1967
- // ── Resource: milens://file/{path} ──
1968
- server.resource('file-symbols', new ResourceTemplate('milens://file/{+path}', { list: undefined }), { description: 'All symbols in a file with ref/dep counts' }, async (uri, { path }) => {
1969
- const { db } = getDb();
1970
- const filePath = decodeURIComponent(path);
1971
- const symbols = db.getSymbolsByFile(filePath);
1972
- if (symbols.length === 0) {
1973
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `No symbols in "${filePath}".` }] };
1974
- }
1975
- const lines = [`${filePath}: ${symbols.length} symbols\n`];
1976
- for (const sym of symbols) {
1977
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1978
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1979
- const exp = sym.exported ? ' (exported)' : '';
1980
- lines.push(`${fmtSymbol(sym, 'L2')}${exp} ← ${incoming.length} refs, → ${outgoing.length} deps`);
1981
- }
1982
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1983
- });
1984
- // ── Resource: milens://domain/{name} ──
1985
- server.resource('domain', new ResourceTemplate('milens://domain/{name}', { list: undefined }), { description: 'Domain cluster details: files and top symbols in a domain' }, async (uri, { name }) => {
1986
- const { db } = getDb();
1987
- const domainName = name;
1988
- // Find files in this domain
1989
- const allFiles = db.db_getFilesByZone(domainName);
1990
- if (allFiles.length === 0) {
1991
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: `Domain "${domainName}" not found.` }] };
1992
- }
1993
- const lines = [`domain: ${domainName} (${allFiles.length} files)\n`];
1994
- let totalSymbols = 0;
1995
- for (const file of allFiles) {
1996
- const syms = db.getSymbolsByFile(file);
1997
- totalSymbols += syms.length;
1998
- const exported = syms.filter(s => s.exported);
1999
- lines.push(`${file}: ${syms.length} symbols (${exported.length} exported)`);
2000
- }
2001
- lines.push(`\ntotal: ${totalSymbols} symbols in ${allFiles.length} files`);
2002
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
2003
- });
2004
- // ── Resource: milens://overview ──
2005
- server.resource('overview', 'milens://overview', { description: 'Index overview: stats, domains, unresolved, test coverage, staleness' }, async (uri) => {
2006
- const { db, root, lazy } = getDb();
2007
- const stats = lazy.getCachedStats();
2008
- const unresolved = db.getUnresolvedStats();
2009
- const coverage = db.getTestCoverage();
2010
- const domains = lazy.getCachedDomainStats();
2011
- const staleFiles = db.getStaleFiles(24);
2012
- const lines = [
2013
- `repo: ${root}`,
2014
- `symbols: ${stats.symbols}`,
2015
- `links: ${stats.links}`,
2016
- `files: ${stats.files}`,
2017
- ];
2018
- if (unresolved.imports > 0 || unresolved.calls > 0) {
2019
- lines.push(`⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls`);
2020
- }
2021
- if (unresolved.externalImports > 0 || unresolved.externalCalls > 0) {
2022
- lines.push(`external (expected): ${unresolved.externalImports} imports, ${unresolved.externalCalls} calls`);
2023
- }
2024
- if (coverage.testFiles > 0) {
2025
- const pct = coverage.exportedProductionSymbols > 0
2026
- ? Math.round(coverage.testedSymbols / coverage.exportedProductionSymbols * 100) : 0;
2027
- lines.push(`test coverage: ${coverage.testedSymbols}/${coverage.exportedProductionSymbols} (${pct}%) from ${coverage.testFiles} test files`);
2028
- }
2029
- if (domains.length > 0) {
2030
- lines.push(`\ndomains (${domains.length}):`);
2031
- for (const d of domains) {
2032
- lines.push(` ${d.domain}: ${d.files} files, ${d.symbols} symbols`);
2033
- }
2034
- }
2035
- if (staleFiles.length > 0) {
2036
- lines.push(`\n⏳ ${staleFiles.length} stale files (>24h)`);
2037
- }
2038
- return { contents: [{ uri: uri.href, mimeType: 'text/plain', text: lines.join('\n') }] };
1782
+ registerResources(server, {
1783
+ getDb,
1784
+ fmtSymbol,
1785
+ fmtImpact,
1786
+ rootPath,
1787
+ toolCallCounts,
1788
+ resolveRoot,
1789
+ guard,
1790
+ getToolCallCount,
2039
1791
  });
2040
1792
  // ── Prompt: delete-feature ──
2041
1793
  server.prompt('delete-feature', 'Step-by-step workflow for safely deleting a feature from the codebase', { name: z.string().describe('Feature or symbol name to delete') }, ({ name }) => ({
@@ -2158,131 +1910,10 @@ export function createMcpServer(rootPath) {
2158
1910
  },
2159
1911
  }],
2160
1912
  }));
2161
- // ── Register MCP Prompts (W1) ──
1913
+ // ── Register MCP Prompts ──
2162
1914
  registerAllPrompts(server);
2163
- // ── Tool: security_scan (S2) ──
2164
- server.tool('security_scan', 'Scan codebase for security vulnerabilities using 190+ built-in rules across 25 categories. Replaces multiple manual grep() calls. Categories: secrets, injection, rce, xss, deserialization, ssrf, xxe, path-traversal, file-upload, unicode, dangerous, config, data-leak, crypto, auth, jwt, cors-headers, dependency, cloud, docker, kubernetes, iac, business-logic, api-security, misc, file-access.', {
2165
- scope: z.enum(['all', 'secrets', 'injection', 'rce', 'xss', 'deserialization', 'ssrf', 'xxe', 'path-traversal', 'file-upload', 'unicode', 'dangerous', 'config', 'data-leak', 'crypto', 'auth', 'jwt', 'cors-headers', 'dependency', 'cloud', 'docker', 'kubernetes', 'iac', 'business-logic', 'api-security', 'misc', 'file-access']).optional().default('all').describe('Scan scope'),
2166
- repo: z.string().optional().describe('Repository root path'),
2167
- severity: z.enum(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']).optional().describe('Minimum severity filter'),
2168
- limit: z.number().optional().default(50).describe('Max findings'),
2169
- }, async ({ scope, repo, severity, limit }) => {
2170
- const { db, root } = getDb(repo);
2171
- const rules = loadRules();
2172
- // Filter rules by scope and severity
2173
- const filtered = rules.filter(r => {
2174
- if (scope !== 'all' && r.category !== scope)
2175
- return false;
2176
- if (severity) {
2177
- const sevOrder = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
2178
- if ((sevOrder[r.severity] || 0) < (sevOrder[severity] || 0))
2179
- return false;
2180
- }
2181
- return r.enabled;
2182
- });
2183
- // Get all source files from the DB
2184
- const symbols = db.getAllSymbols();
2185
- const fileSet = new Set();
2186
- for (const s of symbols) {
2187
- if (s.filePath && !s.filePath.includes('node_modules') && !s.filePath.includes('.git')) {
2188
- fileSet.add(s.filePath);
2189
- }
2190
- }
2191
- const files = [...fileSet].slice(0, 1000); // cap at 1000 files
2192
- const { readFileSync: rfs, existsSync: es } = await import('node:fs');
2193
- const { resolve: resolvePath } = await import('node:path');
2194
- const findings = [];
2195
- const byCategory = {};
2196
- const bySeverity = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
2197
- for (const file of files) {
2198
- const fullPath = resolvePath(root, file);
2199
- if (!es(fullPath))
2200
- continue;
2201
- // Apply excludeGlob from each rule's exclusion pattern
2202
- let shouldExclude = false;
2203
- for (const rule of filtered) {
2204
- if (rule.excludeGlob) {
2205
- const excludePatterns = rule.excludeGlob.split(',');
2206
- for (const pattern of excludePatterns) {
2207
- const regex = new RegExp('^' + pattern.replace(/\./g, '\\.').replace(/\*/g, '.*').replace(/\*\*/g, '.*') + '$');
2208
- if (regex.test(file) || regex.test('/' + file)) {
2209
- shouldExclude = true;
2210
- break;
2211
- }
2212
- }
2213
- }
2214
- if (shouldExclude)
2215
- break;
2216
- }
2217
- if (shouldExclude)
2218
- continue;
2219
- // Skip files that don't match rule fileGlobs (simple check)
2220
- const applicableRules = filtered.filter(r => {
2221
- if (!r.fileGlob)
2222
- return true;
2223
- // Simple glob: just check extension
2224
- const ext = r.fileGlob.replace('**/*.', '').replace('**/*', '');
2225
- return file.endsWith(ext) || r.fileGlob === '**/*';
2226
- });
2227
- if (applicableRules.length === 0)
2228
- continue;
2229
- try {
2230
- const content = rfs(fullPath, 'utf-8');
2231
- const lines = content.split('\n');
2232
- for (const rule of applicableRules) {
2233
- for (const pattern of rule.patterns) {
2234
- let match;
2235
- // Reset regex lastIndex for global patterns
2236
- pattern.lastIndex = 0;
2237
- while ((match = pattern.exec(content)) !== null) {
2238
- const lineNum = content.substring(0, match.index).split('\n').length;
2239
- const ctxStart = Math.max(0, lineNum - 3);
2240
- const ctxEnd = Math.min(lines.length, lineNum + 2);
2241
- const context = lines.slice(ctxStart, ctxEnd).join('\n');
2242
- findings.push({
2243
- ruleId: rule.id,
2244
- category: rule.category,
2245
- severity: rule.severity,
2246
- owasp: rule.owasp,
2247
- file,
2248
- line: lineNum,
2249
- match: match[0].length > 100 ? match[0].slice(0, 97) + '...' : match[0],
2250
- context,
2251
- fix: rule.fix,
2252
- });
2253
- byCategory[rule.category] = (byCategory[rule.category] || 0) + 1;
2254
- bySeverity[rule.severity] = (bySeverity[rule.severity] || 0) + 1;
2255
- }
2256
- }
2257
- }
2258
- }
2259
- catch {
2260
- // Skip unreadable files
2261
- }
2262
- }
2263
- // Calculate security score (100 - deductions)
2264
- const deduction = findings.filter((f) => f.severity === 'CRITICAL').length * 5 +
2265
- findings.filter((f) => f.severity === 'HIGH').length * 2 +
2266
- findings.filter((f) => f.severity === 'MEDIUM').length * 0.5;
2267
- const score = Math.max(0, Math.round(100 - deduction));
2268
- const limited = findings.slice(0, limit);
2269
- return {
2270
- content: [{
2271
- type: 'text',
2272
- text: JSON.stringify({
2273
- summary: {
2274
- totalScanned: files.length,
2275
- findings: findings.length,
2276
- byCategory,
2277
- bySeverity,
2278
- score,
2279
- },
2280
- findings: limited,
2281
- }, null, 2),
2282
- }],
2283
- };
2284
- });
2285
- // ═══ compare_impact ═══
1915
+ registerSecurityTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1916
+ // ── Tool: compare_impact ──
2286
1917
  server.tool('compare_impact', 'Compare impact graph before/after an edit. Takes a snapshot first, then call again to see the diff. Returns new/removed dependents and heat changes.', {
2287
1918
  name: z.string().describe('Symbol name to compare'),
2288
1919
  action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
@@ -2329,88 +1960,13 @@ export function createMcpServer(rootPath) {
2329
1960
  db.close();
2330
1961
  }
2331
1962
  });
2332
- // ═══ orchestrate ═══
1963
+ // ── Tool: orchestrate ──
2333
1964
  server.tool('orchestrate', 'Run full orchestration cycle: detect_changes → review_pr → impact → coverage gaps → dead code. Returns structured action plan.', { repo: z.string().optional() }, async ({ repo }) => {
2334
1965
  const { root, dbPath } = getDb(repo);
2335
1966
  const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
2336
1967
  const report = await orchestrator.runAndFormat();
2337
1968
  return { content: [{ type: 'text', text: report }] };
2338
1969
  });
2339
- // ═══ fix_apply ═══
2340
- server.tool('fix_apply', 'Apply a security fix suggestion to a file. Creates a backup before modifying. CRITICAL rules require confirm: true.', {
2341
- ruleId: z.string().describe('Security rule ID (e.g. "hardcoded_secret")'),
2342
- file: z.string().describe('File path relative to repo root'),
2343
- line: z.number().describe('Line number where the issue was found'),
2344
- confirm: z.boolean().optional().default(false).describe('Confirmation required for CRITICAL rules'),
2345
- repo: z.string().optional(),
2346
- }, async ({ ruleId, file, line, confirm, repo }) => {
2347
- const { root } = getDb(repo);
2348
- const rules = loadRules();
2349
- const rule = rules.find(r => r.id === ruleId);
2350
- if (!rule)
2351
- return { content: [{ type: 'text', text: `Rule not found: "${ruleId}"` }] };
2352
- if (rule.severity === 'CRITICAL' && !confirm) {
2353
- return { content: [{ type: 'text', text: `CRITICAL rule "${ruleId}" requires confirmation. Set confirm: true to proceed.` }] };
2354
- }
2355
- const fullPath = resolve(root, file);
2356
- if (!existsSync(fullPath))
2357
- return { content: [{ type: 'text', text: `File not found: ${file}` }] };
2358
- const content = readFileSync(fullPath, 'utf-8');
2359
- const lines = content.split('\n');
2360
- if (line < 1 || line > lines.length)
2361
- return { content: [{ type: 'text', text: `Line ${line} out of range (file has ${lines.length} lines).` }] };
2362
- // Backup original
2363
- const backupDir = join(root, '.milens', 'backups');
2364
- mkdirSync(backupDir, { recursive: true });
2365
- const backupPath = join(backupDir, `${file.replace(/[\\/]/g, '_')}_${Date.now()}.bak`);
2366
- writeFileSync(backupPath, content, 'utf-8');
2367
- // Apply fix: add comment above the affected line with the fix suggestion
2368
- const targetLine = lines[line - 1];
2369
- const indent = targetLine.match(/^(\s*)/)?.[1] ?? '';
2370
- const fixComment = `${indent}// milens(fix): rule=${rule.id} — ${rule.fix ?? 'Review manually'}`;
2371
- lines.splice(line - 1, 0, fixComment);
2372
- const newContent = lines.join('\n');
2373
- writeFileSync(fullPath, newContent, 'utf-8');
2374
- return { content: [{ type: 'text', text: `Fix applied for rule "${ruleId}" at ${file}:${line}\nSeverity: ${rule.severity}\nBackup: ${relative(root, backupPath)}\nFix: ${rule.fix ?? 'Manual review needed'}\n\nAdded fix comment above line ${line}.` }] };
2375
- });
2376
- // ═══ test_generate ═══
2377
- server.tool('test_generate', 'Generate a test file for a symbol using its test plan. Detects test framework and follows project conventions.', {
2378
- symbol: z.string().describe('Symbol name to generate tests for'),
2379
- repo: z.string().optional(),
2380
- }, async ({ symbol, repo }) => {
2381
- const { db, root } = getDb(repo);
2382
- const plan = generateTestPlan(db, symbol, root);
2383
- if (!plan)
2384
- return { content: [{ type: 'text', text: `Symbol not found: "${symbol}"` }] };
2385
- // Detect test framework
2386
- const framework = detectTestFramework(root);
2387
- const testExt = framework === 'pytest' ? '.py' : '.test.ts';
2388
- // Determine test file path
2389
- const srcFile = plan.file;
2390
- const srcDir = dirname(srcFile);
2391
- const srcName = basename(srcFile, srcFile.includes('.') ? '.' + srcFile.split('.').pop() : '');
2392
- const testFileName = `${srcName}${testExt}`;
2393
- const testDir = join(srcDir, '__tests__');
2394
- const testPath = join(testDir, testFileName);
2395
- // Don't overwrite existing test files
2396
- if (existsSync(join(root, testPath))) {
2397
- return { content: [{ type: 'text', text: `Test file already exists at ${testPath}. Skipping to avoid overwrite.` }] };
2398
- }
2399
- // Check if a sister test file exists alongside the source
2400
- const altTestPath = join(srcDir, testFileName);
2401
- const existingTestDir = existsSync(join(root, testDir));
2402
- const altExists = existsSync(join(root, altTestPath));
2403
- if (altExists) {
2404
- return { content: [{ type: 'text', text: `Test file exists at ${altTestPath}. Skipping to avoid overwrite.` }] };
2405
- }
2406
- // Generate test code
2407
- const testCode = generateTestCode(plan, framework, srcFile);
2408
- // Write the test file
2409
- const writePath = existingTestDir ? testPath : altTestPath;
2410
- mkdirSync(dirname(join(root, writePath)), { recursive: true });
2411
- writeFileSync(join(root, writePath), testCode, 'utf-8');
2412
- return { content: [{ type: 'text', text: `Test file generated: ${writePath}\nFramework: ${framework}\nScenarios: ${plan.testScenarios.length}\nMock deps: ${plan.mockStrategy.length}` }] };
2413
- });
2414
1970
  // ── Prompt: dead_code_remove ──
2415
1971
  server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
2416
1972
  messages: [{
@@ -2435,103 +1991,6 @@ export function createMcpServer(rootPath) {
2435
1991
  }));
2436
1992
  return server;
2437
1993
  }
2438
- // ── Helpers ──
2439
- function detectTestFramework(rootPath) {
2440
- try {
2441
- const pkgPath = resolve(rootPath, 'package.json');
2442
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
2443
- const deps = { ...pkg.devDependencies, ...pkg.dependencies };
2444
- if (deps.vitest)
2445
- return 'vitest';
2446
- if (deps.jest)
2447
- return 'jest';
2448
- if (deps.mocha)
2449
- return 'mocha';
2450
- }
2451
- catch { }
2452
- // Check for Python
2453
- try {
2454
- const cfg = readFileSync(resolve(rootPath, 'pytest.ini'), 'utf-8');
2455
- return 'pytest';
2456
- }
2457
- catch { }
2458
- try {
2459
- const cfg = readFileSync(resolve(rootPath, 'setup.cfg'), 'utf-8');
2460
- if (cfg.includes('[tool:pytest]'))
2461
- return 'pytest';
2462
- }
2463
- catch { }
2464
- return 'vitest'; // default (Vitest is most common for TS projects)
2465
- }
2466
- function generateTestCode(plan, framework, srcFile) {
2467
- const lines = [];
2468
- if (framework === 'pytest') {
2469
- lines.push(`# Generated by milens — test plan for ${plan.symbol}`);
2470
- lines.push(`import pytest`);
2471
- lines.push(`from ${srcFile.replace(/[/\\]/g, '.').replace(/\.(ts|tsx|js|jsx|py)$/, '')} import ${plan.symbol}`);
2472
- lines.push('');
2473
- lines.push(`class Test${capitalize(plan.symbol)}:`);
2474
- for (const s of plan.testScenarios) {
2475
- lines.push(` def test_${s.name.toLowerCase().replace(/\s+/g, '_')}(self):`);
2476
- lines.push(` """${s.description}"""`);
2477
- lines.push(` pass # TODO: implement`);
2478
- lines.push('');
2479
- }
2480
- }
2481
- else {
2482
- const hasTypescript = srcFile.endsWith('.ts') || srcFile.endsWith('.tsx');
2483
- const ext = hasTypescript ? '.ts' : '.js';
2484
- lines.push(`// Generated by milens — test plan for ${plan.symbol}`);
2485
- if (framework === 'vitest') {
2486
- lines.push(`import { describe, it, expect${plan.mockStrategy.length > 0 ? ', vi' : ''} } from 'vitest';`);
2487
- lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2488
- }
2489
- else if (framework === 'mocha') {
2490
- lines.push(`import { expect } from 'chai';`);
2491
- lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2492
- }
2493
- else {
2494
- lines.push(`import { ${plan.symbol} } from '${relativeImport(srcFile, hasTypescript)}';`);
2495
- }
2496
- // Mock imports
2497
- for (const m of plan.mockStrategy) {
2498
- if (framework === 'vitest') {
2499
- lines.push(`vi.mock('${m.dependency}');`);
2500
- }
2501
- else if (framework === 'jest') {
2502
- lines.push(`jest.mock('${m.dependency}');`);
2503
- }
2504
- }
2505
- lines.push('');
2506
- const describeFn = framework === 'mocha' ? `describe('${plan.symbol}'` : framework === 'vitest' ? `describe('${plan.symbol}', () =>` : `describe('${plan.symbol}', () =>`;
2507
- const beforeEachHook = framework === 'mocha' ? ` beforeEach(() => {` : ` beforeEach(() => {`;
2508
- const endBrace = framework === 'mocha' ? `});` : `});`;
2509
- lines.push(`${describeFn} {`);
2510
- lines.push(`${beforeEachHook}`);
2511
- lines.push(` // Setup mocks`);
2512
- lines.push(` });`);
2513
- lines.push('');
2514
- for (const s of plan.testScenarios) {
2515
- const testFn = framework === 'mocha' ? `it('${s.name}'` : `it('${s.name}', () =>`;
2516
- lines.push(` ${testFn} {`);
2517
- lines.push(` // ${s.description}`);
2518
- lines.push(` const result = ${plan.symbol}();`);
2519
- lines.push(` expect(result).toBeDefined();`);
2520
- lines.push(` });`);
2521
- lines.push('');
2522
- }
2523
- lines.push(`});`);
2524
- }
2525
- return lines.join('\n');
2526
- }
2527
- function capitalize(s) {
2528
- return s.charAt(0).toUpperCase() + s.slice(1);
2529
- }
2530
- function relativeImport(srcFile, hasTypescript) {
2531
- // Convert src/foo/bar.ts → ../foo/bar (relative import for __tests__/bar.test.ts)
2532
- const withoutExt = srcFile.replace(/\.(ts|tsx|js|jsx)$/, '');
2533
- return `.${hasTypescript ? '' : '.js'}/${withoutExt.split('/').pop()}`;
2534
- }
2535
1994
  // ── Transport: stdio ──
2536
1995
  export async function startStdio(rootPath) {
2537
1996
  const server = createMcpServer(rootPath);
@@ -2558,14 +2017,33 @@ export async function startStdio(rootPath) {
2558
2017
  }
2559
2018
  }
2560
2019
  }
2561
- // Cleanup on exit
2020
+ // Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
2021
+ // Node's default terminate-on-signal behavior, so we must exit explicitly
2022
+ // or the process hangs forever waiting for the (already-closed) stdio transport.
2562
2023
  const cleanup = () => {
2563
2024
  if (watcher)
2564
2025
  watcher.stop();
2026
+ process.exit(0);
2565
2027
  };
2566
2028
  process.on('SIGINT', cleanup);
2567
2029
  process.on('SIGTERM', cleanup);
2568
- await server.connect(transport);
2030
+ // Many MCP hosts tear down the child by closing the stdio pipe (EOF) instead
2031
+ // of sending a signal — StdioServerTransport doesn't detect this on its own,
2032
+ // so without this the process leaks forever with no parent left to talk to.
2033
+ process.stdin.on('end', cleanup);
2034
+ process.stdin.on('close', cleanup);
2035
+ try {
2036
+ await server.connect(transport);
2037
+ }
2038
+ catch (err) {
2039
+ server.server.sendLoggingMessage({
2040
+ level: 'error',
2041
+ data: `[milens:stdio] Failed to start: ${err.message}`,
2042
+ });
2043
+ if (watcher)
2044
+ watcher.stop();
2045
+ process.exit(1);
2046
+ }
2569
2047
  }
2570
2048
  // ── Transport: HTTP (Streamable) ──
2571
2049
  export async function startHttp(port, rootPath) {
@@ -2593,10 +2071,20 @@ export async function startHttp(port, rootPath) {
2593
2071
  }
2594
2072
  }
2595
2073
  }
2596
- // Cleanup on exit
2074
+ // Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
2075
+ // Node's default terminate-on-signal behavior, so we must exit explicitly
2076
+ // or the process hangs forever with the HTTP server still listening.
2077
+ let shuttingDown = false;
2597
2078
  const cleanup = () => {
2079
+ if (shuttingDown)
2080
+ return;
2081
+ shuttingDown = true;
2598
2082
  if (watcher)
2599
2083
  watcher.stop();
2084
+ clearInterval(evictTimer);
2085
+ httpServer.close(() => process.exit(0));
2086
+ // Force-exit if some connection keeps the server from closing in time
2087
+ setTimeout(() => process.exit(0), 3000).unref();
2600
2088
  };
2601
2089
  process.on('SIGINT', cleanup);
2602
2090
  process.on('SIGTERM', cleanup);
@@ -2649,7 +2137,20 @@ export async function startHttp(port, rootPath) {
2649
2137
  sessions.set(id, { transport: transport, lastActive: Date.now() });
2650
2138
  },
2651
2139
  });
2652
- await server.connect(transport);
2140
+ try {
2141
+ await server.connect(transport);
2142
+ }
2143
+ catch (err) {
2144
+ server.server.sendLoggingMessage({
2145
+ level: 'error',
2146
+ data: `[milens:http] Failed to connect transport: ${err.message}`,
2147
+ });
2148
+ if (!res.headersSent) {
2149
+ res.writeHead(500);
2150
+ res.end('Internal server error');
2151
+ }
2152
+ return;
2153
+ }
2653
2154
  }
2654
2155
  await transport.handleRequest(req, res, parsed);
2655
2156
  }
@@ -2667,7 +2168,7 @@ export async function startHttp(port, rootPath) {
2667
2168
  });
2668
2169
  // Bind to localhost only — prevents network exposure without auth
2669
2170
  httpServer.listen(port, '127.0.0.1', () => {
2670
- console.log(`milens MCP server listening on http://127.0.0.1:${port}/mcp`);
2171
+ process.stderr.write(`milens MCP server listening on http://127.0.0.1:${port}/mcp\n`);
2671
2172
  });
2672
2173
  }
2673
2174
  function readBody(req) {