milens 0.7.0 → 0.7.2

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 (211) hide show
  1. package/.agents/skills/adapters/SKILL.md +59 -51
  2. package/.agents/skills/analyzer/SKILL.md +23 -21
  3. package/.agents/skills/apps/SKILL.md +65 -68
  4. package/.agents/skills/docs/SKILL.md +10 -25
  5. package/.agents/skills/milens/SKILL.md +11 -8
  6. package/.agents/skills/orchestrator/SKILL.md +13 -10
  7. package/.agents/skills/parser/SKILL.md +15 -12
  8. package/.agents/skills/root/SKILL.md +30 -29
  9. package/.agents/skills/scripts/SKILL.md +18 -16
  10. package/.agents/skills/security/SKILL.md +7 -7
  11. package/.agents/skills/server/SKILL.md +36 -26
  12. package/.agents/skills/store/SKILL.md +24 -23
  13. package/.agents/skills/test/SKILL.md +24 -22
  14. package/.agents/skills/ui/SKILL.md +6 -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/claude-code/CLAUDE.md +2 -0
  21. package/adapters/claude-code/hooks/hooks.json +27 -0
  22. package/adapters/codex/.codex/config.toml +3 -0
  23. package/adapters/copilot/.vscode/mcp.json +10 -0
  24. package/adapters/cursor/.cursor/mcp.json +9 -0
  25. package/adapters/cursor/.cursorrules +69 -9
  26. package/adapters/gemini/.gemini/settings.json +9 -0
  27. package/dist/agents-md.d.ts +1 -1
  28. package/dist/agents-md.js +47 -3
  29. package/dist/agents-md.js.map +1 -1
  30. package/dist/analyzer/config.d.ts +0 -1
  31. package/dist/analyzer/config.js +32 -0
  32. package/dist/analyzer/config.js.map +1 -1
  33. package/dist/analyzer/engine.d.ts +0 -1
  34. package/dist/analyzer/engine.js +67 -41
  35. package/dist/analyzer/engine.js.map +1 -1
  36. package/dist/analyzer/enrich.d.ts +0 -1
  37. package/dist/analyzer/enrich.js +81 -1
  38. package/dist/analyzer/enrich.js.map +1 -1
  39. package/dist/analyzer/resolver.d.ts +4 -2
  40. package/dist/analyzer/resolver.js +283 -36
  41. package/dist/analyzer/resolver.js.map +1 -1
  42. package/dist/analyzer/review.d.ts +2 -2
  43. package/dist/analyzer/review.js +39 -61
  44. package/dist/analyzer/review.js.map +1 -1
  45. package/dist/analyzer/risk.d.ts +20 -0
  46. package/dist/analyzer/risk.js +76 -0
  47. package/dist/analyzer/risk.js.map +1 -0
  48. package/dist/analyzer/scanner.d.ts +0 -1
  49. package/dist/analyzer/scanner.js +5 -4
  50. package/dist/analyzer/scanner.js.map +1 -1
  51. package/dist/analyzer/scope-resolver.d.ts +0 -1
  52. package/dist/analyzer/scope-resolver.js +2 -2
  53. package/dist/analyzer/scope-resolver.js.map +1 -1
  54. package/dist/build-info.d.ts +2 -0
  55. package/dist/build-info.js +4 -0
  56. package/dist/build-info.js.map +1 -0
  57. package/dist/cli.d.ts +0 -1
  58. package/dist/cli.js +225 -30
  59. package/dist/cli.js.map +1 -1
  60. package/dist/metrics.d.ts +0 -1
  61. package/dist/orchestrator/orchestrator.d.ts +1 -2
  62. package/dist/orchestrator/orchestrator.js +30 -7
  63. package/dist/orchestrator/orchestrator.js.map +1 -1
  64. package/dist/orchestrator/reporter.d.ts +0 -1
  65. package/dist/parser/extract.d.ts +7 -1
  66. package/dist/parser/extract.js +76 -29
  67. package/dist/parser/extract.js.map +1 -1
  68. package/dist/parser/lang-css.d.ts +0 -1
  69. package/dist/parser/lang-css.js +12 -0
  70. package/dist/parser/lang-css.js.map +1 -1
  71. package/dist/parser/lang-go.d.ts +0 -1
  72. package/dist/parser/lang-html.d.ts +0 -1
  73. package/dist/parser/lang-java.d.ts +0 -1
  74. package/dist/parser/lang-js.d.ts +0 -1
  75. package/dist/parser/lang-js.js +15 -4
  76. package/dist/parser/lang-js.js.map +1 -1
  77. package/dist/parser/lang-md.d.ts +0 -1
  78. package/dist/parser/lang-md.js +1 -0
  79. package/dist/parser/lang-md.js.map +1 -1
  80. package/dist/parser/lang-php.d.ts +0 -1
  81. package/dist/parser/lang-py.d.ts +0 -1
  82. package/dist/parser/lang-ruby.d.ts +0 -1
  83. package/dist/parser/lang-rust.d.ts +0 -1
  84. package/dist/parser/lang-ts.d.ts +0 -1
  85. package/dist/parser/lang-ts.js +40 -6
  86. package/dist/parser/lang-ts.js.map +1 -1
  87. package/dist/parser/lang-vue.d.ts +0 -1
  88. package/dist/parser/lang-vue.js +0 -21
  89. package/dist/parser/lang-vue.js.map +1 -1
  90. package/dist/parser/language-provider.d.ts +0 -1
  91. package/dist/parser/languages.d.ts +0 -1
  92. package/dist/parser/loader.d.ts +0 -1
  93. package/dist/security/deps.d.ts +0 -1
  94. package/dist/security/rules.d.ts +0 -1
  95. package/dist/security/rules.js +35 -35
  96. package/dist/security/rules.js.map +1 -1
  97. package/dist/server/guard-hook.d.ts +10 -0
  98. package/dist/server/guard-hook.js +179 -0
  99. package/dist/server/guard-hook.js.map +1 -0
  100. package/dist/server/hooks.d.ts +0 -1
  101. package/dist/server/hooks.js +2 -1
  102. package/dist/server/hooks.js.map +1 -1
  103. package/dist/server/mcp-prompts.d.ts +0 -1
  104. package/dist/server/mcp-prompts.js +509 -523
  105. package/dist/server/mcp-prompts.js.map +1 -1
  106. package/dist/server/mcp.d.ts +0 -1
  107. package/dist/server/mcp.js +324 -681
  108. package/dist/server/mcp.js.map +1 -1
  109. package/dist/server/test-plan.d.ts +1 -1
  110. package/dist/server/test-plan.js +25 -7
  111. package/dist/server/test-plan.js.map +1 -1
  112. package/dist/server/tools/deps.d.ts +28 -0
  113. package/dist/server/tools/deps.js +2 -0
  114. package/dist/server/tools/deps.js.map +1 -0
  115. package/dist/server/tools/findings-report.d.ts +3 -0
  116. package/dist/server/tools/findings-report.js +170 -0
  117. package/dist/server/tools/findings-report.js.map +1 -0
  118. package/dist/server/tools/resources.d.ts +3 -0
  119. package/dist/server/tools/resources.js +103 -0
  120. package/dist/server/tools/resources.js.map +1 -0
  121. package/dist/server/tools/security.d.ts +3 -0
  122. package/dist/server/tools/security.js +232 -0
  123. package/dist/server/tools/security.js.map +1 -0
  124. package/dist/server/tools/session.d.ts +3 -0
  125. package/dist/server/tools/session.js +160 -0
  126. package/dist/server/tools/session.js.map +1 -0
  127. package/dist/server/tools/testing.d.ts +3 -0
  128. package/dist/server/tools/testing.js +247 -0
  129. package/dist/server/tools/testing.js.map +1 -0
  130. package/dist/server/watcher.d.ts +0 -1
  131. package/dist/server/watcher.js +1 -1
  132. package/dist/server/watcher.js.map +1 -1
  133. package/dist/skills.d.ts +0 -1
  134. package/dist/skills.js +23 -11
  135. package/dist/skills.js.map +1 -1
  136. package/dist/store/annotations.d.ts +6 -1
  137. package/dist/store/annotations.js +56 -9
  138. package/dist/store/annotations.js.map +1 -1
  139. package/dist/store/confidence.d.ts +0 -1
  140. package/dist/store/confidence.js +2 -0
  141. package/dist/store/confidence.js.map +1 -1
  142. package/dist/store/db.d.ts +22 -1
  143. package/dist/store/db.js +225 -45
  144. package/dist/store/db.js.map +1 -1
  145. package/dist/store/registry.d.ts +1 -1
  146. package/dist/store/registry.js +17 -6
  147. package/dist/store/registry.js.map +1 -1
  148. package/dist/store/schema.sql +2 -1
  149. package/dist/store/vectors.d.ts +0 -1
  150. package/dist/types.d.ts +23 -2
  151. package/dist/ui/progress.d.ts +0 -1
  152. package/dist/uninstall.d.ts +1 -1
  153. package/dist/uninstall.js +33 -10
  154. package/dist/uninstall.js.map +1 -1
  155. package/dist/utils.d.ts +2 -1
  156. package/dist/utils.js +37 -2
  157. package/dist/utils.js.map +1 -1
  158. package/package.json +11 -8
  159. package/dist/agents-md.d.ts.map +0 -1
  160. package/dist/analyzer/config.d.ts.map +0 -1
  161. package/dist/analyzer/engine.d.ts.map +0 -1
  162. package/dist/analyzer/enrich.d.ts.map +0 -1
  163. package/dist/analyzer/resolver.d.ts.map +0 -1
  164. package/dist/analyzer/review.d.ts.map +0 -1
  165. package/dist/analyzer/scanner.d.ts.map +0 -1
  166. package/dist/analyzer/scope-resolver.d.ts.map +0 -1
  167. package/dist/analyzer/testplan.d.ts +0 -59
  168. package/dist/analyzer/testplan.d.ts.map +0 -1
  169. package/dist/analyzer/testplan.js +0 -218
  170. package/dist/analyzer/testplan.js.map +0 -1
  171. package/dist/cli.d.ts.map +0 -1
  172. package/dist/metrics.d.ts.map +0 -1
  173. package/dist/orchestrator/orchestrator.d.ts.map +0 -1
  174. package/dist/orchestrator/reporter.d.ts.map +0 -1
  175. package/dist/parser/extract.d.ts.map +0 -1
  176. package/dist/parser/lang-css.d.ts.map +0 -1
  177. package/dist/parser/lang-go.d.ts.map +0 -1
  178. package/dist/parser/lang-html.d.ts.map +0 -1
  179. package/dist/parser/lang-java.d.ts.map +0 -1
  180. package/dist/parser/lang-js.d.ts.map +0 -1
  181. package/dist/parser/lang-md.d.ts.map +0 -1
  182. package/dist/parser/lang-php.d.ts.map +0 -1
  183. package/dist/parser/lang-py.d.ts.map +0 -1
  184. package/dist/parser/lang-ruby.d.ts.map +0 -1
  185. package/dist/parser/lang-rust.d.ts.map +0 -1
  186. package/dist/parser/lang-ts.d.ts.map +0 -1
  187. package/dist/parser/lang-vue.d.ts.map +0 -1
  188. package/dist/parser/language-provider.d.ts.map +0 -1
  189. package/dist/parser/languages.d.ts.map +0 -1
  190. package/dist/parser/loader.d.ts.map +0 -1
  191. package/dist/security/deps.d.ts.map +0 -1
  192. package/dist/security/rules.d.ts.map +0 -1
  193. package/dist/server/hooks.d.ts.map +0 -1
  194. package/dist/server/mcp-prompts.d.ts.map +0 -1
  195. package/dist/server/mcp.d.ts.map +0 -1
  196. package/dist/server/test-plan.d.ts.map +0 -1
  197. package/dist/server/watcher.d.ts.map +0 -1
  198. package/dist/skills.d.ts.map +0 -1
  199. package/dist/store/annotations.d.ts.map +0 -1
  200. package/dist/store/confidence.d.ts.map +0 -1
  201. package/dist/store/db.d.ts.map +0 -1
  202. package/dist/store/registry.d.ts.map +0 -1
  203. package/dist/store/vectors.d.ts.map +0 -1
  204. package/dist/types.d.ts.map +0 -1
  205. package/dist/ui/progress.d.ts.map +0 -1
  206. package/dist/uninstall.d.ts.map +0 -1
  207. package/dist/utils.d.ts.map +0 -1
  208. package/docs/README.md +0 -25
  209. package/docs/diagram1.svg +0 -1
  210. package/docs/diagram2.svg +0 -1
  211. 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, existsSync } 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,19 @@ 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';
25
+ import { registerFindingsReportTools } from './tools/findings-report.js';
23
26
  import { FileWatcher } from './watcher.js';
24
27
  import { reviewPr } from '../analyzer/review.js';
28
+ import { globToRegex } from '../utils.js';
29
+ import { BUILD_SHA, BUILT_AT } from '../build-info.js';
25
30
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
31
  const PKG_VERSION = process.env.MILENS_VERSION ?? JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf-8')).version;
27
32
  // ── Lazy DB connection with idle eviction ──
@@ -147,7 +152,10 @@ class SessionGuard {
147
152
  }
148
153
  }
149
154
  // ── Tool usage tracking ──
150
- // Estimated tokens an agent would spend WITHOUT milens (manual exploration cost per tool)
155
+ // Hand-picked heuristic estimates of tokens an agent would spend WITHOUT milens
156
+ // (manual exploration cost per tool). Not derived from measurement — these feed
157
+ // the "tokens saved" numbers in usage tracking/dashboards, so treat them as a
158
+ // rough directional signal, not a validated benchmark.
151
159
  const TOKEN_SAVINGS_MULTIPLIER = {
152
160
  query: 3, // vs 3+ separate grep/file reads
153
161
  grep: 2, // vs terminal grep + manual filtering
@@ -233,10 +241,10 @@ function isTestFilePath(filePath) {
233
241
  // ── Text grep across project files ──
234
242
  const GREP_SKIP_DIRS = new Set([
235
243
  'node_modules', '.git', 'dist', 'build', 'out',
236
- '.next', '.nuxt', '.svelte-kit',
244
+ '.next', '.nuxt', '.svelte-kit', '.turbo', '.cache', '.parcel-cache',
237
245
  '__pycache__', '.venv', 'venv', 'env',
238
246
  'vendor', 'target',
239
- '.idea', '.vscode',
247
+ '.idea',
240
248
  'coverage', '.nyc_output',
241
249
  ]);
242
250
  const BINARY_EXTENSIONS = new Set([
@@ -276,8 +284,6 @@ function grepFiles(rootPath, pattern, options) {
276
284
  return;
277
285
  const abs = join(dir, entry);
278
286
  const rel = relative(rootPath, abs).replace(/\\/g, '/');
279
- if (entry.startsWith('.') && entry !== '.')
280
- continue;
281
287
  if (GREP_SKIP_DIRS.has(entry))
282
288
  continue;
283
289
  if (ig.ignores(rel))
@@ -328,7 +334,11 @@ function matchesScope(lineText, scope) {
328
334
  // definitions: function, class, interface, struct, trait, enum, type, def, fn, pub fn, etc.
329
335
  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
336
  }
331
- /** Validate user-supplied regex is safe from catastrophic backtracking (ReDoS). */
337
+ /**
338
+ * Validate user-supplied regex is safe from catastrophic backtracking (ReDoS).
339
+ * This is a heuristic blocklist of known-dangerous constructs, not a formal
340
+ * proof of safety — it catches common patterns but isn't exhaustive.
341
+ */
332
342
  function safeRegex(pattern, flags) {
333
343
  if (pattern.length > 200)
334
344
  throw new Error('Pattern too long');
@@ -355,14 +365,6 @@ function safeRegex(pattern, flags) {
355
365
  throw new Error('Unsafe regex pattern');
356
366
  return new RegExp(pattern, flags);
357
367
  }
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
368
  function loadGrepIgnoreRules(rootPath) {
367
369
  const ig = ignore();
368
370
  ig.add(['node_modules', 'dist', 'build', '.git', '__pycache__', 'vendor', 'target']);
@@ -389,7 +391,7 @@ RULE: Before opening ANY file to understand code, call the appropriate milens to
389
391
 
390
392
  AUDIT: session_end reports which symbols were safety-checked. Editing without checks = audit gap.
391
393
 
392
- TOKEN SAVINGS: Using milens first = 70% fewer tokens, zero missed dependencies.
394
+ 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
395
 
394
396
  milens — code intelligence engine. Indexes codebases into symbol graphs.
395
397
 
@@ -438,6 +440,9 @@ export function createMcpServer(rootPath) {
438
440
  const toolCallCounts = new Map(); // In-memory counter per repo
439
441
  const trackDb = getTrackingDb();
440
442
  const guard = new SessionGuard();
443
+ // On Windows, the same path can arrive as "c:\..." or "C:\..." depending on the
444
+ // caller — without normalizing the drive letter, those resolve to different
445
+ // registry keys and split one repo's index across two entries.
441
446
  function normalizePath(p) {
442
447
  const abs = resolve(p);
443
448
  if (process.platform === 'win32') {
@@ -445,6 +450,19 @@ export function createMcpServer(rootPath) {
445
450
  }
446
451
  return abs;
447
452
  }
453
+ function isInsideDocstring(content, lineNumber) {
454
+ const lines = content.split('\n');
455
+ let inside = false;
456
+ for (let i = 0; i < lineNumber; i++) {
457
+ if (i >= lines.length)
458
+ break;
459
+ const trimmed = lines[i].trim();
460
+ const tripleCount = (trimmed.match(/"""|'''/g) || []).length;
461
+ for (let j = 0; j < tripleCount; j++)
462
+ inside = !inside;
463
+ }
464
+ return inside;
465
+ }
448
466
  function resolveRoot(repoPath) {
449
467
  if (repoPath) {
450
468
  const root = normalizePath(repoPath);
@@ -487,7 +505,11 @@ export function createMcpServer(rootPath) {
487
505
  return toolCallCounts.get(root) ?? 0;
488
506
  }
489
507
  const server = new McpServer({ name: 'milens', version: PKG_VERSION }, { instructions: MILENS_INSTRUCTIONS });
490
- // Auto-wrap every tool handler with usage tracking + background decay tick
508
+ // Auto-wrap every tool handler with usage tracking + background decay tick.
509
+ // This replaces `server.tool` itself, so every `server.tool(...)` call below —
510
+ // including the ones inside registerTestingTools/registerSessionTools/
511
+ // registerSecurityTools/registerResources in other files, which all receive
512
+ // this same `server` instance — goes through this wrapper too.
491
513
  const origTool = server.tool.bind(server);
492
514
  let lastDecayTick = 0;
493
515
  const DECAY_INTERVAL = 5 * 60_000; // 5 minutes between decay ticks
@@ -521,13 +543,15 @@ export function createMcpServer(rootPath) {
521
543
  }
522
544
  return origTool(...args);
523
545
  });
524
- // ── Selective tool profiles (W4) ──
546
+ // ── Selective tool profiles ──
525
547
  const profile = process.env.MILENS_PROFILE || undefined;
526
548
  if (profile && profile !== 'full') {
527
549
  const minimal = new Set(['query', 'grep', 'context', 'impact', 'status', 'codebase_summary', 'edit_check', 'detect_changes', 'get_file_symbols', 'overview']);
528
- 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']);
550
+ 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', 'generate_findings_report']);
529
551
  const allowed = profile === 'minimal' ? minimal : standard;
530
- // Wrap server.tool again to gate by profile
552
+ // Wrap the tracking-wrapped server.tool again, so profile gating sits on
553
+ // top: disabled tools still get registered (via the no-op handler below)
554
+ // and still get tracked, they just short-circuit to the disabled message.
531
555
  const profileWrappedTool = server.tool.bind(server);
532
556
  server.tool = ((...args) => {
533
557
  const toolName = args[0];
@@ -673,7 +697,8 @@ export function createMcpServer(rootPath) {
673
697
  lines.push(`No ${direction} deps found.`);
674
698
  }
675
699
  else {
676
- const depth1Count = refs.filter(r => r.depth === 1).length;
700
+ // Dedupe depth-1 by calling file same real dependent, same blast radius
701
+ const depth1Count = countDependentFiles(db, sym.id).count;
677
702
  lines.push(`${direction} (${refs.length} symbols, depth-1: ${depth1Count}):`);
678
703
  lines.push(fmtImpact(refs, detail));
679
704
  // Strict mode: hard stop if depth-1 dependents > 5
@@ -698,7 +723,19 @@ export function createMcpServer(rootPath) {
698
723
  const stats = lazy.getCachedStats();
699
724
  const unresolved = db.getUnresolvedStats();
700
725
  const coverage = db.getTestCoverage();
701
- let text = `repo: ${root}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
726
+ let text = `repo: ${root}\nbuild: ${BUILD_SHA} (built: ${BUILT_AT})\nversion: ${PKG_VERSION}\nsymbols: ${stats.symbols}\nlinks: ${stats.links}\nfiles: ${stats.files}`;
727
+ // Warn if the running server build differs from the on-disk build
728
+ try {
729
+ const distBuildPath = join(__dirname, '..', 'build-info.js');
730
+ if (existsSync(distBuildPath)) {
731
+ const diskBuild = readFileSync(distBuildPath, 'utf-8');
732
+ const m = diskBuild.match(/export const BUILD_SHA = '([^']+)'/);
733
+ if (m && m[1] !== BUILD_SHA) {
734
+ text += `\n⚠ Running server build (${BUILD_SHA}) differs from on-disk build (${m[1]}) — restart the MCP server to pick up recent changes.`;
735
+ }
736
+ }
737
+ }
738
+ catch { /* best-effort */ }
702
739
  if (unresolved.imports > 0 || unresolved.calls > 0) {
703
740
  text += `\n⚠ unresolved (internal): ${unresolved.imports} imports, ${unresolved.calls} calls — callers may be incomplete`;
704
741
  }
@@ -719,13 +756,13 @@ export function createMcpServer(rootPath) {
719
756
  if (staleFiles.length > 0) {
720
757
  text += `\n⏳ ${staleFiles.length} files not analyzed in 24h`;
721
758
  }
722
- // Accuracy report — confidence distribution of resolved links
759
+ // Resolution confidence distribution (heuristic self-ratings, not validated accuracy)
723
760
  const conf = db.getConfidenceDistribution();
724
761
  if (conf.total > 0) {
725
762
  const highPct = Math.round(conf.high / conf.total * 100);
726
763
  const medPct = Math.round(conf.medium / conf.total * 100);
727
764
  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}%)`;
765
+ text += `\nresolution confidence: ${conf.total} links — ≥0.9: ${conf.high} (${highPct}%) | 0.7-0.9: ${conf.medium} (${medPct}%) | <0.7: ${conf.low} (${lowPct}%)`;
729
766
  if (lowPct > 15) {
730
767
  text += `\n⚠ ${lowPct}% low-confidence links — consider re-analyzing with \`--force\` or reviewing unresolved calls`;
731
768
  }
@@ -755,7 +792,7 @@ export function createMcpServer(rootPath) {
755
792
  return { content: [{ type: 'text', text: lines.join('\n') }] };
756
793
  });
757
794
  // ── 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.', {
795
+ 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
796
  name: z.string().describe('Symbol name'),
760
797
  repo: z.string().optional(),
761
798
  depth: z.number().optional().default(2).describe('Impact traversal depth (default: 2)'),
@@ -776,8 +813,11 @@ export function createMcpServer(rootPath) {
776
813
  // Section 2: Context (incoming + outgoing) for each symbol
777
814
  if (symbols.length > 0) {
778
815
  for (const sym of symbols) {
779
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
780
- const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
816
+ const incoming = db.getIncomingLinks(sym.id);
817
+ const outgoing = db.getOutgoingLinks(sym.id);
818
+ if (symbols.length > 1) {
819
+ sections.push(`─── ${fmtSymbol(sym, detail)} ---`);
820
+ }
781
821
  if (incoming.length > 0) {
782
822
  sections.push(`[incoming] ${incoming.length} refs:`);
783
823
  const inSyms = incoming.map(l => {
@@ -855,7 +895,8 @@ export function createMcpServer(rootPath) {
855
895
  lines.push(` domains: ${summary.domains.join(', ')}`);
856
896
  }
857
897
  if (summary.staleCount > 0) {
858
- lines.push(` ⏳ ${summary.staleCount} stale files`);
898
+ const stalePct = summary.files > 0 ? Math.round(summary.staleCount / summary.files * 100) : 0;
899
+ lines.push(` ⏳ ${summary.staleCount} stale files (>24h)${stalePct > 20 ? ' — ⚠ run `milens analyze` to refresh' : ''}`);
859
900
  }
860
901
  if (!pools.has(entry.rootPath))
861
902
  tempDb.close();
@@ -874,6 +915,9 @@ export function createMcpServer(rootPath) {
874
915
  repo: z.string().optional(),
875
916
  }, async ({ ref, repo }) => {
876
917
  const { db, root } = getDb(repo);
918
+ // `ref` is passed straight to `git diff` below. Restrict it to characters
919
+ // valid in a git ref so it can't be crafted as a flag (e.g. a leading "-")
920
+ // and get interpreted as a git option instead of a revision.
877
921
  if (!/^[a-zA-Z0-9\/._~^\-]+$/.test(ref)) {
878
922
  return { content: [{ type: 'text', text: 'Invalid git ref.' }] };
879
923
  }
@@ -940,11 +984,11 @@ export function createMcpServer(rootPath) {
940
984
  const unchangedNote = unchangedCount > 0 ? ` (${unchangedCount} unchanged not shown)` : '';
941
985
  lines.push(`${file}: ${displaySyms.length} changed symbols${unchangedNote}`);
942
986
  for (const sym of displaySyms) {
943
- const upstream = db.findUpstream(sym.id, 1);
987
+ const depsCount = countDependentFiles(db, sym.id).count;
944
988
  totalChanged++;
945
- if (upstream.length > 0) {
946
- lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${upstream.length} direct dependents`);
947
- totalAffected += upstream.length;
989
+ if (depsCount > 0) {
990
+ lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine} → ${depsCount} direct dependents`);
991
+ totalAffected += depsCount;
948
992
  }
949
993
  else {
950
994
  lines.push(` ${sym.name} [${sym.kind}] :${sym.startLine}`);
@@ -962,12 +1006,32 @@ export function createMcpServer(rootPath) {
962
1006
  repo: z.string().optional(),
963
1007
  }, async ({ from, to, repo }) => {
964
1008
  const { db } = getDb(repo);
965
- const path = db.findPath(from, to);
1009
+ let fromSyms = db.findSymbolByName(from);
1010
+ const toSyms = db.findSymbolByName(to);
1011
+ if (fromSyms.length === 0) {
1012
+ return { content: [{ type: 'text', text: `Symbol "${from}" not found in index. Try \`grep\`.` }] };
1013
+ }
1014
+ if (toSyms.length === 0) {
1015
+ return { content: [{ type: 'text', text: `Symbol "${to}" not found in index. Try \`grep\`.` }] };
1016
+ }
1017
+ // Vue SFC: class-kind symbols have no call edges; resolve to file's _top module
1018
+ const fromSym = fromSyms[0];
1019
+ if (fromSym.kind === 'class' && fromSym.filePath.endsWith('.vue')) {
1020
+ const topId = `${fromSym.filePath}#module:_top:0`;
1021
+ const topSym = db.findSymbolById(topId);
1022
+ if (topSym)
1023
+ fromSyms = [topSym];
1024
+ }
1025
+ // Use the id-based lookup once a symbol has been disambiguated above —
1026
+ // findPath(name, ...) would re-resolve by name and could pick a
1027
+ // different same-named symbol (e.g. some other file's "_top"),
1028
+ // silently undoing the disambiguation.
1029
+ const path = db.findPathFromId(fromSyms[0].id, to);
966
1030
  if (!path) {
967
1031
  return { content: [{ type: 'text', text: `No path between "${from}" and "${to}".` }] };
968
1032
  }
969
- const fromSym = db.findSymbolByName(from)[0];
970
- const lines = [`FROM: ${fmtSymbol(fromSym)}`, ''];
1033
+ const displaySym = fromSyms[0];
1034
+ const lines = [`FROM: ${fmtSymbol(displaySym)}`, ''];
971
1035
  for (const { symbol, depth, via } of path) {
972
1036
  lines.push(` ${'→'.repeat(depth)} [${via}] ${fmtSymbol(symbol)}`);
973
1037
  }
@@ -981,12 +1045,25 @@ export function createMcpServer(rootPath) {
981
1045
  }, async ({ kind, limit, repo }) => {
982
1046
  const { db } = getDb(repo);
983
1047
  const dead = db.findDeadCode(kind, limit);
984
- if (dead.length === 0) {
1048
+ const testOnly = db.findTestOnlyReferenced(limit);
1049
+ const lines = [];
1050
+ if (dead.length === 0 && testOnly.length === 0) {
985
1051
  return { content: [{ type: 'text', text: 'No unreferenced exported symbols found.' }] };
986
1052
  }
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));
1053
+ if (dead.length > 0) {
1054
+ const cappedNote = dead.length >= limit ? ` (showing ${dead.length}, may be more — increase limit to see all)` : '';
1055
+ lines.push(`${dead.length} unreferenced exported symbols (code-level only — verify with grep before removing)${cappedNote}:\n`);
1056
+ for (const sym of dead) {
1057
+ lines.push(fmtSymbol(sym));
1058
+ }
1059
+ }
1060
+ if (testOnly.length > 0) {
1061
+ if (dead.length > 0)
1062
+ lines.push('');
1063
+ lines.push(`─── Symbols referenced only by test files (likely orphaned) — ${testOnly.length} found:\n`);
1064
+ for (const sym of testOnly) {
1065
+ lines.push(fmtSymbol(sym));
1066
+ }
990
1067
  }
991
1068
  return { content: [{ type: 'text', text: lines.join('\n') }] };
992
1069
  });
@@ -995,7 +1072,11 @@ export function createMcpServer(rootPath) {
995
1072
  file: z.string().describe('File path (relative to repo root)'),
996
1073
  repo: z.string().optional(),
997
1074
  detail: z.enum(['L0', 'L1', 'L2']).optional().default('L1').describe('Output detail: L0=names only, L1=default, L2=full metadata'),
998
- }, async ({ file, repo, detail }) => {
1075
+ limit: z.number().optional().default(100).describe('Max symbols to return'),
1076
+ offset: z.number().optional().default(0).describe('Offset for pagination'),
1077
+ }, async ({ file, repo, detail, limit, offset }) => {
1078
+ limit = limit ?? 100;
1079
+ offset = offset ?? 0;
999
1080
  const { db } = getDb(repo);
1000
1081
  const symbols = db.getSymbolsByFile(file);
1001
1082
  if (symbols.length === 0) {
@@ -1005,8 +1086,13 @@ export function createMcpServer(rootPath) {
1005
1086
  const sorted = detail === 'L2'
1006
1087
  ? [...symbols].sort((a, b) => (b.heat ?? 0) - (a.heat ?? 0))
1007
1088
  : symbols;
1008
- const lines = [`${file}: ${symbols.length} symbols\n`];
1009
- for (const sym of sorted) {
1089
+ const paginated = sorted.slice(offset, offset + limit);
1090
+ const total = sorted.length;
1091
+ const pageInfo = total > limit || offset > 0
1092
+ ? ` (showing ${offset + 1}-${Math.min(offset + limit, total)} of ${total}; use offset/limit to paginate)`
1093
+ : '';
1094
+ const lines = [`${file}: ${total} symbols${pageInfo}\n`];
1095
+ for (const sym of paginated) {
1010
1096
  const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1011
1097
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1012
1098
  const exp = sym.exported ? ' (exported)' : '';
@@ -1059,13 +1145,17 @@ export function createMcpServer(rootPath) {
1059
1145
  if (ancestors.length > 0) {
1060
1146
  lines.push('extends/implements:');
1061
1147
  for (const { symbol: a, depth } of ancestors) {
1062
- lines.push(` ${'↑'.repeat(depth)} ${fmtSymbol(a)}`);
1148
+ const isExternal = a.filePath === '(external)';
1149
+ const label = isExternal ? ` ${fmtSymbol(a)} (external)` : ` ${fmtSymbol(a)}`;
1150
+ lines.push(` ${'↑'.repeat(depth)}${label}`);
1063
1151
  }
1064
1152
  }
1065
1153
  if (descendants.length > 0) {
1066
1154
  lines.push('extended/implemented by:');
1067
1155
  for (const { symbol: d, depth } of descendants) {
1068
- lines.push(` ${'↓'.repeat(depth)} ${fmtSymbol(d)}`);
1156
+ const isExternal = d.filePath === '(external)';
1157
+ const label = isExternal ? ` ${fmtSymbol(d)} (external)` : ` ${fmtSymbol(d)}`;
1158
+ lines.push(` ${'↓'.repeat(depth)}${label}`);
1069
1159
  }
1070
1160
  }
1071
1161
  if (ancestors.length === 0 && descendants.length === 0) {
@@ -1123,7 +1213,7 @@ export function createMcpServer(rootPath) {
1123
1213
  // 5. Unresolved warning (only for internal)
1124
1214
  const unresolved = db.getUnresolvedStats();
1125
1215
  if (unresolved.imports > 0 || unresolved.calls > 0) {
1126
- sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
1216
+ sections.push(`⚠ this repo's index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls repo-wide this symbol's callers list may be incomplete`);
1127
1217
  }
1128
1218
  // 6. Test coverage for this symbol
1129
1219
  for (const sym of symbols) {
@@ -1161,10 +1251,7 @@ export function createMcpServer(rootPath) {
1161
1251
  for (const sym of symbols) {
1162
1252
  sections.push(`${fmtSymbol(sym)}${sym.exported ? ' (exported)' : ''}`);
1163
1253
  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;
1254
+ const depsCount = countDependentFiles(db, sym.id, { excludeTestFiles: true }).count;
1168
1255
  if (session_id)
1169
1256
  guard.recordCheck(session_id, name);
1170
1257
  // Hard stop: if > 5 non-test dependents and no confirmation
@@ -1230,7 +1317,7 @@ export function createMcpServer(rootPath) {
1230
1317
  // Unresolved warning
1231
1318
  const unresolved = db.getUnresolvedStats();
1232
1319
  if (unresolved.imports > 0 || unresolved.calls > 0) {
1233
- sections.push(`⚠ index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls — callers list may be incomplete`);
1320
+ sections.push(`⚠ this repo's index has ${unresolved.imports} unresolved internal imports, ${unresolved.calls} unresolved internal calls repo-wide this symbol's callers list may be incomplete`);
1234
1321
  }
1235
1322
  return { content: [{ type: 'text', text: sections.join('\n') }] };
1236
1323
  });
@@ -1254,7 +1341,7 @@ export function createMcpServer(rootPath) {
1254
1341
  const traces = db.traceToEntrypoints(sym.id, depth);
1255
1342
  sections.push(`## Execution paths TO ${fmtSymbol(sym)}\n`);
1256
1343
  if (traces.length === 0) {
1257
- sections.push('No call chains found (symbol may be an entrypoint itself or unreachable).');
1344
+ sections.push('No call chain found — verify with `context()` before treating this symbol as dead code.');
1258
1345
  }
1259
1346
  else {
1260
1347
  for (let i = 0; i < traces.length; i++) {
@@ -1295,17 +1382,20 @@ export function createMcpServer(rootPath) {
1295
1382
  // ── Tool: routes ──
1296
1383
  server.tool('routes', 'Detect framework routes/endpoints and map them to handler symbols. Scans for Express, FastAPI, NestJS, Flask, Go HTTP, PHP, Rails patterns.', {
1297
1384
  repo: z.string().optional(),
1298
- framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, flask, go, php, rails). Default: auto-detect all.'),
1299
- limit: z.number().optional().default(50),
1385
+ framework: z.string().optional().describe('Filter by framework (express, fastapi, nestjs, nestjs-gateway, flask, go, php, rails). Default: auto-detect all.'),
1386
+ limit: z.number().optional().default(200).describe('Max routes to display. Internal search always scans up to 500 matches per framework, so a low limit only affects display truncation, not detection accuracy.'),
1300
1387
  }, async ({ repo, framework, limit }) => {
1301
1388
  const root = resolveRoot(repo);
1302
1389
  const { db } = getDb(repo);
1303
- // Route patterns for different frameworks
1390
+ // Route patterns for different frameworks. Best-effort, single-line regex
1391
+ // matching — route declarations split across multiple lines (e.g. a decorator
1392
+ // and its path on separate lines) will not be detected.
1304
1393
  const routePatterns = [
1305
1394
  { name: 'express', pattern: /\b(?:app|router)\.(get|post|put|patch|delete|use|all)\s*\(\s*['"`]([^'"`]+)['"`]/, fileGlob: '**/*.{ts,js,mjs,cjs}' },
1306
1395
  { name: 'fastapi', pattern: /@(?:app|router)\.(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1307
- { name: 'flask', pattern: /@(?:app|bp|blueprint)\.(route|get|post|put|delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1396
+ { name: 'flask', pattern: /@(?:app|bp|blueprint)\.route\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.py' },
1308
1397
  { name: 'nestjs', pattern: /@(Get|Post|Put|Patch|Delete)\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/, fileGlob: '**/*.ts' },
1398
+ { name: 'nestjs-gateway', pattern: /@SubscribeMessage\s*\(\s*['"]([^'"]+)['"]\s*\)/, fileGlob: '**/*.ts' },
1309
1399
  { name: 'go', pattern: /\b(?:mux|router|http)\.(HandleFunc|Handle|Get|Post|Put|Delete)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.go' },
1310
1400
  { name: 'php', pattern: /Route::(get|post|put|patch|delete|any)\s*\(\s*['"]([^'"]+)['"]/, fileGlob: '**/*.php' },
1311
1401
  { name: 'rails', pattern: /\b(get|post|put|patch|delete|resources?|root)\s+['"]([^'"]+)['"]/, fileGlob: '**/*.rb' },
@@ -1314,19 +1404,29 @@ export function createMcpServer(rootPath) {
1314
1404
  ? routePatterns.filter(p => p.name === framework.toLowerCase())
1315
1405
  : routePatterns;
1316
1406
  if (activePatterns.length === 0) {
1317
- return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, flask, go, php, rails` }] };
1407
+ return { content: [{ type: 'text', text: `Unknown framework "${framework}". Available: express, fastapi, nestjs, nestjs-gateway, flask, go, php, rails` }] };
1318
1408
  }
1319
1409
  const routes = [];
1320
1410
  for (const rp of activePatterns) {
1321
1411
  const matches = grepFiles(root, rp.pattern.source, {
1322
- isRegex: true, maxResults: limit, includePattern: rp.fileGlob,
1412
+ isRegex: true, maxResults: 500, includePattern: rp.fileGlob,
1323
1413
  });
1324
1414
  for (const m of matches) {
1325
1415
  const match = rp.pattern.exec(m.text);
1326
1416
  if (!match)
1327
1417
  continue;
1328
- const method = match[1].toUpperCase();
1329
- const path = match[2] || '/';
1418
+ // Skip matches inside Python docstrings (triple-quoted strings)
1419
+ if (rp.fileGlob === '**/*.py') {
1420
+ try {
1421
+ const filePath = resolve(root, m.file);
1422
+ const content = readFileSync(filePath, 'utf-8');
1423
+ if (isInsideDocstring(content, m.line))
1424
+ continue;
1425
+ }
1426
+ catch { /* can't read file — skip filtering */ }
1427
+ }
1428
+ const method = rp.name === 'nestjs-gateway' ? 'WS' : match[1].toUpperCase();
1429
+ const path = rp.name === 'nestjs-gateway' ? match[1] : (match[2] || '/');
1330
1430
  // Try to find the handler symbol on this line or nearby
1331
1431
  const fileSymbols = db.getSymbolsByFile(m.file);
1332
1432
  const handler = fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line && s.kind === 'method') ?? fileSymbols.find(s => s.startLine <= m.line && s.endLine >= m.line) ?? fileSymbols.find(s => Math.abs(s.startLine - m.line) <= 3 && (s.kind === 'function' || s.kind === 'method'));
@@ -1343,6 +1443,71 @@ export function createMcpServer(rootPath) {
1343
1443
  if (routes.length === 0) {
1344
1444
  return { content: [{ type: 'text', text: 'No framework routes detected.' }] };
1345
1445
  }
1446
+ // NestJS: join controller prefix with method path
1447
+ const nestjsRoutes = routes.filter(r => r.framework === 'nestjs');
1448
+ if (nestjsRoutes.length > 0) {
1449
+ const nestjsByFile = new Map();
1450
+ for (const r of nestjsRoutes) {
1451
+ const arr = nestjsByFile.get(r.file) ?? [];
1452
+ arr.push(r);
1453
+ nestjsByFile.set(r.file, arr);
1454
+ }
1455
+ for (const [file, fileRoutes] of nestjsByFile) {
1456
+ try {
1457
+ const content = readFileSync(resolve(root, file), 'utf-8');
1458
+ const lines = content.split('\n');
1459
+ // Find @Controller decorators with their class body line ranges
1460
+ const controllers = [];
1461
+ const ctrlRe = /@Controller\s*\(\s*['"]?([^'")]*?)['"]?\s*\)/;
1462
+ const classRe = /export\s+(?:abstract\s+)?class\s+\w+/;
1463
+ for (let i = 0; i < lines.length; i++) {
1464
+ const cm = ctrlRe.exec(lines[i]);
1465
+ if (!cm)
1466
+ continue;
1467
+ // Find the class declaration within next 5 lines
1468
+ let classLine = -1;
1469
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
1470
+ if (classRe.test(lines[j])) {
1471
+ classLine = j;
1472
+ break;
1473
+ }
1474
+ }
1475
+ if (classLine === -1)
1476
+ continue;
1477
+ // Track brace depth to find end of class body
1478
+ let depth = 0;
1479
+ let started = false;
1480
+ let endLine = classLine;
1481
+ for (let k = classLine; k < lines.length; k++) {
1482
+ for (const ch of lines[k]) {
1483
+ if (ch === '{') {
1484
+ depth++;
1485
+ started = true;
1486
+ }
1487
+ else if (ch === '}') {
1488
+ depth--;
1489
+ }
1490
+ }
1491
+ if (started && depth === 0) {
1492
+ endLine = k;
1493
+ break;
1494
+ }
1495
+ }
1496
+ controllers.push({ prefix: cm[1] || '', startLine: classLine + 1, endLine: endLine + 1 });
1497
+ }
1498
+ // Map each route to its controller prefix
1499
+ for (const r of fileRoutes) {
1500
+ const ctrl = controllers.find(c => r.line >= c.startLine && r.line <= c.endLine);
1501
+ if (ctrl && ctrl.prefix) {
1502
+ const methodPath = r.path === '/' ? '' : r.path;
1503
+ const joinPath = ctrl.prefix + (methodPath.startsWith('/') || methodPath === '' ? methodPath : '/' + methodPath);
1504
+ r.path = joinPath || '/';
1505
+ }
1506
+ }
1507
+ }
1508
+ catch { /* can't read file */ }
1509
+ }
1510
+ }
1346
1511
  // Group by framework
1347
1512
  const grouped = new Map();
1348
1513
  for (const r of routes) {
@@ -1350,10 +1515,13 @@ export function createMcpServer(rootPath) {
1350
1515
  arr.push(r);
1351
1516
  grouped.set(r.framework, arr);
1352
1517
  }
1353
- const lines = [`${routes.length} routes detected:\n`];
1518
+ const displayRoutes = routes.slice(0, limit);
1519
+ const lines = [`${routes.length} routes detected${routes.length > limit ? ` (showing first ${limit})` : ''}:\n`];
1354
1520
  for (const [fw, fwRoutes] of grouped) {
1355
1521
  lines.push(`[${fw}]`);
1356
1522
  for (const r of fwRoutes) {
1523
+ if (!displayRoutes.includes(r))
1524
+ break;
1357
1525
  const handlerInfo = r.handler ? ` → ${r.handler}` : '';
1358
1526
  lines.push(` ${r.method.padEnd(7)} ${r.path} (${r.file}:${r.line})${handlerInfo}`);
1359
1527
  }
@@ -1452,10 +1620,19 @@ export function createMcpServer(rootPath) {
1452
1620
  // Execution paths + data flow
1453
1621
  const traces = db.traceToEntrypoints(sym.id, 6);
1454
1622
  if (traces.length > 0) {
1623
+ const seenChains = new Set();
1455
1624
  sections.push(`execution paths (${traces.length}):`);
1456
- for (let i = 0; i < Math.min(traces.length, 3); i++) {
1625
+ let shown = 0;
1626
+ for (let i = 0; i < traces.length && shown < 3; i++) {
1457
1627
  const chain = traces[i].path;
1458
- sections.push(` ${chain.map(s => s.symbol.name).join(' ')}`);
1628
+ const firstFile = chain[0]?.symbol?.filePath?.split('/').pop() ?? '';
1629
+ const rendered = chain.map(s => s.symbol.name).join(' → ');
1630
+ const key = `${firstFile}: ${rendered}`;
1631
+ if (seenChains.has(key))
1632
+ continue;
1633
+ seenChains.add(key);
1634
+ sections.push(` ${key}`);
1635
+ shown++;
1459
1636
  }
1460
1637
  }
1461
1638
  else {
@@ -1610,7 +1787,7 @@ export function createMcpServer(rootPath) {
1610
1787
  return { content: [{ type: 'text', text: `Query error: ${err.message}` }] };
1611
1788
  }
1612
1789
  });
1613
- // ═══ codebase_summary ═══
1790
+ // ── Tool: codebase_summary ──
1614
1791
  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
1792
  const { db } = getDb(repo);
1616
1793
  const summary = db.getCodebaseSummary();
@@ -1638,7 +1815,7 @@ export function createMcpServer(rootPath) {
1638
1815
  }
1639
1816
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1640
1817
  });
1641
- // ═══ review_pr ═══
1818
+ // ── Tool: review_pr ──
1642
1819
  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
1820
  const { db, root } = getDb(repo);
1644
1821
  const result = reviewPr(db, root, ref);
@@ -1664,7 +1841,7 @@ export function createMcpServer(rootPath) {
1664
1841
  lines.push(`\nSummary: CRITICAL=${summary.CRITICAL} HIGH=${summary.HIGH} MEDIUM=${summary.MEDIUM} LOW=${summary.LOW}`);
1665
1842
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1666
1843
  });
1667
- // ═══ review_symbol ═══
1844
+ // ── Tool: review_symbol ──
1668
1845
  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
1846
  const { db, root } = getDb(repo);
1670
1847
  const syms = db.findSymbolByName(name);
@@ -1672,9 +1849,19 @@ export function createMcpServer(rootPath) {
1672
1849
  return { content: [{ type: 'text', text: `"${name}" not found.` }] };
1673
1850
  const lines = [];
1674
1851
  for (const sym of syms) {
1675
- const incoming = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1852
+ const incomingRaw = db.getIncomingLinks(sym.id).filter(l => l.type !== 'contains');
1853
+ // Dedupe by calling file — imports + calls from the same file is 1 real dependent
1854
+ const seenCallerFiles = new Set();
1855
+ const incoming = incomingRaw.filter(l => {
1856
+ const from = db.findSymbolById(l.fromId);
1857
+ const key = from?.filePath ?? l.fromId;
1858
+ if (seenCallerFiles.has(key))
1859
+ return false;
1860
+ seenCallerFiles.add(key);
1861
+ return true;
1862
+ });
1676
1863
  const outgoing = db.getOutgoingLinks(sym.id).filter(l => l.type !== 'contains');
1677
- const depsCount = incoming.length;
1864
+ const depsCount = countDependentFiles(db, sym.id).count;
1678
1865
  const depsTop = incoming.slice(0, 5).map(l => { const s = db.findSymbolById(l.fromId); return s?.name ?? l.fromId; });
1679
1866
  const outCount = outgoing.length;
1680
1867
  const outTop = outgoing.slice(0, 5).map(l => { const s = db.findSymbolById(l.toId); return s?.name ?? l.toId; });
@@ -1702,208 +1889,9 @@ export function createMcpServer(rootPath) {
1702
1889
  }
1703
1890
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1704
1891
  });
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 ═══
1892
+ registerTestingTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1893
+ registerSessionTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
1894
+ // ── Tool: semantic_search ──
1907
1895
  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
1896
  const { db } = getDb(repo);
1909
1897
  const results = db.searchSymbols(query, limit);
@@ -1916,7 +1904,7 @@ export function createMcpServer(rootPath) {
1916
1904
  }
1917
1905
  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
1906
  });
1919
- // ═══ find_similar ═══
1907
+ // ── Tool: find_similar ──
1920
1908
  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
1909
  const { db } = getDb(repo);
1922
1910
  const syms = db.findSymbolByName(name);
@@ -1931,111 +1919,16 @@ export function createMcpServer(rootPath) {
1931
1919
  }
1932
1920
  return { content: [{ type: 'text', text: lines.join('\n') }] };
1933
1921
  });
1934
- // ══════════════════════════════════════════════
1935
1922
  // ── 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') }] };
1923
+ registerResources(server, {
1924
+ getDb,
1925
+ fmtSymbol,
1926
+ fmtImpact,
1927
+ rootPath,
1928
+ toolCallCounts,
1929
+ resolveRoot,
1930
+ guard,
1931
+ getToolCallCount,
2039
1932
  });
2040
1933
  // ── Prompt: delete-feature ──
2041
1934
  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 +2051,11 @@ export function createMcpServer(rootPath) {
2158
2051
  },
2159
2052
  }],
2160
2053
  }));
2161
- // ── Register MCP Prompts (W1) ──
2054
+ // ── Register MCP Prompts ──
2162
2055
  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 ═══
2056
+ registerSecurityTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
2057
+ registerFindingsReportTools(server, { getDb, fmtSymbol, fmtImpact, rootPath, toolCallCounts, resolveRoot, guard, getToolCallCount });
2058
+ // ── Tool: compare_impact ──
2286
2059
  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
2060
  name: z.string().describe('Symbol name to compare'),
2288
2061
  action: z.enum(['snapshot', 'compare']).describe("'snapshot' to save current state, 'compare' to diff against last snapshot"),
@@ -2329,88 +2102,13 @@ export function createMcpServer(rootPath) {
2329
2102
  db.close();
2330
2103
  }
2331
2104
  });
2332
- // ═══ orchestrate ═══
2105
+ // ── Tool: orchestrate ──
2333
2106
  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
2107
  const { root, dbPath } = getDb(repo);
2335
2108
  const orchestrator = new Orchestrator({ rootPath: root, dbPath, useEmoji: false });
2336
2109
  const report = await orchestrator.runAndFormat();
2337
2110
  return { content: [{ type: 'text', text: report }] };
2338
2111
  });
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
2112
  // ── Prompt: dead_code_remove ──
2415
2113
  server.prompt('dead_code_remove', 'Safe dead code removal workflow: detect → verify → remove → test.', { repo: z.string().optional().describe('Repository root path') }, ({ repo }) => ({
2416
2114
  messages: [{
@@ -2435,103 +2133,6 @@ export function createMcpServer(rootPath) {
2435
2133
  }));
2436
2134
  return server;
2437
2135
  }
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
2136
  // ── Transport: stdio ──
2536
2137
  export async function startStdio(rootPath) {
2537
2138
  const server = createMcpServer(rootPath);
@@ -2558,14 +2159,33 @@ export async function startStdio(rootPath) {
2558
2159
  }
2559
2160
  }
2560
2161
  }
2561
- // Cleanup on exit
2162
+ // Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
2163
+ // Node's default terminate-on-signal behavior, so we must exit explicitly
2164
+ // or the process hangs forever waiting for the (already-closed) stdio transport.
2562
2165
  const cleanup = () => {
2563
2166
  if (watcher)
2564
2167
  watcher.stop();
2168
+ process.exit(0);
2565
2169
  };
2566
2170
  process.on('SIGINT', cleanup);
2567
2171
  process.on('SIGTERM', cleanup);
2568
- await server.connect(transport);
2172
+ // Many MCP hosts tear down the child by closing the stdio pipe (EOF) instead
2173
+ // of sending a signal — StdioServerTransport doesn't detect this on its own,
2174
+ // so without this the process leaks forever with no parent left to talk to.
2175
+ process.stdin.on('end', cleanup);
2176
+ process.stdin.on('close', cleanup);
2177
+ try {
2178
+ await server.connect(transport);
2179
+ }
2180
+ catch (err) {
2181
+ server.server.sendLoggingMessage({
2182
+ level: 'error',
2183
+ data: `[milens:stdio] Failed to start: ${err.message}`,
2184
+ });
2185
+ if (watcher)
2186
+ watcher.stop();
2187
+ process.exit(1);
2188
+ }
2569
2189
  }
2570
2190
  // ── Transport: HTTP (Streamable) ──
2571
2191
  export async function startHttp(port, rootPath) {
@@ -2593,10 +2213,20 @@ export async function startHttp(port, rootPath) {
2593
2213
  }
2594
2214
  }
2595
2215
  }
2596
- // Cleanup on exit
2216
+ // Cleanup on exit — registering a custom SIGINT/SIGTERM handler replaces
2217
+ // Node's default terminate-on-signal behavior, so we must exit explicitly
2218
+ // or the process hangs forever with the HTTP server still listening.
2219
+ let shuttingDown = false;
2597
2220
  const cleanup = () => {
2221
+ if (shuttingDown)
2222
+ return;
2223
+ shuttingDown = true;
2598
2224
  if (watcher)
2599
2225
  watcher.stop();
2226
+ clearInterval(evictTimer);
2227
+ httpServer.close(() => process.exit(0));
2228
+ // Force-exit if some connection keeps the server from closing in time
2229
+ setTimeout(() => process.exit(0), 3000).unref();
2600
2230
  };
2601
2231
  process.on('SIGINT', cleanup);
2602
2232
  process.on('SIGTERM', cleanup);
@@ -2649,7 +2279,20 @@ export async function startHttp(port, rootPath) {
2649
2279
  sessions.set(id, { transport: transport, lastActive: Date.now() });
2650
2280
  },
2651
2281
  });
2652
- await server.connect(transport);
2282
+ try {
2283
+ await server.connect(transport);
2284
+ }
2285
+ catch (err) {
2286
+ server.server.sendLoggingMessage({
2287
+ level: 'error',
2288
+ data: `[milens:http] Failed to connect transport: ${err.message}`,
2289
+ });
2290
+ if (!res.headersSent) {
2291
+ res.writeHead(500);
2292
+ res.end('Internal server error');
2293
+ }
2294
+ return;
2295
+ }
2653
2296
  }
2654
2297
  await transport.handleRequest(req, res, parsed);
2655
2298
  }
@@ -2667,7 +2310,7 @@ export async function startHttp(port, rootPath) {
2667
2310
  });
2668
2311
  // Bind to localhost only — prevents network exposure without auth
2669
2312
  httpServer.listen(port, '127.0.0.1', () => {
2670
- console.log(`milens MCP server listening on http://127.0.0.1:${port}/mcp`);
2313
+ process.stderr.write(`milens MCP server listening on http://127.0.0.1:${port}/mcp\n`);
2671
2314
  });
2672
2315
  }
2673
2316
  function readBody(req) {