ucn 4.2.3 → 5.0.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 (72) hide show
  1. package/.claude/skills/ucn/SKILL.md +89 -77
  2. package/.claude/skills/ucn/references/commands.md +62 -68
  3. package/.claude/skills/ucn/references/trust-contract.md +31 -6
  4. package/README.md +438 -305
  5. package/assets/demo.svg +31 -0
  6. package/cli/index.js +430 -1385
  7. package/core/account.js +144 -34
  8. package/core/analysis.js +182 -72
  9. package/core/ast-analysis.js +279 -0
  10. package/core/bridge.js +205 -24
  11. package/core/brief.js +27 -58
  12. package/core/build-worker.js +21 -140
  13. package/core/cache.js +513 -11
  14. package/core/callers.js +4920 -456
  15. package/core/check.js +13 -4
  16. package/core/command-contracts.js +402 -0
  17. package/core/compilation-database.js +276 -0
  18. package/core/confidence.js +4 -1
  19. package/core/deadcode.js +397 -19
  20. package/core/discovery.js +359 -46
  21. package/core/entrypoints.js +195 -41
  22. package/core/execute.js +887 -81
  23. package/core/graph-build.js +162 -7
  24. package/core/graph.js +53 -77
  25. package/core/imports.js +65 -6
  26. package/core/index-ir.js +138 -0
  27. package/core/ir.js +195 -0
  28. package/core/output/analysis.js +212 -22
  29. package/core/output/brief.js +23 -0
  30. package/core/output/check.js +4 -0
  31. package/core/output/doctor.js +37 -6
  32. package/core/output/endpoints.js +5 -2
  33. package/core/output/extraction.js +24 -12
  34. package/core/output/find.js +141 -36
  35. package/core/output/graph.js +11 -5
  36. package/core/output/public.js +462 -0
  37. package/core/output/refactoring.js +42 -10
  38. package/core/output/reporting.js +97 -20
  39. package/core/output/search.js +24 -16
  40. package/core/output/shared.js +22 -1
  41. package/core/output/tracing.js +30 -15
  42. package/core/output-budget.js +295 -0
  43. package/core/output.js +1 -0
  44. package/core/parallel-build.js +44 -11
  45. package/core/parser.js +3 -3
  46. package/core/project.js +384 -187
  47. package/core/public-command.js +47 -0
  48. package/core/registry.js +247 -117
  49. package/core/reporting.js +312 -290
  50. package/core/search.js +317 -185
  51. package/core/semantic-provider.js +110 -0
  52. package/core/stacktrace.js +25 -0
  53. package/core/tracing.js +101 -51
  54. package/core/trust-matrix.js +19 -40
  55. package/core/verify.js +534 -37
  56. package/languages/adapter.js +218 -0
  57. package/languages/c-family.js +2791 -0
  58. package/languages/c.js +3 -0
  59. package/languages/cpp.js +3 -0
  60. package/languages/csharp.js +1402 -0
  61. package/languages/go.js +60 -21
  62. package/languages/html.js +2 -2
  63. package/languages/index.js +85 -7
  64. package/languages/java.js +396 -13
  65. package/languages/javascript.js +199 -19
  66. package/languages/python.js +964 -22
  67. package/languages/rust.js +1317 -152
  68. package/languages/utils.js +40 -3
  69. package/mcp/server.js +254 -636
  70. package/package.json +39 -22
  71. package/eslint.config.js +0 -43
  72. package/jsconfig.json +0 -10
package/mcp/server.js CHANGED
@@ -33,9 +33,19 @@ try {
33
33
  const { ProjectIndex } = require('../core/project');
34
34
  const { findProjectRoot } = require('../core/discovery');
35
35
  const output = require('../core/output');
36
- const { getMcpCommandEnum, normalizeParams, BROAD_COMMANDS: BROAD_CANONICAL, toMcpName, FLAG_APPLICABILITY, REVERSE_PARAM_MAP, generateMcpParamSection, resolveCommand } = require('../core/registry');
36
+ const {
37
+ getMcpCommandEnum,
38
+ normalizeParams,
39
+ FLAG_APPLICABILITY,
40
+ REVERSE_PARAM_MAP,
41
+ generateMcpParamSection,
42
+ resolveCommand,
43
+ suggestCommand,
44
+ v4MigrationHint,
45
+ formatSurfaceMessage,
46
+ } = require('../core/registry');
37
47
  const { execute } = require('../core/execute');
38
- const { ExpandCache } = require('../core/expand-cache');
48
+ const { applyOutputBudget, MAX_OUTPUT_CHARS } = require('../core/output-budget');
39
49
 
40
50
  // ============================================================================
41
51
  // INDEX CACHE
@@ -43,9 +53,11 @@ const { ExpandCache } = require('../core/expand-cache');
43
53
 
44
54
  const indexCache = new Map(); // projectDir → { index, checkedAt }
45
55
  const MAX_CACHE_SIZE = 10;
46
- const expandCacheInstance = new ExpandCache();
47
56
 
48
57
  function getIndex(projectDir, options) {
58
+ if (typeof projectDir !== 'string' || projectDir.trim().length === 0) {
59
+ throw new Error('project_dir is required and must be a non-empty path.');
60
+ }
49
61
  const maxFiles = options && options.maxFiles;
50
62
  const followSymlinks = options && options.followSymlinks;
51
63
  const absDir = path.resolve(projectDir);
@@ -76,8 +88,6 @@ function getIndex(projectDir, options) {
76
88
  buildOpts.forceRebuild = !!loaded;
77
89
  index.build(null, buildOpts);
78
90
  if (!maxFiles) index.saveCache(); // Don't pollute disk cache with partial indexes
79
- // Clear expand cache entries for this project — stale after rebuild
80
- expandCacheInstance.clearForRoot(root);
81
91
  }
82
92
 
83
93
  // LRU eviction
@@ -92,7 +102,6 @@ function getIndex(projectDir, options) {
92
102
  }
93
103
  if (oldestKey) {
94
104
  indexCache.delete(oldestKey);
95
- expandCacheInstance.clearForRoot(oldestKey);
96
105
  }
97
106
  }
98
107
 
@@ -116,93 +125,82 @@ const server = new McpServer({
116
125
  // TOOL HELPERS
117
126
  // ============================================================================
118
127
 
119
- const DEFAULT_OUTPUT_CHARS = 10000; // ~2.5K tokens — targeted commands (about, context, smart, etc.)
120
- const BROAD_OUTPUT_CHARS = 3000; // ~750 tokens — broad commands where truncated listings are useless
121
- const MAX_OUTPUT_CHARS = 100000; // hard ceiling even with max_chars override
128
+ function toolResult(text, command, maxChars, suffixNote, params = {}) {
129
+ const suffix = suffixNote || '';
130
+ const budget = applyOutputBudget(text, {
131
+ command,
132
+ maxChars,
133
+ surface: 'mcp',
134
+ params,
135
+ });
136
+ const response = { content: [{ type: 'text', text: budget.text + suffix }] };
137
+ if (budget.truncated) {
138
+ response.structuredContent = {
139
+ truncated: true,
140
+ fullChars: budget.fullChars,
141
+ requestedLimit: budget.requestedLimit,
142
+ contractMetadata: budget.contractMetadata,
143
+ contractMetadataComplete: budget.contractMetadataComplete,
144
+ };
145
+ }
146
+ return response;
147
+ }
122
148
 
123
- // Broad commands (derived from registry): output is project-wide, truncation means you need a filter
124
- const BROAD_COMMANDS = new Set([...BROAD_CANONICAL].map(toMcpName));
149
+ function toolError(message) {
150
+ return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
151
+ }
125
152
 
126
- const CONTRACT_LINE_RE = /^\s*(?:ACCOUNT|CONTRACT|WARNING|FILTERED|CALLEE ACCOUNT|TREE ACCOUNT):/;
127
- const MAX_PRESERVED_CONTRACT_LINES = 24;
128
- const MAX_PRESERVED_CONTRACT_CHARS = 8000;
153
+ function paramEditDistance(a, b) {
154
+ if (Math.abs(a.length - b.length) > 2) return 3;
155
+ const prev = new Array(b.length + 1);
156
+ const curr = new Array(b.length + 1);
157
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
158
+ for (let i = 1; i <= a.length; i++) {
159
+ curr[0] = i;
160
+ for (let j = 1; j <= b.length; j++) {
161
+ curr[j] = Math.min(
162
+ prev[j] + 1,
163
+ curr[j - 1] + 1,
164
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
165
+ );
166
+ }
167
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
168
+ }
169
+ return prev[b.length];
170
+ }
129
171
 
130
172
  /**
131
- * Keep trust/accounting metadata visible even when the human-readable body is
132
- * truncated. A first-N slice without this footer can turn a qualified answer
133
- * into an apparently complete one for an agent.
173
+ * Detect parameters the schema does not define, DELETE them from rawParams,
174
+ * and return one human-readable note per dropped key. Three cases:
175
+ * canonical camelCase spelling of a known param (includeTests), a close typo
176
+ * (include_test), and an entirely unknown key (zzz).
134
177
  */
135
- function preservedContractMetadata(fullText, visibleText) {
136
- const visible = new Set(visibleText.split('\n').map(line => line.trim()));
137
- const selected = [];
138
- let selectedChars = 0;
139
- let omitted = 0;
140
-
141
- for (const rawLine of fullText.split('\n')) {
142
- if (!CONTRACT_LINE_RE.test(rawLine)) continue;
143
- const line = rawLine.trim();
144
- if (!line || visible.has(line)) continue;
145
- if (selected.length >= MAX_PRESERVED_CONTRACT_LINES ||
146
- selectedChars + line.length + 1 > MAX_PRESERVED_CONTRACT_CHARS) {
147
- omitted++;
178
+ function collectUnknownParamNotes(rawParams) {
179
+ const knownKeys = Object.keys(INPUT_SHAPE).filter(key => key !== 'command' && key !== 'project_dir');
180
+ const knownSet = new Set(knownKeys);
181
+ const notes = [];
182
+ for (const key of Object.keys(rawParams)) {
183
+ if (knownSet.has(key)) continue;
184
+ delete rawParams[key];
185
+ const snake = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
186
+ if (knownSet.has(snake)) {
187
+ notes.push(`${key} is not an accepted parameter — use ${snake}.`);
148
188
  continue;
149
189
  }
150
- selected.push(line);
151
- selectedChars += line.length + 1;
152
- }
153
-
154
- return { lines: selected, omitted, complete: omitted === 0 };
155
- }
156
-
157
- function toolResult(text, command, maxChars, suffixNote) {
158
- const suffix = suffixNote || '';
159
- if (!text) return { content: [{ type: 'text', text: '(no output)' + suffix }] };
160
- const defaultLimit = BROAD_COMMANDS.has(command) ? BROAD_OUTPUT_CHARS : DEFAULT_OUTPUT_CHARS;
161
- const limit = Math.min(maxChars || defaultLimit, MAX_OUTPUT_CHARS);
162
- if (text.length > limit) {
163
- const fullSize = text.length;
164
- const fullTokens = Math.round(fullSize / 4);
165
- const truncated = text.substring(0, limit);
166
- // Cut at last newline to avoid breaking mid-line
167
- const lastNewline = truncated.lastIndexOf('\n');
168
- const cleanCut = lastNewline > limit * 0.8 ? truncated.substring(0, lastNewline) : truncated;
169
- const contractMetadata = preservedContractMetadata(text, cleanCut);
170
- // Command-specific narrowing hints
171
- const hints = {
172
- toc: 'Use in= to scope to a subdirectory, or detailed=false for compact view.',
173
- entrypoints: 'Use framework= to filter by framework, exclude= to skip patterns.',
174
- endpoints: 'Use prefix= to filter by URL prefix, method= to filter by HTTP method, server_only/client_only to halve output.',
175
- diff_impact: 'Use file= to scope to specific files/directories.',
176
- affected_tests: 'Use file= to scope, exclude= to skip patterns.',
177
- deadcode: 'Use file= to scope, exclude= to skip patterns.',
178
- usages: 'Use file= to scope to specific files.',
179
- };
180
- const narrow = hints[command] || 'Use file=/in=/exclude= to narrow scope.';
181
- let rendered = cleanCut + `\n\n... OUTPUT TRUNCATED: showing ${limit} of ${fullSize} chars. Full output would be ~${fullTokens} tokens. ${narrow} Use all=true to lift formatter caps; the MCP transport still has a 100K character ceiling.`;
182
- if (contractMetadata.lines.length > 0 || contractMetadata.omitted > 0) {
183
- rendered += '\n\nPRESERVED CONTRACT METADATA (from omitted output):';
184
- if (contractMetadata.lines.length > 0) rendered += '\n' + contractMetadata.lines.join('\n');
185
- if (contractMetadata.omitted > 0) {
186
- rendered += `\nWARNING: ${contractMetadata.omitted} additional contract line(s) could not fit the preservation budget; narrow scope before acting.`;
190
+ let best = null;
191
+ let bestDistance = 3;
192
+ for (const candidate of knownKeys) {
193
+ const distance = paramEditDistance(key.toLowerCase(), candidate);
194
+ if (distance < bestDistance) {
195
+ bestDistance = distance;
196
+ best = candidate;
187
197
  }
188
198
  }
189
- rendered += suffix;
190
- return {
191
- content: [{ type: 'text', text: rendered }],
192
- structuredContent: {
193
- truncated: true,
194
- fullChars: fullSize,
195
- requestedLimit: limit,
196
- contractMetadata: contractMetadata.lines,
197
- contractMetadataComplete: contractMetadata.complete,
198
- },
199
- };
199
+ notes.push(best
200
+ ? `unknown parameter ${key} ignored — did you mean ${best}?`
201
+ : `unknown parameter ${key} ignored.`);
200
202
  }
201
- return { content: [{ type: 'text', text: text + suffix }] };
202
- }
203
-
204
- function toolError(message) {
205
- return { content: [{ type: 'text', text: `Error: ${message}` }], isError: true };
203
+ return notes;
206
204
  }
207
205
 
208
206
  /**
@@ -234,171 +232,101 @@ function resolveAndValidatePath(index, file) {
234
232
  // CONSOLIDATED TOOL REGISTRATION
235
233
  // ============================================================================
236
234
 
237
- const VERBOSE_TOOL_DESCRIPTION = `Code intelligence toolkit for AI agents. Extract specific functions, trace call chains, find all callers, and detect dead code without reading entire files or scanning full projects. Use instead of grep/read for code relationships. Supports JavaScript/TypeScript, Python, Go, Rust, Java, and HTML.
238
-
239
- COMMON STARTING COMMANDS: orient, about, impact, trace, find
240
-
241
- QUICK GUIDE: choosing the right command:
242
- New/unfamiliar repo → orient (size, top dirs, hot functions, entry points, readiness; run FIRST)
243
- Understand a symbol → about (everything), context (callers/callees only), smart (code + called functions inline)
244
- Before modifying → impact (all call sites with args), verify (signature check), plan (preview refactor)
245
- Execution flow → trace (function call tree) or graph (file imports/exports)
246
- Find code → find (by name), search (by text), toc (project overview)
247
- Extract code → fn, class, lines (avoid reading whole files)
248
-
249
- Commands:
250
-
251
- UNDERSTANDING CODE:
252
- - about <name>: Definition, source, callers, callees, and tests in one call. Replaces 3-4 grep+read cycles. Your first stop for any function or class. Pass git=true for last-modified, author, and recent-changes (last 30d).
253
- - context <name>: Who calls it and what does it call, without source code. Results are numbered for use with expand. For classes/structs, shows all methods instead.
254
- - impact <name>: Every call site with actual arguments passed, grouped by file. Use it before changing a function signature to see the affected sites.
255
- - blast <name>: Transitive blast radius through callers of callers. Shows the full chain of functions affected by a change. Use depth (default: 3) to control how far up the chain to walk.
256
- - smart <name>: Get a function's source with all called functions expanded inline (not constants/variables). Use to understand or modify a function and its dependencies in one read.
257
- - trace <name>: Call tree from a function downward. Use to understand "what happens when X runs" and which modules a pipeline touches. Set depth (default: 3); setting depth expands all children.
258
- - example <name>: Best real-world usage example. Automatically scores call sites by quality and returns the top one with context. Use to understand expected calling patterns. Set diverse=true to cluster call sites by argument shape and return one representative per cluster (pair with top=N, default 3).
259
- - reverse_trace <name>: Upward call chain to entry points. Use to find paths that lead to a function. Set depth (default: 5) to control how far up. This complements trace, which goes downward.
260
- - related <name>: Sibling functions: same file, similar names, or shared callers/callees. Find companions to update together (e.g., serialize when you're changing deserialize). Name-based, not semantic.
261
- - brief <name>: Compact summary of a function: typed signature, first sentence of docstring, side-effect classification (fs/network/process/global_mutation), complexity (branches, depth, lines). Cheaper than about; more useful than fn when you don't need the body. Pass git=true for last-modified info.
262
-
263
- FINDING CODE:
264
- - find <name>: Locate definitions ranked by usage count. Supports glob patterns (e.g. find "handle*" or "_update*"). Use when you know the name but not the file.
265
- - usages <name>: See every usage organized by type: definitions, calls, imports, references. Complete picture of how something is used. Use code_only=true to skip comments/strings.
266
- - toc: Get a quick overview of an unfamiliar project: file counts, line counts, function/class counts, and entry points. Use detailed=true for full symbol listing.
267
- - search <term>: Text search (like grep, respects .gitignore). Supports regex by default (e.g. "\\d+" or "foo|bar"). Supports context=N for surrounding lines, exclude/in for file filtering. Case-insensitive by default; set case_sensitive=true for exact case. Invalid regex auto-falls back to plain text. STRUCTURAL MODE: Add type=function|class|call|method|type to query the symbol index instead of text. Combine with param=, returns=, decorator=, receiver= (for calls), exported=true, unused=true. Term becomes optional name filter (glob). Example: type=function, param=Request → all functions taking Request.
268
- - tests <name>: Find test files covering a function, test case names, and how it's called in tests. Use before modifying or to find test patterns to follow.
269
- - affected_tests <name>: Which tests to run after changing a function. Combines blast (transitive callers) with test detection. Shows test files, coverage %, and uncovered functions. Use depth= to control depth.
270
- - deadcode: Generate unreferenced-symbol candidates for review. Never treat the result as standalone deletion proof. Exported, decorated, and test symbols are excluded by default; use include_exported/include_decorated/include_tests to expand the audit.
271
- - entrypoints: Detect framework entry points: routes, handlers, DI providers, tasks. Auto-detects Express, Flask, Spring, Gin, Actix, and more. Use framework= to filter by specific framework.
272
- - endpoints: HTTP API surface with server routes and client requests. Use bridge=true to match clients to servers across language boundaries; method=/prefix= to filter; server_only/client_only to reduce output.
273
-
274
- EXTRACTING CODE (use instead of reading entire files):
275
- - fn <name>: Extract one or more functions. Comma-separated for bulk extraction (e.g. "parse,format,validate"). Use file to disambiguate.
276
- - class <name>: Extract a class/struct/interface with all its methods. Handles all supported types: JS/TS, Python, Go, Rust, Java. Large classes (>200 lines) show summary; use max_lines for truncated source.
277
- - lines: Extract specific lines (e.g. range="10-20" or just "15"). Requires file and range. Use when you know the exact line range you need.
278
- - expand <item>: Drill into a numbered item from the last context result. Run context first in the same session. Use expand to see the selected source code.
279
-
280
- FILE DEPENDENCIES (require file param):
281
- - imports: All imports with resolved file paths. Use to understand dependencies before modifying or moving a file. Resolves relative, package, and language-specific patterns.
282
- - exporters: Every file that imports or depends on this file. Use before moving, renaming, or deleting.
283
- - file_exports: File's public API: all exported functions, classes, variables with signatures. Use to understand what a module offers before importing. Requires explicit export markers; use toc --detailed as fallback.
284
- - graph: File-level dependency tree. Use it to understand module clusters and dependency chains. Set direction ("imports"/"importers"/"both"). Use depth=1 for large codebases.
285
- - circular_deps: Detect circular import chains. Shows cycle paths and involved files. Use file= to check a specific file, exclude= to ignore paths.
286
-
287
- REFACTORING:
288
- - verify <name>: Check all call sites match function signature (argument count). Run before adding/removing parameters to catch breakage early.
289
- - plan <name>: Preview refactoring: before/after signatures and call sites needing updates. Use add_param (with optional default_value), remove_param, or rename_to. Pair with verify.
290
- - diff_impact: Which functions changed in git diff and who calls them. Use to understand impact of recent changes before committing or reviewing. Use base, staged, or file params to scope.
291
- - check: Pre-commit lint of pending changes against the index. Composes diff_impact + verify + affected_tests; flags ADDED functions with zero callers (ORPHAN), BROKEN_IMPORT, signature drift across call sites, and recommends which tests to run. Use base= to compare against a branch, staged=true for staged changes only.
292
-
293
- DIAGNOSTICS:
294
- - doctor: Task-specific readiness report with index health, semantic blind spots, command proof classification, and navigation/refactor/deletion levels. deep=true adds a stratified resolution-evidence profile; it is not an accuracy estimate. Use in= to scope to a subtree.
295
- - orient: One-screen repo orientation for a codebase you just entered: size + language mix, densest directories, most-called functions, entry-point counts, and the trust verdict. Best FIRST command in a new repo.
296
-
297
- OTHER:
298
- - typedef <name>: Find type definitions matching a name: interfaces, enums, structs, traits, type aliases. See field shapes, required methods, or enum values.
299
- - stacktrace: Parse a stack trace, show source context per frame. Requires stack param. Handles JS, Python, Go, Rust, Java formats.
300
- - api: Public API surface of project or file: all exported/public symbols with signatures. Use to understand what a library exposes. Pass file to scope to one file. Python needs __all__; use toc instead.
301
- - stats: Quick project stats: file counts, symbol counts, lines of code by language and symbol type. Use functions=true for per-function line counts sorted by size (complexity audit). Set hot=true with top=N for the most-called functions (project orientation primitive).
302
- - audit_async: Find async calls inside async functions that are likely missing await (probable bugs). JS/TS/Python only. Filter with file/exclude/limit.
303
-
304
- READING OUTPUT (trust contract):
305
- - Caller/impact answers partition literal-name text lines. CONFIRMED entries carry binding/receiver/import evidence; UNVERIFIED entries are possible callers without target proof. ACCOUNT reconciles that text ground set. CONTRACT states the boundary explicitly.
306
- - A zero account is an observed-text zero, not proof of zero semantic callers: aliases, indirect calls, generated code, and runtime dispatch can exist. Never use it alone as safe-delete evidence; review usages/deadcode, warnings, and tests.
307
- - WARNING lines list unparsed files containing the symbol. Their lines were not analyzed; fall back to text search there.
308
- - verify arg-checks and plan plans CONFIRMED sites only; their UNVERIFIED CALL SITES sections list candidates to review manually. check reports "N callers (+M unverified)" per changed function.
309
- - context/smart/trace also account the callee side (CALLEE ACCOUNT line + unverified callees with reasons).
310
- - Advisory commands (related, example, stacktrace, endpoints bridge=true) mark output "Advisory:". These are ranked heuristics, not verified claims. Other semantic answers expose their evidence/account boundaries.` + generateMcpParamSection();
311
-
312
- const CONCISE_TOOL_DESCRIPTION = `AST code intelligence for JavaScript/TypeScript, Python, Go, Rust, Java, and HTML.
313
-
314
- Start:
315
- - orient: repo map and task-specific readiness.
316
- - find: definitions by name; brief: cheap signature/summary; about: compact symbol card.
317
-
318
- Understand and change:
319
- - context: direct callers/callees. impact: caller sites and arguments.
320
- - trace: downward execution tree. reverse_trace/blast: upward/transitive impact.
321
- - fn/class/lines: extract only the source needed. smart: target plus dependencies.
322
- - verify: confirmed-site arity check. plan: refactor preview. check/diff_impact: change preflight.
323
- - tests/affected_tests: relevant tests. usages: all AST usage kinds. deadcode: conservative candidate list.
324
-
325
- Architecture and search:
326
- - toc/stats/api/entrypoints: project surface. imports/exporters/file_exports/graph/circular_deps: file graph.
327
- - search: text or structural query. endpoints: server/client HTTP surface. typedef: types.
328
- - example/related/stacktrace/endpoints bridge=true are advisory heuristics.
329
-
330
- Trust contract:
331
- - CONFIRMED means binding/receiver/import evidence supports this target. UNVERIFIED means possible target; review it before a breaking change.
332
- - ACCOUNT conserves literal-name text lines only. CONTRACT states whether that partition is complete and always warns that semantic completeness is not proven. A clean zero is observed-text zero, never standalone deletion proof.
333
- - WARNING means parse/read blind spots. FILTERED means the displayed answer intentionally hides accounted entries.
334
- - Numeric confidence fields are ordinal evidence weights, not probabilities or calibrated accuracy.
335
- - MCP defaults about/context/impact to compact output; set compact=false for expressions/source. Truncated responses preserve contract metadata and expose structuredContent.truncated.
336
- - Use doctor deep=true for task readiness plus a stratified evidence profile; the profile is not an accuracy measurement.
337
- ` + generateMcpParamSection();
338
-
339
- const TOOL_DESCRIPTION = process.env.UCN_MCP_VERBOSE_DESCRIPTION === '1'
340
- ? VERBOSE_TOOL_DESCRIPTION
341
- : CONCISE_TOOL_DESCRIPTION;
235
+ const CONCISE_TOOL_DESCRIPTION = `Auditable AST code intelligence for JavaScript/TypeScript, Python, Go, Rust, Java, C, C++, C#, and HTML.
236
+
237
+ Use UCN for semantic questions: exact definitions, symbol-aware callers/callees, change impact, test selection, dependencies, APIs, entry points, and focused audits. Use grep/ripgrep for simple literals, error messages, configuration, filenames, Markdown, and unsupported languages. UCN search is worthwhile when AST structure, code-only filtering, or the shared agent contract matters.
238
+
239
+ 18 task-oriented commands:
240
+ - repo: repository overview; sections=files,stats,health adds detail. Reports skipped unsupported source explicitly and hands it to grep/language-native tooling.
241
+ - show: one symbol; sections=summary,callers,callees,source,dependencies,tests,types,example,related.
242
+ - find/usages/search/source: locate definitions, literal-name occurrences, literal text (regex=true selects the linear-time RE2-compatible engine), AST structure, or exact source. Find activity is pinned confirmed + visible-unverified candidates; proved other-target calls are separate.
243
+ - trace: direction=callees or callers; to=entrypoints follows callers to roots.
244
+ - impact: symbol impact when name is set; Git-diff impact when it is omitted.
245
+ - tests: static direct links by default; depth>0 includes transitive affected links. Empty results are not runtime coverage proof.
246
+ - deps/api/entrypoints/endpoints: architecture and public surfaces.
247
+ - check: symbol signature check when name is set; precommit check when omitted. plan previews the selected declaration plus indexed call/import/export edits.
248
+ - deadcode/audit_async/stacktrace: focused audits and runtime evidence. Computed dispatch is reported as a health/deletion blind spot; unknown decorators and member-assigned handlers are withheld from default dead-code claims.
249
+
250
+ CONFIRMED carries target-identity evidence. UNVERIFIED is possible and requires review. ACCOUNT conserves observed literal-name lines; an observed-text zero never claims semantic completeness or safe deletion. Evidence weights are ordinal, not probabilities. Warnings, excluded reasons, and contract metadata remain visible when caller evidence is returned. Release evaluation cross-checks overlapping stable-handle claims across find, show, source, impact, tests, check, and caller trace, and compares pinned real-repository samples with ts-morph, Pyright, gopls, rust-analyzer, JDT LS, clangd, and Roslyn.` + generateMcpParamSection();
251
+
252
+ // The default description is the only public contract. The former verbose
253
+ // description is intentionally not selectable: it documented retired commands
254
+ // and made the single MCP tool difficult for agents to parse.
255
+ const TOOL_DESCRIPTION = CONCISE_TOOL_DESCRIPTION;
256
+
257
+ // The schema shape is named so the handler can distinguish known keys from
258
+ // unknown/typo'd/camelCase ones — z.object() would otherwise strip them
259
+ // silently, and a silently ignored parameter changes the answer with no
260
+ // signal (e.g. include_test:true returning untested-filtered results).
261
+ const INPUT_SHAPE = {
262
+ command: null, // assigned below (needs z at load time)
263
+ };
342
264
 
343
265
  server.registerTool(
344
266
  'ucn',
345
267
  {
346
268
  description: TOOL_DESCRIPTION,
347
- inputSchema: z.object({
348
- command: z.enum(getMcpCommandEnum()),
349
- project_dir: z.string().describe('Absolute or relative path to the project root directory'),
350
- name: z.string().optional().describe('Symbol name to analyze. For fn: comma-separated for bulk (e.g. "parse,format"). For find: supports glob patterns (e.g. "handle*").'),
351
- file: z.string().optional().describe('File path (imports/exporters/graph/file_exports/lines/api/diff_impact) or filter pattern for disambiguation (e.g. "parser", "src/core")'),
269
+ inputSchema: z.object(Object.assign(INPUT_SHAPE, {
270
+ // Runtime validation stays string-based so retired v4 names reach
271
+ // directive migration guidance. Zod metadata publishes the strict
272
+ // v5 enum to MCP clients without making retired names aliases.
273
+ command: z.string()
274
+ .meta({ enum: getMcpCommandEnum() })
275
+ .describe(`UCN task command. One of: ${getMcpCommandEnum().join(', ')}.`),
276
+ project_dir: z.string().trim().min(1, 'project_dir is required and must be a non-empty path.').describe('Non-empty absolute path, or a path relative to the MCP server process working directory, identifying the project to analyze.'),
277
+ name: z.string().optional().describe('Symbol name or stable path:line:name handle. Used by show/find/usages/source/trace/impact/tests/check/plan.'),
278
+ file: z.string().optional().describe('File target for source/deps/api or a symbol-disambiguation filter.'),
279
+ sections: z.string().optional().describe('Comma-separated projection. show: summary,callers,callees,source,dependencies,tests,types,example,related. repo: summary,files,stats,health.'),
352
280
  exclude: z.string().optional().describe('Comma-separated patterns to exclude (e.g. "test,mock,vendor")'),
353
281
  include_tests: z.boolean().optional().describe('Include test files in results (excluded by default)'),
354
- exclude_tests: z.boolean().optional().describe('Exclude test files from results. Used by entrypoints (where tests are included by default).'),
355
- include_methods: z.boolean().optional().describe('Include obj.method() callee expansion in trace/blast. No effect on about/context/impact/verify; method calls are always analyzed and tiered by receiver evidence'),
356
- include_uncertain: z.boolean().optional().describe('No effect on tiered commands (about/context/impact/trace/blast/reverse_trace/affected_tests/verify/smart); unverified candidates are always shown with reasons'),
357
- expand_unverified: z.boolean().optional().describe('blast/reverse_trace: follow unverified caller edges in the tree; downstream nodes are marked as possible, not confirmed, impact chains'),
282
+ exclude_tests: z.boolean().optional().describe('Explicit spelling of the default test-file exclusion (entrypoints). Use include_tests=true to include test files.'),
283
+ include_methods: z.boolean().optional().describe('Include method callees where receiver evidence permits. Caller-bearing views always tier method sites.'),
284
+ expand_unverified: z.boolean().optional().describe('trace callers: follow unverified edges; downstream nodes remain marked possible, never confirmed.'),
358
285
  min_confidence: z.number().min(0).max(1).optional().describe('Minimum ordinal evidence weight (legacy name; not a probability) for caller/callee edges'),
359
- show_confidence: z.boolean().optional().describe('Show resolution-evidence labels. Numeric weights are ordinal, not probabilities.'),
360
- hide_confidence: z.boolean().optional().describe('Hide resolution-evidence labels (alias of show_confidence=false).'),
361
- unreachable_only: z.boolean().optional().describe('Show only callers/callees that are unreachable from any detected entry point (about, context, impact).'),
362
- with_types: z.boolean().optional().describe('Include related type definitions in output'),
363
- detailed: z.boolean().optional().describe('Show full symbol listing per file'),
286
+ show_confidence: z.boolean().optional().describe('show: resolution-evidence labels default to visible; set false to hide them. Numeric weights are ordinal, not probabilities.'),
287
+ unreachable_only: z.boolean().optional().describe('show/impact: retain only relationships unreachable from detected entry points.'),
288
+ with_types: z.boolean().optional().describe('show: include related type definitions.'),
289
+ with_source: z.boolean().optional().describe('Attach exact source to find results.'),
290
+ detailed: z.boolean().optional().describe('repo files: show symbols per file; deps: include import declarations and importers.'),
364
291
  exact: z.boolean().optional().describe('Exact name match only (no substring matching)'),
365
292
  in: z.string().optional().describe('Only search in this directory path (e.g. "src/core")'),
366
293
  top: z.number().int().positive().max(10000).optional().describe('Max results to show (default: 10). Must be a positive integer.'),
367
- depth: z.number().int().nonnegative().max(100).optional().describe('Max depth (default: 3 for trace, 2 for graph); expands all children. Non-negative integer.'),
294
+ depth: z.number().int().nonnegative().max(100).optional().describe('Max depth (default: 3 for trace, 2 for deps); expands all children. Non-negative integer.'),
368
295
  code_only: z.boolean().optional().describe('Exclude matches in comments and strings'),
369
296
  context: z.number().int().nonnegative().max(1000).optional().describe('Lines of context around each match. Non-negative integer.'),
370
297
  include_exported: z.boolean().optional().describe('Include exported symbols in deadcode results'),
371
298
  include_decorated: z.boolean().optional().describe('Include decorated/annotated symbols in deadcode results'),
372
- calls_only: z.boolean().optional().describe('Only direct calls and test-case matches (tests command)'),
373
- max_lines: z.number().int().positive().max(1000000).optional().describe('Max source lines for class (large classes show summary by default). Must be a positive integer.'),
374
- direction: z.enum(['imports', 'importers', 'both']).optional().describe('Graph direction: imports (what this file uses), importers (who uses this file), both (default: both)'),
375
- term: z.string().optional().describe('Search term (regex by default; set regex=false to force plain text)'),
376
- regex: z.boolean().optional().describe('Treat search term as a regex pattern (default: true). Set false to force plain text escaping.'),
377
- functions: z.boolean().optional().describe('Include per-function line counts in stats output, sorted by size (complexity audit)'),
378
- hot: z.boolean().optional().describe('Include top N most-called functions in stats output (orientation primitive). Pair with top=N (default 10).'),
379
- diverse: z.boolean().optional().describe('For example: cluster call sites by argument shape and return one representative per cluster. Pair with top=N (default 3).'),
380
- git: z.boolean().optional().describe('Attach git enrichment (last modified, author, recent change count last 30d) to about/brief output. Returns gracefully when not a git repo.'),
299
+ calls_only: z.boolean().optional().describe('tests: retain direct calls and test-case matches only.'),
300
+ max_lines: z.number().int().positive().max(1000000).optional().describe('source: maximum lines for large class-like declarations.'),
301
+ direction: z.enum(['callees', 'callers', 'imports', 'importers', 'both']).optional().describe('trace: callees/callers. deps: imports/importers/both.'),
302
+ to: z.enum(['entrypoints']).optional().describe('trace with direction=callers: continue toward entry points.'),
303
+ cycles: z.boolean().optional().describe('deps: report circular imports instead of a file graph.'),
304
+ term: z.string().optional().describe('Literal search term by default. Set regex=true only for regular-expression syntax.'),
305
+ regex: z.boolean().optional().describe('Treat search term as a regular expression (default: false/literal). Ordinary patterns run in an RE2-compatible linear-time engine; unsafe nested repetition is rejected.'),
306
+ functions: z.boolean().optional().describe('repo stats: include per-function line counts sorted by size.'),
307
+ hot: z.boolean().optional().describe('repo stats: include the top N most-called functions.'),
308
+ diverse: z.boolean().optional().describe('show example: return representatives from distinct argument shapes.'),
309
+ git: z.boolean().optional().describe('show summary: attach last-modified, author, and recent-change metadata.'),
381
310
  add_param: z.string().optional().describe('Parameter name to add (plan command)'),
382
311
  remove_param: z.string().optional().describe('Parameter name to remove (plan command)'),
383
312
  rename_to: z.string().optional().describe('New function name (plan command)'),
384
313
  default_value: z.string().optional().describe('Default value for added parameter (plan command)'),
385
314
  stack: z.string().optional().describe('The stack trace text to parse (stacktrace command)'),
386
- item: z.number().int().positive().max(1000000).optional().describe('Item number from context output to expand (e.g. 1, 2, 3). Must be a positive integer.'),
387
- range: z.string().optional().describe('Line range to extract, e.g. "10-20" or "15" (lines command)'),
315
+ range: z.string().optional().describe('source line range, e.g. "10-20" or "15"; requires file.'),
388
316
  base: z.string().optional().describe('Git ref to diff against (default: HEAD). E.g. "HEAD~3", "main", a commit SHA'),
389
- staged: z.boolean().optional().describe('Analyze staged changes (diff_impact command)'),
390
- deep: z.boolean().optional().describe('Run deeper analysis (doctor: sample the ordinal resolution-evidence profile, not accuracy)'),
391
- compact: z.boolean().optional().describe('Token-efficient output for about/context/impact. Defaults true on MCP; set false when source expressions are required.'),
317
+ staged: z.boolean().optional().describe('impact/check without a symbol: analyze staged changes.'),
318
+ deep: z.boolean().optional().describe('repo: include health and sample the ordinal resolution-evidence profile, not accuracy.'),
319
+ compact: z.boolean().optional().describe('Compact output defaults to true for show/impact and false for usages; set the opposite value to change that command\'s presentation.'),
392
320
  case_sensitive: z.boolean().optional().describe('Case-sensitive search (default: false, case-insensitive)'),
393
- all: z.boolean().optional().describe('Show all results (expand truncated sections). Applies to about, toc, related, trace, and others.'),
394
- top_level: z.boolean().optional().describe('Show only top-level functions in toc (exclude nested/indented)'),
321
+ all: z.boolean().optional().describe('Lift formatter and result caps where supported.'),
322
+ top_level: z.boolean().optional().describe('repo files: show only top-level functions.'),
395
323
  class_name: z.string().optional().describe('Class name to scope method analysis (e.g. "MarketDataFetcher" for close)'),
396
324
  line: z.number().int().positive().optional().describe('Definition line pin. Resolves the symbol defined at this exact line (the middle component of a file:line:name handle). Disambiguates same-file same-name definitions.'),
397
- limit: z.number().int().positive().max(1000000).optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, toc --detailed. Must be a positive integer.'),
325
+ limit: z.number().int().positive().max(1000000).optional().describe('Max results to return (default: 500). Caps find, usages, search, deadcode, api, and repo files. Must be a positive integer.'),
398
326
  max_files: z.number().int().positive().max(10000000).optional().describe('Max files to index (default: 10000). Use for very large codebases. Must be a positive integer.'),
399
327
  max_chars: z.number().int().positive().max(100000).optional().describe('Max output chars before truncation. Targeted commands default to 10K; broad commands default to 3K. Maximum: 100K. all=true lifts formatter caps but keeps the 100K transport ceiling.'),
400
328
  // Structural search flags (search command)
401
- type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type. Triggers index-based search.'),
329
+ type: z.string().optional().describe('Symbol type filter for structural search: function, class, call, method, type, state, field, constant, macro. Triggers index-based search.'),
402
330
  param: z.string().optional().describe('Filter by parameter name or type (structural search). E.g. "Request", "ctx".'),
403
331
  receiver: z.string().optional().describe('Filter calls by receiver (structural search, type=call). E.g. "db", "http".'),
404
332
  returns: z.string().optional().describe('Filter by return type (structural search). E.g. "Promise", "error".'),
@@ -416,29 +344,60 @@ server.registerTool(
416
344
  prefix: z.string().optional().describe('Filter routes/requests by path prefix (endpoints command).'),
417
345
  hide_uncertain: z.boolean().optional().describe('Hide uncertain (interpolated-path) bridges (endpoints command).')
418
346
 
419
- })
347
+ })).passthrough()
420
348
  },
421
349
  async (args) => {
422
350
  const { command, project_dir, ...rawParams } = args;
423
351
 
352
+ // Defensive validation for direct/internal handler calls. MCP clients
353
+ // normally fail at the strict command enum before reaching this path.
354
+ if (!resolveCommand(command, 'mcp')) {
355
+ const migration = v4MigrationHint(command, 'mcp');
356
+ if (migration) return toolError(`Unknown command: ${command}. ${migration}`);
357
+ const suggestion = suggestCommand(command, 'mcp');
358
+ return toolError(`Unknown command: ${command}.` +
359
+ (suggestion ? ` Did you mean "${suggestion}"?` : '') +
360
+ ` Valid commands: ${getMcpCommandEnum().join(', ')}.`);
361
+ }
362
+
363
+ // Unknown, typo'd, or camelCase-spelled parameters must never
364
+ // silently no-op. They are dropped, and each one gets a Note.
365
+ const unknownParamNotes = collectUnknownParamNotes(rawParams);
366
+
424
367
  // Normalize ALL params once — execute() handlers pick what they need.
425
368
  // This eliminates per-case param selection and prevents CLI/MCP drift.
426
369
  const ep = normalizeParams(rawParams);
427
370
 
428
- // Translate hide_confidence → showConfidence:false (canonical inverse).
429
- if (ep.hideConfidence === true && ep.showConfidence === undefined) {
430
- ep.showConfidence = false;
431
- }
432
- delete ep.hideConfidence;
433
-
434
371
  // Strip params not applicable to this command (prevents silent no-ops).
435
372
  // Global/core params are always allowed — only optional flags are filtered.
436
373
  // FLAG_APPLICABILITY is keyed by canonical (camelCase) names, but `command`
437
374
  // is the MCP (snake_case) name — resolve to canonical first to avoid
438
- // silently skipping multi-word commands (circular_deps, diff_impact, etc.).
375
+ // silently skipping multi-word commands such as audit_async.
439
376
  const strippedParams = [];
440
377
  const canonicalCommand = resolveCommand(command, 'mcp') || command;
441
378
  const applicable = FLAG_APPLICABILITY[canonicalCommand];
379
+ let projectScopeNote = null;
380
+ // Match the CLI's directory-target convenience. The index still lives
381
+ // at the repository root for cross-file resolution, while commands
382
+ // supporting `in` receive the requested subdirectory as an implicit
383
+ // result scope. Commands without such a scope disclose the widening.
384
+ try {
385
+ const requestedDir = path.resolve(project_dir);
386
+ if (fs.existsSync(requestedDir) && fs.statSync(requestedDir).isDirectory()) {
387
+ const projectRoot = findProjectRoot(requestedDir);
388
+ if (requestedDir !== projectRoot && requestedDir.startsWith(projectRoot + path.sep)) {
389
+ const relativeScope = path.relative(projectRoot, requestedDir);
390
+ if (applicable?.includes('in')) {
391
+ if (!ep.in) ep.in = relativeScope;
392
+ projectScopeNote = `project_dir resolved to repository root; results scoped with in=${ep.in}.`;
393
+ } else {
394
+ projectScopeNote = `project_dir resolved to repository root ${projectRoot}; '${command}' has no in parameter, so results cover the repository root.`;
395
+ }
396
+ }
397
+ }
398
+ } catch (_) {
399
+ // getIndex below owns the authoritative path error.
400
+ }
442
401
  if (applicable) {
443
402
  // Truly global options — apply to all commands (build/display control).
444
403
  // Command-specific params (name, term, stack, range, etc.) are in FLAG_APPLICABILITY.
@@ -454,15 +413,26 @@ server.registerTool(
454
413
  }
455
414
 
456
415
  // all=true lifts formatter caps and raises MCP output to its hard ceiling.
457
- const maxChars = ep.all ? MAX_OUTPUT_CHARS : ep.maxChars;
416
+ // `all` lifts formatter caps; an explicit transport budget remains a
417
+ // hard caller-controlled ceiling.
418
+ const maxChars = ep.maxChars ?? (ep.all ? MAX_OUTPUT_CHARS : undefined);
458
419
 
459
420
  // Build stripping note (appended inside truncation boundary on success paths)
460
- const strippedNote = strippedParams.length > 0
461
- ? `\n\nNote: ${strippedParams.join(', ')} ignored (not applicable to ${command}).`
421
+ const noteParts = [];
422
+ if (strippedParams.length > 0) {
423
+ noteParts.push(`${strippedParams.join(', ')} ignored (not applicable to ${command}).`);
424
+ }
425
+ if (ep.includeMethods && ['impact', 'trace', 'tests', 'check'].includes(canonicalCommand)) {
426
+ noteParts.push(`include_methods=true has no effect on '${command}' — method calls are always tiered by receiver evidence.`);
427
+ }
428
+ if (projectScopeNote) noteParts.push(projectScopeNote);
429
+ noteParts.push(...unknownParamNotes);
430
+ const strippedNote = noteParts.length > 0
431
+ ? '\n\n' + noteParts.map(part => `Note: ${part}`).join('\n')
462
432
  : '';
463
433
 
464
434
  // Wrap toolResult to auto-inject command + maxChars + stripping note
465
- const tr = (text) => toolResult(text, command, maxChars, strippedNote);
435
+ const tr = (text) => toolResult(text, command, maxChars, strippedNote, ep);
466
436
  // Wrap toolError to include stripping note on error paths too
467
437
  const te = strippedNote
468
438
  ? (msg) => toolError(msg + strippedNote)
@@ -478,424 +448,72 @@ server.registerTool(
478
448
  .replace(/--detailed\b/g, 'detailed=true')
479
449
  .replace(/--all\b/g, 'all=true')
480
450
  .replace(/--expand-unverified\b/g, 'expand_unverified=true')
481
- .replace(/--include-uncertain\b/g, 'include_uncertain=true')
482
451
  .replace(/--(\w[\w-]*)/g, (_m, f) => f.replace(/-/g, '_'));
483
452
 
484
453
  let index = null; // Track for post-command cache save
485
454
  try {
486
- switch (command) {
487
-
488
- // ==================================================================
489
- // UNDERSTANDING CODE
490
- // ==================================================================
491
-
492
- // ── Commands using shared executor ─────────────────────────
493
-
494
- case 'about': {
495
- index = getIndex(project_dir, ep);
496
- const { ok, result, error, note } = execute(index, 'about', ep);
497
- if (!ok) return te(error);
498
- let aboutText = output.formatAbout(result, {
499
- allHint: 'Repeat with all=true to show all.',
500
- showConfidence: ep.showConfidence !== false,
501
- compact: ep.compact !== false,
502
- });
503
- if (note) aboutText += '\n\n' + mn(note);
504
- return tr(aboutText);
505
- }
506
-
507
- case 'context': {
508
- index = getIndex(project_dir, ep);
509
- const { ok, result: ctx, error, note } = execute(index, 'context', ep);
510
- if (!ok) return te(error);
511
- const { text, expandable } = output.formatContext(ctx, {
512
- expandHint: 'Use expand command with item number to see code for any item.',
513
- showConfidence: ep.showConfidence !== false,
514
- compact: ep.compact !== false,
515
- });
516
- expandCacheInstance.save(index.root, ep.name, ep.file, expandable);
517
- let ctxText = text;
518
- if (note) ctxText += '\n\n' + mn(note);
519
- return tr(ctxText);
520
- }
521
-
522
- case 'impact': {
523
- index = getIndex(project_dir, ep);
524
- const { ok, result, error, note } = execute(index, 'impact', ep);
525
- if (!ok) return te(error);
526
- let impactText = output.formatImpact(result, { compact: ep.compact !== false });
527
- if (note) impactText += '\n\n' + mn(note);
528
- return tr(impactText);
529
- }
530
-
531
- case 'blast': {
532
- index = getIndex(project_dir, ep);
533
- const { ok, result, error, note } = execute(index, 'blast', ep);
534
- if (!ok) return te(error);
535
- let blastText = output.formatBlast(result, {
536
- allHint: 'Set depth to expand all children.',
537
- });
538
- if (note) blastText += '\n\n' + mn(note);
539
- return tr(blastText);
540
- }
541
-
542
- case 'smart': {
543
- index = getIndex(project_dir, ep);
544
- const { ok, result, error, note } = execute(index, 'smart', ep);
545
- if (!ok) return te(error);
546
- let smartText = output.formatSmart(result);
547
- if (note) smartText += '\n\n' + mn(note);
548
- return tr(smartText);
549
- }
550
-
551
- case 'trace': {
552
- index = getIndex(project_dir, ep);
553
- const { ok, result, error, note } = execute(index, 'trace', ep);
554
- if (!ok) return te(error);
555
- let traceText = output.formatTrace(result, {
556
- allHint: 'Set depth to expand all children.',
557
- methodsHint: 'Note: obj.method() calls excluded. Use include_methods=true to include them.'
558
- });
559
- if (note) traceText += '\n\n' + mn(note);
560
- return tr(traceText);
561
- }
562
-
563
- case 'reverse_trace': {
564
- index = getIndex(project_dir, ep);
565
- const { ok, result, error, note } = execute(index, 'reverseTrace', ep);
566
- if (!ok) return te(error);
567
- let rtText = output.formatReverseTrace(result, {
568
- allHint: 'Set depth to expand all children.',
569
- });
570
- if (note) rtText += '\n\n' + mn(note);
571
- return tr(rtText);
572
- }
573
-
574
- case 'example': {
575
- index = getIndex(project_dir, ep);
576
- const { ok, result, error } = execute(index, 'example', ep);
577
- if (!ok) return te(error);
578
- return tr(output.formatExample(result, ep.name));
579
- }
580
-
581
- case 'related': {
582
- index = getIndex(project_dir, ep);
583
- const { ok, result, error, note } = execute(index, 'related', ep);
584
- if (!ok) return te(error);
585
- let relText = output.formatRelated(result, {
586
- all: ep.all || false, top: ep.top,
587
- allHint: 'Repeat with all=true to show all.'
588
- });
589
- if (note) relText += '\n\n' + mn(note);
590
- return tr(relText);
591
- }
592
-
593
- case 'brief': {
594
- index = getIndex(project_dir, ep);
595
- const { ok, result, error } = execute(index, 'brief', ep);
596
- if (!ok) return te(error);
597
- return tr(output.formatBrief(result));
598
- }
599
-
600
- case 'doctor': {
601
- index = getIndex(project_dir, ep);
602
- const { ok, result, error } = execute(index, 'doctor', ep);
603
- if (!ok) return te(error);
604
- return tr(output.formatDoctor(result));
605
- }
606
-
607
- case 'orient': {
608
- index = getIndex(project_dir, ep);
609
- const { ok, result, error } = execute(index, 'orient', ep);
610
- if (!ok) return te(error);
611
- return tr(output.formatOrient(result));
612
- }
613
-
614
- case 'check': {
615
- index = getIndex(project_dir, ep);
616
- const { ok, result, error } = execute(index, 'check', ep);
617
- if (!ok) return te(error);
618
- return tr(output.formatCheck(result));
619
- }
620
-
621
- // ── Finding Code ────────────────────────────────────────────
622
-
623
- case 'find': {
624
- index = getIndex(project_dir, ep);
625
- const { ok, result, error, note } = execute(index, 'find', ep);
626
- if (!ok) return te(error);
627
- // Same formatter as every other surface (fix #250 — the
628
- // legacy formatFind had a different default limit, no stable
629
- // file:line:name handles, no confidence markers, and
630
- // silently ignored all/depth/compact).
631
- let text = output.formatFindDetailed(result, ep.name, {
632
- depth: ep.depth, top: ep.top, all: ep.all, compact: ep.compact,
633
- });
634
- if (note) text += '\n\n' + mn(note);
635
- return tr(text);
636
- }
637
-
638
- case 'usages': {
639
- index = getIndex(project_dir, ep);
640
- const { ok, result, error, note } = execute(index, 'usages', ep);
641
- if (!ok) return te(error);
642
- let text = output.formatUsages(result, ep.name, { compact: ep.compact });
643
- if (note) text += '\n\n' + mn(note);
644
- return tr(text);
645
- }
646
-
647
- case 'toc': {
648
- index = getIndex(project_dir, ep);
649
- const { ok, result, error, note } = execute(index, 'toc', ep);
650
- if (!ok) return te(error);
651
- let text = output.formatToc(result, {
652
- topHint: 'Set top=N or use detailed=false for compact view.'
653
- });
654
- if (note) text += '\n\n' + mn(note);
655
- return tr(text);
656
- }
657
-
658
- case 'search': {
659
- index = getIndex(project_dir, ep);
660
- const { ok, result, error, structural, note } = execute(index, 'search', ep);
661
- if (!ok) return te(error);
662
- let searchText;
663
- if (structural) {
664
- searchText = output.formatStructuralSearch(result);
665
- } else {
666
- searchText = output.formatSearch(result, ep.term);
667
- }
668
- if (note) searchText += '\n\n' + mn(note);
669
- return tr(searchText);
670
- }
671
-
672
- case 'tests': {
673
- index = getIndex(project_dir, ep);
674
- const { ok, result, error, note } = execute(index, 'tests', ep);
675
- if (!ok) return te(error);
676
- let testsText = output.formatTests(result, ep.name);
677
- if (note) testsText += '\n\n' + mn(note);
678
- return tr(testsText);
679
- }
680
-
681
- case 'affected_tests': {
682
- index = getIndex(project_dir, ep);
683
- const { ok, result, error, note } = execute(index, 'affectedTests', ep);
684
- if (!ok) return te(error);
685
- let atText = output.formatAffectedTests(result, { all: ep.all });
686
- if (note) atText += '\n\n' + mn(note);
687
- return tr(atText);
688
- }
689
-
690
- case 'deadcode': {
691
- index = getIndex(project_dir, ep);
692
- const { ok, result, error, note } = execute(index, 'deadcode', ep);
693
- if (!ok) return te(error);
694
- const dcNote = note;
695
- let dcText = output.formatDeadcode(result, {
696
- top: ep.top || 0,
697
- decoratedHint: !ep.includeDecorated && result.excludedDecorated > 0 ? `${result.excludedDecorated} decorated/annotated symbol(s) hidden (framework-registered). Use include_decorated=true to include them.` : undefined,
698
- exportedHint: !ep.includeExported && result.excludedExported > 0 ? `${result.excludedExported} exported symbol(s) excluded from the audit (public API may have external callers). Use include_exported=true to audit them.` : undefined,
699
- externalContractHint: !ep.includeExported && result.excludedExternalContract > 0 ? `${result.excludedExternalContract} symbol(s) hidden (override an out-of-tree base class — reachable via external contract, not dead). Use include_exported=true to include them.` : undefined
700
- });
701
- if (dcNote) dcText += '\n\n' + mn(dcNote);
702
- return tr(dcText);
703
- }
704
-
705
- case 'entrypoints': {
706
- index = getIndex(project_dir, ep);
707
- const { ok, result, error, note } = execute(index, 'entrypoints', ep);
708
- if (!ok) return te(error);
709
- let epText = output.formatEntrypoints(result);
710
- if (note) epText += '\n\n' + mn(note);
711
- return tr(epText);
712
- }
713
-
714
- case 'endpoints': {
715
- index = getIndex(project_dir, ep);
716
- const { ok, result, error, note } = execute(index, 'endpoints', ep);
717
- if (!ok) return te(error);
718
- let endText = output.formatEndpoints(result, { bridge: result._bridge, unmatched: result._unmatched });
719
- if (note) endText += '\n\n' + mn(note);
720
- return tr(endText);
721
- }
722
-
723
- // ── File Dependencies ───────────────────────────────────────
724
-
725
- case 'imports': {
726
- index = getIndex(project_dir, ep);
727
- const { ok, result, error } = execute(index, 'imports', ep);
728
- if (!ok) return te(error);
729
- return tr(output.formatImports(result, ep.file));
730
- }
731
-
732
- case 'exporters': {
733
- index = getIndex(project_dir, ep);
734
- const { ok, result, error } = execute(index, 'exporters', ep);
735
- if (!ok) return te(error);
736
- return tr(output.formatExporters(result, ep.file));
737
- }
738
-
739
- case 'file_exports': {
740
- index = getIndex(project_dir, ep);
741
- const { ok, result, error } = execute(index, 'fileExports', ep);
742
- if (!ok) return te(error);
743
- return tr(output.formatFileExports(result, ep.file));
744
- }
745
-
746
- case 'graph': {
747
- index = getIndex(project_dir, ep);
748
- const { ok, result, error } = execute(index, 'graph', ep);
749
- if (!ok) return te(error);
750
- return tr(output.formatGraph(result, {
751
- showAll: ep.all || ep.depth !== undefined,
752
- maxDepth: ep.depth ?? 2, file: ep.file,
753
- depthHint: 'Set depth parameter for deeper graph.',
754
- allHint: 'Set depth to expand all children.'
755
- }));
756
- }
757
-
758
- case 'circular_deps': {
759
- index = getIndex(project_dir, ep);
760
- const { ok, result, error } = execute(index, 'circularDeps', ep);
761
- if (!ok) return te(error);
762
- return tr(output.formatCircularDeps(result));
763
- }
764
-
765
- // ── Refactoring ─────────────────────────────────────────────
766
-
767
- case 'verify': {
768
- index = getIndex(project_dir, ep);
769
- const { ok, result, error } = execute(index, 'verify', ep);
770
- if (!ok) return te(error);
771
- return tr(output.formatVerify(result));
772
- }
773
-
774
- case 'plan': {
775
- index = getIndex(project_dir, ep);
776
- const { ok, result, error } = execute(index, 'plan', ep);
777
- if (!ok) return te(error);
778
- return tr(output.formatPlan(result));
779
- }
780
-
781
- case 'diff_impact': {
782
- index = getIndex(project_dir, ep);
783
- const { ok, result, error, note } = execute(index, 'diffImpact', ep);
784
- if (!ok) return te(error);
785
- let diText = output.formatDiffImpact(result, { all: ep.all });
786
- if (note) diText += '\n\n' + mn(note);
787
- return tr(diText);
788
- }
789
-
790
- // ── Other ───────────────────────────────────────────────────
791
-
792
- case 'typedef': {
793
- index = getIndex(project_dir, ep);
794
- const { ok, result, error } = execute(index, 'typedef', ep);
795
- if (!ok) return te(error);
796
- return tr(output.formatTypedef(result, ep.name));
797
- }
798
-
799
- case 'stacktrace': {
800
- index = getIndex(project_dir, ep);
801
- const { ok, result, error } = execute(index, 'stacktrace', ep);
802
- if (!ok) return te(error);
803
- return tr(output.formatStackTrace(result));
804
- }
805
-
806
- case 'api': {
807
- index = getIndex(project_dir, ep);
808
- const { ok, result, error, note } = execute(index, 'api', ep);
809
- if (!ok) return te(error);
810
- let apiText = output.formatApi(result, ep.file || '.');
811
- if (note) apiText += '\n\n' + mn(note);
812
- return tr(apiText);
813
- }
814
-
815
- case 'stats': {
816
- index = getIndex(project_dir, ep);
817
- const { ok, result, error, note } = execute(index, 'stats', ep);
818
- if (!ok) return te(error);
819
- let statsText = output.formatStats(result, { top: ep.top || 0 });
820
- if (note) statsText += '\n\n' + mn(note);
821
- return tr(statsText);
822
- }
823
-
824
- case 'audit_async': {
825
- index = getIndex(project_dir, ep);
826
- const { ok, result, error, note } = execute(index, 'auditAsync', ep);
827
- if (!ok) return te(error);
828
- let text = output.formatAuditAsync(result);
829
- if (note) text += '\n\n' + mn(note);
830
- return tr(text);
831
- }
832
-
833
- // ── Extracting Code (via execute) ────────────────────────────
834
-
835
- case 'fn': {
836
- index = getIndex(project_dir, ep);
837
- const { ok, result, error, note } = execute(index, 'fn', ep);
838
- if (!ok) return te(error);
839
- // MCP path security: validate all result files are within project root
840
- for (const entry of result.entries) {
841
- const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
455
+ // Public v5 has one MCP dispatch path. The canonical registry,
456
+ // execute() handler, and public formatter are shared with CLI;
457
+ // command-specific switches are no longer part of the contract.
458
+ index = getIndex(project_dir, ep);
459
+ // Validate an explicit source file before execute() can read it.
460
+ // Post-result checks are still retained below for symbol-derived
461
+ // paths, but a range request may point at a real file outside the
462
+ // project and must fail before any content leaves the process.
463
+ if (canonicalCommand === 'source' && ep.file) {
464
+ const sourcePath = resolveAndValidatePath(index, ep.file);
465
+ if (typeof sourcePath !== 'string') return sourcePath;
466
+ ep.file = path.relative(index.root, sourcePath);
467
+ }
468
+ const execution = execute(index, canonicalCommand, ep);
469
+ if (!execution.ok) {
470
+ return te(formatSurfaceMessage(execution.error, 'mcp'));
471
+ }
472
+
473
+ // Preserve MCP's path-boundary guarantee for source-bearing
474
+ // results after consolidating fn/class/lines into source/show.
475
+ const validateSource = (sourceResult) => {
476
+ if (!sourceResult) return null;
477
+ if (Array.isArray(sourceResult.entries)) {
478
+ for (const entry of sourceResult.entries) {
479
+ const file = entry.match?.relativePath || (entry.match?.file && path.relative(index.root, entry.match.file));
480
+ if (!file) continue;
481
+ const check = resolveAndValidatePath(index, file);
482
+ if (typeof check !== 'string') return check;
483
+ }
484
+ } else if (sourceResult.relativePath) {
485
+ const check = resolveAndValidatePath(index, sourceResult.relativePath);
842
486
  if (typeof check !== 'string') return check;
843
487
  }
844
- const fnText = (note ? mn(note) + '\n\n' : '') + output.formatFnResult(result);
845
- return tr(fnText);
846
- }
488
+ return null;
489
+ };
490
+ const sourceError = canonicalCommand === 'source'
491
+ ? validateSource(execution.result)
492
+ : (canonicalCommand === 'show' ? validateSource(execution.result.source) : null);
493
+ if (sourceError) return sourceError;
494
+
495
+ const presentationExecution = {
496
+ ...execution,
497
+ note: mn(execution.note),
498
+ surface: 'mcp',
499
+ };
500
+ return tr(output.formatPublicText(
501
+ canonicalCommand,
502
+ execution.result,
503
+ ep,
504
+ presentationExecution,
505
+ ));
847
506
 
848
- case 'class': {
849
- index = getIndex(project_dir, ep);
850
- const { ok, result, error, note } = execute(index, 'class', ep);
851
- if (!ok) return te(error); // soft error (class not found)
852
- // MCP path security: validate all result files are within project root
853
- for (const entry of result.entries) {
854
- const check = resolveAndValidatePath(index, entry.match.relativePath || path.relative(index.root, entry.match.file));
855
- if (typeof check !== 'string') return check;
856
- }
857
- const classText = (note ? mn(note) + '\n\n' : '') + output.formatClassResult(result);
858
- return tr(classText);
859
- }
860
-
861
- case 'lines': {
862
- index = getIndex(project_dir, ep);
863
- const { ok, result, error } = execute(index, 'lines', ep);
864
- if (!ok) return te(error);
865
- // MCP path security: validate file is within project root
866
- const check = resolveAndValidatePath(index, result.relativePath);
867
- if (typeof check !== 'string') return check;
868
- return tr(output.formatLines(result));
869
- }
870
-
871
- case 'expand': {
872
- if (ep.item === undefined || ep.item === null) {
873
- return te('Item number is required (e.g. item=1).');
874
- }
875
- index = getIndex(project_dir, ep);
876
- const lookup = expandCacheInstance.lookup(index.root, ep.item);
877
- const { ok, result, error } = execute(index, 'expand', {
878
- match: lookup.match, itemNum: ep.item,
879
- itemCount: lookup.itemCount, symbolName: lookup.symbolName,
880
- validateRoot: true
881
- });
882
- if (!ok) return te(error);
883
- return tr(result.text);
884
- }
885
-
886
- default:
887
- return te(`Unknown command: ${command}`);
888
- }
889
507
  } catch (e) {
890
508
  return te(e.message);
891
509
  } finally {
892
510
  // Persist calls cache after command execution.
893
511
  // getIndex() only saves after build (when callsCache is empty).
894
- // Commands like context/about/impact populate callsCache lazily,
512
+ // Commands like show/impact/trace populate callsCache lazily,
895
513
  // so we save here to avoid re-parsing all files on every MCP session.
896
514
  // MED-1: also persist when reachability was computed in-process so
897
515
  // long-lived MCP servers carry the BFS result forward to disk.
898
- if (index && (index.callsCacheDirty || index.reachabilityDirty)) {
516
+ if (index && (index.callsCacheDirty || index.reachabilityDirty || index.computedDispatchDirty)) {
899
517
  try { index.saveCache(); } catch (_) { /* best-effort */ }
900
518
  index.callsCacheDirty = false;
901
519
  }