gitnexus 1.6.11-rc.2 → 1.6.11-rc.21

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 (158) hide show
  1. package/README.md +43 -1
  2. package/dist/_shared/impact-risk.d.ts +37 -0
  3. package/dist/_shared/impact-risk.d.ts.map +1 -0
  4. package/dist/_shared/impact-risk.js +92 -0
  5. package/dist/_shared/impact-risk.js.map +1 -0
  6. package/dist/_shared/index.d.ts +2 -0
  7. package/dist/_shared/index.d.ts.map +1 -1
  8. package/dist/_shared/index.js +2 -0
  9. package/dist/_shared/index.js.map +1 -1
  10. package/dist/cli/ai-context.js +4 -4
  11. package/dist/cli/analyze-config.d.ts +2 -0
  12. package/dist/cli/analyze-config.js +16 -0
  13. package/dist/cli/analyze-options.d.ts +4 -0
  14. package/dist/cli/analyze.d.ts +17 -0
  15. package/dist/cli/analyze.js +43 -7
  16. package/dist/cli/eval-server.js +22 -5
  17. package/dist/cli/group.js +7 -1
  18. package/dist/cli/help-i18n.js +2 -0
  19. package/dist/cli/i18n/en.d.ts +12 -2
  20. package/dist/cli/i18n/en.js +12 -2
  21. package/dist/cli/i18n/resources.d.ts +22 -2
  22. package/dist/cli/i18n/zh-CN.d.ts +10 -0
  23. package/dist/cli/i18n/zh-CN.js +12 -2
  24. package/dist/cli/index.js +11 -4
  25. package/dist/cli/status.js +91 -6
  26. package/dist/cli/watch-queue.d.ts +41 -0
  27. package/dist/cli/watch-queue.js +184 -0
  28. package/dist/cli/watch.d.ts +20 -0
  29. package/dist/cli/watch.js +372 -0
  30. package/dist/cli/wiki.js +15 -2
  31. package/dist/config/ignore-service.d.ts +11 -0
  32. package/dist/config/ignore-service.js +41 -4
  33. package/dist/config/repo-control-file.d.ts +3 -0
  34. package/dist/config/repo-control-file.js +115 -0
  35. package/dist/core/group/config-parser.js +20 -2
  36. package/dist/core/group/cross-impact.d.ts +2 -1
  37. package/dist/core/group/cross-impact.js +33 -2
  38. package/dist/core/group/extractors/fs-utils.d.ts +2 -0
  39. package/dist/core/group/extractors/fs-utils.js +86 -0
  40. package/dist/core/group/extractors/graphql-extractor.d.ts +17 -0
  41. package/dist/core/group/extractors/graphql-extractor.js +652 -0
  42. package/dist/core/group/extractors/java-workspace-extractor.js +244 -31
  43. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  44. package/dist/core/group/extractors/manifest-extractor.js +1 -1
  45. package/dist/core/group/matching.js +8 -1
  46. package/dist/core/group/service.js +5 -1
  47. package/dist/core/group/storage.js +1 -0
  48. package/dist/core/group/sync.d.ts +3 -1
  49. package/dist/core/group/sync.js +43 -5
  50. package/dist/core/group/types.d.ts +19 -5
  51. package/dist/core/incremental/derived-writeback.d.ts +36 -0
  52. package/dist/core/incremental/derived-writeback.js +68 -0
  53. package/dist/core/incremental/subgraph-extract.d.ts +6 -4
  54. package/dist/core/incremental/subgraph-extract.js +7 -5
  55. package/dist/core/index-content-drift.d.ts +54 -0
  56. package/dist/core/index-content-drift.js +127 -0
  57. package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
  58. package/dist/core/ingestion/filesystem-walker.js +20 -3
  59. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.d.ts +22 -0
  60. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.js +124 -0
  61. package/dist/core/ingestion/language-provider.d.ts +43 -0
  62. package/dist/core/ingestion/languages/csharp/razor-view-components.d.ts +62 -0
  63. package/dist/core/ingestion/languages/csharp/razor-view-components.js +954 -0
  64. package/dist/core/ingestion/languages/csharp/resolution-config.d.ts +3 -0
  65. package/dist/core/ingestion/languages/csharp/resolution-config.js +6 -1
  66. package/dist/core/ingestion/languages/csharp/scope-resolver.js +8 -0
  67. package/dist/core/ingestion/languages/java/capture-side-channel.d.ts +4 -0
  68. package/dist/core/ingestion/languages/java/capture-side-channel.js +16 -0
  69. package/dist/core/ingestion/languages/java/captures.js +14 -1
  70. package/dist/core/ingestion/languages/java/lombok-synthesizer.d.ts +42 -0
  71. package/dist/core/ingestion/languages/java/lombok-synthesizer.js +439 -0
  72. package/dist/core/ingestion/languages/java/scope-resolver.js +2 -0
  73. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.d.ts +8 -0
  74. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.js +62 -0
  75. package/dist/core/ingestion/languages/java.js +2 -0
  76. package/dist/core/ingestion/languages/jvm/accessor-synthesis.d.ts +99 -0
  77. package/dist/core/ingestion/languages/jvm/accessor-synthesis.js +173 -0
  78. package/dist/core/ingestion/languages/jvm/beanspec.d.ts +17 -0
  79. package/dist/core/ingestion/languages/jvm/beanspec.js +42 -0
  80. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
  81. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
  82. package/dist/core/ingestion/languages/kotlin/captures.js +14 -1
  83. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.d.ts +26 -0
  84. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.js +417 -0
  85. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
  86. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.d.ts +8 -0
  87. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.js +77 -0
  88. package/dist/core/ingestion/languages/kotlin.js +2 -0
  89. package/dist/core/ingestion/pipeline-phases/di.js +47 -14
  90. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -1
  91. package/dist/core/ingestion/pipeline-phases/parse-impl.js +57 -86
  92. package/dist/core/ingestion/pipeline-phases/parse.d.ts +2 -0
  93. package/dist/core/ingestion/pipeline-phases/runner.d.ts +4 -1
  94. package/dist/core/ingestion/pipeline-phases/runner.js +34 -14
  95. package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
  96. package/dist/core/ingestion/pipeline.d.ts +6 -0
  97. package/dist/core/ingestion/pipeline.js +44 -12
  98. package/dist/core/ingestion/scope-extractor.js +1 -0
  99. package/dist/core/ingestion/utils/symbol-labels.d.ts +2 -2
  100. package/dist/core/ingestion/utils/symbol-labels.js +2 -2
  101. package/dist/core/ingestion/workers/parse-worker.js +28 -2
  102. package/dist/core/lbug/lbug-adapter.d.ts +59 -0
  103. package/dist/core/lbug/lbug-adapter.js +154 -1
  104. package/dist/core/run-analyze.d.ts +17 -0
  105. package/dist/core/run-analyze.js +231 -23
  106. package/dist/core/search/fts-indexes.d.ts +19 -1
  107. package/dist/core/search/fts-indexes.js +28 -1
  108. package/dist/core/wiki/generator.js +8 -0
  109. package/dist/core/wiki/grok-client.d.ts +21 -0
  110. package/dist/core/wiki/grok-client.js +287 -0
  111. package/dist/core/wiki/llm-client.d.ts +1 -1
  112. package/dist/core/wiki/llm-client.js +5 -2
  113. package/dist/core/wiki/local-cli-client.d.ts +11 -0
  114. package/dist/core/wiki/local-cli-client.js +22 -9
  115. package/dist/mcp/local/local-backend.d.ts +24 -7
  116. package/dist/mcp/local/local-backend.js +190 -81
  117. package/dist/mcp/local/pdg-impact.d.ts +8 -4
  118. package/dist/mcp/local/pdg-impact.js +7 -2
  119. package/dist/mcp/repository-policy.d.ts +5 -1
  120. package/dist/mcp/repository-policy.js +48 -4
  121. package/dist/mcp/resources.js +2 -1
  122. package/dist/mcp/server.js +6 -5
  123. package/dist/mcp/tools.js +31 -19
  124. package/dist/server/api.js +23 -64
  125. package/dist/server/grep-params.d.ts +18 -0
  126. package/dist/server/grep-params.js +83 -0
  127. package/dist/server/grep-scan.d.ts +23 -0
  128. package/dist/server/grep-scan.js +107 -0
  129. package/dist/server/grep-worker.d.ts +1 -0
  130. package/dist/server/grep-worker.js +12 -0
  131. package/dist/server/mcp-http.d.ts +8 -0
  132. package/dist/server/mcp-http.js +16 -1
  133. package/dist/storage/file-hash.d.ts +5 -0
  134. package/dist/storage/file-hash.js +16 -6
  135. package/dist/storage/fs-atomic.d.ts +24 -0
  136. package/dist/storage/fs-atomic.js +86 -2
  137. package/dist/storage/git.d.ts +19 -8
  138. package/dist/storage/git.js +83 -24
  139. package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
  140. package/dist/storage/gitnexus-managed-paths.js +46 -0
  141. package/dist/storage/parse-cache.d.ts +22 -4
  142. package/dist/storage/parse-cache.js +106 -29
  143. package/dist/storage/parsedfile-store.d.ts +34 -60
  144. package/dist/storage/parsedfile-store.js +177 -171
  145. package/dist/storage/repo-manager.d.ts +11 -1
  146. package/dist/storage/repo-manager.js +23 -2
  147. package/dist/storage/repo-meta.d.ts +12 -0
  148. package/dist/storage/v8-sidecar.d.ts +48 -0
  149. package/dist/storage/v8-sidecar.js +347 -0
  150. package/dist/types/pipeline.d.ts +14 -0
  151. package/package.json +4 -1
  152. package/scripts/cross-platform-shard.ts +4 -2
  153. package/scripts/cross-platform-tests.ts +7 -1
  154. package/skills/gitnexus-cli.md +11 -3
  155. package/skills/gitnexus-impact-analysis.md +9 -0
  156. package/web/assets/{agent-Dr4l5EOp.js → agent-CFqT4hjR.js} +108 -104
  157. package/web/assets/{index-2zdvEdzg.js → index-BMIniRtX.js} +3 -3
  158. package/web/index.html +1 -1
@@ -254,7 +254,8 @@ async function getReposResource(backend) {
254
254
  }
255
255
  if (repos.length > 1) {
256
256
  lines.push('');
257
- lines.push('# Multiple repos indexed. Use repo parameter in tool calls:');
257
+ lines.push('# Multiple repos indexed. Read-only tools may omit repo when an MCP default is configured or GitNexus process.cwd() is inside one listed path without crossing an unindexed nested Git checkout.');
258
+ lines.push('# Otherwise—and for mutating tools without an MCP default—pass repo explicitly:');
258
259
  lines.push(`# query({search_query: "auth", repo: "${repos[0].name}"})`);
259
260
  }
260
261
  return lines.join('\n');
@@ -136,11 +136,11 @@ export function createMCPServer(backend, options = {}) {
136
136
  };
137
137
  }
138
138
  });
139
- // With multiple visible repositories and no process-wide default, make the
140
- // routing requirement machine-readable. Agents then supply `repo` before the
141
- // call instead of discovering the ambiguity through a failed tool response.
139
+ // Make the effective routing contract machine-readable. Read-only tools may
140
+ // use a cwd-derived default; mutating rename remains explicit unless policy
141
+ // supplies a single/default repository.
142
142
  server.setRequestHandler(ListToolsRequestSchema, async () => {
143
- const requireRepo = await repositoryPolicy.requiresExplicitRepo(backend);
143
+ const { readOnlyRequiresRepo, mutatingRequiresRepo } = await repositoryPolicy.toolSchemaRepoRequirements(backend);
144
144
  return {
145
145
  tools: GITNEXUS_TOOLS.filter((tool) => (!readOnly || MCP_READ_ONLY_TOOLS.has(tool.name)) &&
146
146
  repositoryPolicy.toolAllowed(tool.name))
@@ -148,7 +148,8 @@ export function createMCPServer(backend, options = {}) {
148
148
  .map((tool) => ({
149
149
  name: tool.name,
150
150
  description: tool.description,
151
- inputSchema: requireRepo && REPO_SCOPED_TOOLS.has(tool.name)
151
+ inputSchema: (tool.name === 'rename' ? mutatingRequiresRepo : readOnlyRequiresRepo) &&
152
+ REPO_SCOPED_TOOLS.has(tool.name)
152
153
  ? {
153
154
  ...tool.inputSchema,
154
155
  required: [...new Set([...tool.inputSchema.required, 'repo'])],
package/dist/mcp/tools.js CHANGED
@@ -49,6 +49,8 @@ export const PDG_QUERY_MAX_LIMIT = 200;
49
49
  // Shared impact traversal depth cap. The MCP schema advertises this bound;
50
50
  // PDG direct backend callers also enforce it before running traversal.
51
51
  export const IMPACT_MAX_DEPTH = 32;
52
+ const CWD_AWARE_REPO_OMISSION = 'Omit when only one repo is indexed, an MCP default is configured, or the GitNexus process cwd is inside a registered path without crossing an unindexed nested Git checkout; otherwise specify it explicitly.';
53
+ const MUTATING_REPO_OMISSION = 'Omit only when one repo is indexed or an MCP default is configured; otherwise mutating tools require an explicit repo.';
52
54
  export const GITNEXUS_TOOLS = [
53
55
  {
54
56
  name: 'list_repos',
@@ -61,8 +63,10 @@ PAGINATION: Results are paginated so a large registry is not truncated by MCP/LL
61
63
  WHEN TO USE: First step when multiple repos are indexed, or to discover available repos.
62
64
  AFTER THIS: READ gitnexus://repo/{name}/context for the repo you want to work with.
63
65
 
64
- When multiple repos are indexed, you MUST specify the "repo" parameter
65
- on other tools (query, context, impact, etc.) to target the correct one.`,
66
+ When multiple repos are indexed, repo-scoped read-only tools use the configured
67
+ MCP default or the registered path containing the GitNexus process cwd, unless
68
+ cwd has crossed into an unindexed nested Git checkout. If neither applies,
69
+ specify the "repo" parameter explicitly.`,
66
70
  annotations: READ_ONLY_TOOL_ANNOTATIONS,
67
71
  inputSchema: {
68
72
  type: 'object',
@@ -148,7 +152,7 @@ SERVICE: optional monorepo path prefix (POSIX-style, case-sensitive segments). W
148
152
  },
149
153
  repo: {
150
154
  type: 'string',
151
- description: 'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). Omit when only one indexed repo exists.',
155
+ description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>" (member path keys from group.yaml). ${CWD_AWARE_REPO_OMISSION}`,
152
156
  },
153
157
  service: {
154
158
  type: 'string',
@@ -227,7 +231,7 @@ TIPS:
227
231
  },
228
232
  repo: {
229
233
  type: 'string',
230
- description: 'Repository name or path. Omit if only one repo is indexed.',
234
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
231
235
  },
232
236
  },
233
237
  required: ['statement'],
@@ -290,7 +294,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
290
294
  },
291
295
  repo: {
292
296
  type: 'string',
293
- description: 'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
297
+ description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
294
298
  },
295
299
  service: {
296
300
  type: 'string',
@@ -334,7 +338,7 @@ Returns: changed symbols, affected processes, and a risk summary.
334
338
  },
335
339
  repo: {
336
340
  type: 'string',
337
- description: 'Repository name or path. Omit if only one repo is indexed.',
341
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
338
342
  },
339
343
  },
340
344
  required: [],
@@ -373,7 +377,7 @@ A graph too large to analyze at all returns \`{ error, truncated: true }\` with
373
377
  },
374
378
  repo: {
375
379
  type: 'string',
376
- description: 'Repository name or path. Omit if only one repo is indexed.',
380
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
377
381
  },
378
382
  },
379
383
  required: [],
@@ -410,7 +414,7 @@ Handles disambiguation via context()'s payload verbatim: an ambiguous symbol_nam
410
414
  },
411
415
  repo: {
412
416
  type: 'string',
413
- description: 'Repository name or path. Omit if only one repo is indexed.',
417
+ description: `Repository name or path. ${MUTATING_REPO_OMISSION}`,
414
418
  },
415
419
  },
416
420
  required: ['new_name'],
@@ -425,17 +429,19 @@ MODE (opt-in): "callgraph" (default) walks symbol→symbol edges (CALLS/IMPORTS/
425
429
 
426
430
  STATEMENT-ANCHORED PDG SLICE: with mode:'pdg', pass "line" (1-based source line within the target symbol) to seed the dependence slice on the statement at that line and return what depends on it in affectedStatements (line + text). Inter-procedural symbols are still reported through interproceduralByDepth/pdgInterprocedural and the compatibility byDepth bucket. Without "line", pdg returns whole-symbol inter-procedural reach plus local whole-symbol PDG diagnostics.
427
431
 
428
- PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:2 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:2, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar. Return-value-ascent coverage is published structurally at pdgEvidence.ascent — present iff the inter-procedural descent ran, including on an empty slice — with referencesScanned (DISTINCT callees scanned for a CALL_SUMMARY: a distinct-id tally, not a call-site count — two call sites to the same callee count once), returnFlowFound (whether the ascent fired anywhere in the slice), undecodableSummaryCount, examinedComplete (whether that scan covered every callee the index recorded a resolved id for on the visited blocks), incompleteReasons ('traversal-truncated' | 'callee-list-capped' | 'callee-ids-unrecorded'), and callSummaryLayerPresent. Read callSummaryLayerPresent FIRST: false ⇒ a pre-CALL_SUMMARY index, so {referencesScanned:N>0, returnFlowFound:false} is self-consistent and says nothing about the callees — the scan ran, but no layer existed in which a return-flow could be recorded (remedy: re-run gitnexus analyze --pdg). Branch on those fields; the note narrates the same facts in prose for humans and is not a stable contract.
432
+ PDG OUTPUT CONTRACT: every mode:'pdg' result (success, empty, degraded, or error) carries pdgResultVersion:3 — a stable discriminator for external consumers that bumps on any breaking change to the PDG result shape (distinct from the DB schema version). Successful PDG results include mode:'pdg', a full target envelope (id/name/type/filePath), affectedStatements, affectedStatementCount, interproceduralByDepth/pdgInterprocedural for cross-function reach, compatibility byDepth/byDepthCounts, risk:'UNKNOWN', and a note describing the unified contract. Degraded PDG results (no-layer, sub-layer-missing, unknown) keep mode:'pdg', pdgResultVersion:3, target metadata when the target resolves, risk:'UNKNOWN', note/remediation, and empty byDepth parity fields — never a false-safe zero. If depth and limit both bound the slice, truncatedByReasons reports both causes while truncatedBy remains scalar. Return-value-ascent coverage is published structurally at pdgEvidence.ascent — present iff the inter-procedural descent ran, including on an empty slice — with referencesScanned (DISTINCT callees scanned for a CALL_SUMMARY: a distinct-id tally, not a call-site count — two call sites to the same callee count once), returnFlowFound (whether the ascent fired anywhere in the slice), undecodableSummaryCount, examinedComplete (whether that scan covered every callee the index recorded a resolved id for on the visited blocks), incompleteReasons ('traversal-truncated' | 'callee-list-capped' | 'callee-ids-unrecorded'), and callSummaryLayerPresent. Read callSummaryLayerPresent FIRST: false ⇒ a pre-CALL_SUMMARY index, so {referencesScanned:N>0, returnFlowFound:false} is self-consistent and says nothing about the callees — the scan ran, but no layer existed in which a return-flow could be recorded (remedy: re-run gitnexus analyze --pdg). Branch on those fields; the note narrates the same facts in prose for humans and is not a stable contract.
429
433
 
430
434
  WHEN TO USE: Before making code changes — especially refactoring, renaming, or modifying shared code. Shows what would break.
431
435
  AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
432
436
 
433
437
  Output includes:
434
- - risk: LOW / MEDIUM / HIGH / CRITICAL / UNKNOWN. An upstream walk that resolved ZERO callers reports UNKNOWN, never LOW, and carries riskNote: "safe to change" is a claim about callers and there were none to reason about, so the symbol is either genuinely unused OR reached only through a reference class the index does not record (plain-object property access, a bare-identifier read of a module-scope const). Confirm with a text search before acting on it. Downstream walks are unaffected — an empty downstream result reports resolved callees, not safety.
438
+ - risk: LOW / MEDIUM / HIGH / CRITICAL / UNKNOWN. This is the HIGH/CRITICAL edit-gate field. File targets lack process/community membership, so their risk is not directly comparable with symbol risk; use riskSharedAxes to compare the direct/total axes common to both. Group-mode (\`repo: "@…"\`) results lift the same fields to the top-level envelope. The web Graph-RAG impact tool expands File targets to in-file symbols before enrichment, so process/cluster axes remain comparable there. An upstream walk that resolved ZERO callers reports UNKNOWN, never LOW, and carries riskNote: "safe to change" is a claim about callers and there were none to reason about, so the symbol is either genuinely unused OR reached only through a reference class the index does not record (plain-object property access, a bare-identifier read of a module-scope const). Confirm with a text search before acting on it. Downstream walks are unaffected — an empty downstream result reports resolved callees, not safety.
439
+ - riskSharedAxes: single-repo risk computed only from direct and total impact. Group mode then applies the cross-repo crossing overlay to that local value. Suitable for comparing File and symbol targets within the same mode. Never substitute it for \`risk\` when deciding whether to warn before edits.
440
+ - riskScale: { comparableAcrossKinds, unusedAxes } — names process/module axes that were structurally unavailable, skipped, budget-exhausted (\`IMPACT_MAX_CHUNKS=0\`), truncated (sampled a subset of impacted symbols), or failed at query time. Failed-query and truncated-sample counts are lower bounds: known HIGH/CRITICAL warnings survive, otherwise risk is UNKNOWN. Group impact copies this metadata from the local leg.
435
441
  - riskNote: string — present only when risk is UNKNOWN; states why the verdict is withheld.
436
442
  - summary: direct callers, processes affected, modules affected
437
443
  - affected_processes: which execution flows break and at which step
438
- - affected_modules: which functional areas are hit (direct vs indirect)
444
+ - affected_modules: which functional areas are hit (direct vs indirect; classification-unavailable when that secondary query fails)
439
445
  - byDepth: affected symbols grouped by traversal depth (paginated by limit/offset; omitted when summaryOnly:true — use byDepthCounts for totals per depth, pagination object when truncated). Each item includes a processes:[{id,label,processType,step}] field listing the execution flows that symbol participates in. Empty when the symbol has no process membership. Can ALSO be empty when partial:true is set — either the process-aggregation pass hit its cap before detecting affected processes, or per-symbol enrichment was capped on a very large page. When partial:true, do NOT treat processes:[] as proof of no participation; cross-check the top-level affected_processes list.
440
446
  - epistemic: 'exact' | 'lower-bound' — whether impactedCount is the whole story. 'lower-bound' means the walk provably missed callers, so the count is a floor. Absent only on skipped probes (ambiguous-candidate lists, group fan-out).
441
447
  - boundaries: string[] — one plain-language sentence per reason the count is short. Prose for humans; branch on causes instead.
@@ -540,7 +546,7 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
540
546
  },
541
547
  repo: {
542
548
  type: 'string',
543
- description: 'Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". Omit if only one repo is indexed.',
549
+ description: `Indexed repository name or path, or group mode "@<groupName>" / "@<groupName>/<memberPath>". ${CWD_AWARE_REPO_OMISSION}`,
544
550
  },
545
551
  service: {
546
552
  type: 'string',
@@ -628,7 +634,7 @@ Findings are deliberately NOT part of impact()'s traversal or the web schema —
628
634
  },
629
635
  repo: {
630
636
  type: 'string',
631
- description: 'Repository name or path. Omit if only one repo is indexed.',
637
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
632
638
  },
633
639
  },
634
640
  required: [],
@@ -677,7 +683,7 @@ CONTRACT CAVEATS:
677
683
  },
678
684
  repo: {
679
685
  type: 'string',
680
- description: 'Repository name or path. Omit if only one repo is indexed.',
686
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
681
687
  },
682
688
  },
683
689
  required: ['mode', 'target'],
@@ -701,7 +707,7 @@ Returns: route nodes with their handlers, middleware wrapper chains (e.g., withA
701
707
  },
702
708
  repo: {
703
709
  type: 'string',
704
- description: 'Repository name or path. Omit if only one repo is indexed.',
710
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
705
711
  },
706
712
  },
707
713
  required: [],
@@ -719,7 +725,10 @@ Returns: tool nodes with their handler files and descriptions.`,
719
725
  type: 'object',
720
726
  properties: {
721
727
  tool: { type: 'string', description: 'Filter by tool name. Omit for all tools.' },
722
- repo: { type: 'string', description: 'Repository name or path.' },
728
+ repo: {
729
+ type: 'string',
730
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
731
+ },
723
732
  },
724
733
  required: [],
725
734
  },
@@ -742,7 +751,7 @@ Returns routes that have both detected response keys AND consumers. Shows top-le
742
751
  },
743
752
  repo: {
744
753
  type: 'string',
745
- description: 'Repository name or path. Omit if only one repo is indexed.',
754
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
746
755
  },
747
756
  },
748
757
  required: [],
@@ -767,7 +776,10 @@ Response shape is keyed on how many routes match, not on the data: exactly one m
767
776
  type: 'string',
768
777
  description: 'Optional HTTP verb — GET, POST, PUT, PATCH, DELETE, etc. — to narrow a multi-verb route or file lookup to a single method. Returns an error if no matched route uses that verb.',
769
778
  },
770
- repo: { type: 'string', description: 'Repository name or path.' },
779
+ repo: {
780
+ type: 'string',
781
+ description: `Repository name or path. ${CWD_AWARE_REPO_OMISSION}`,
782
+ },
771
783
  },
772
784
  required: [],
773
785
  },
@@ -877,7 +889,7 @@ DESTINATION TRACE (cross-repo): for an "@groupName" trace, OMIT to/to_uid/to_fil
877
889
  },
878
890
  repo: {
879
891
  type: 'string',
880
- description: 'Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. Omit if only one repo is indexed.',
892
+ description: `Repository name or path, or "@groupName" / "@groupName/memberPath" for a cross-repo trace over a group. ${CWD_AWARE_REPO_OMISSION}`,
881
893
  },
882
894
  },
883
895
  required: [],
@@ -20,14 +20,16 @@ import { searchFTSFromLbug } from '../core/search/bm25-index.js';
20
20
  import { hybridSearch } from '../core/search/hybrid-search.js';
21
21
  import { ftsDegradedWarning } from '../core/search/fts-indexes.js';
22
22
  import { LocalBackend } from '../mcp/local/local-backend.js';
23
- import { mountMCPEndpoints } from './mcp-http.js';
23
+ import { installServeMcpAuth, mountMCPEndpoints } from './mcp-http.js';
24
24
  import { fileURLToPath } from 'url';
25
25
  import { isTerminalJobStatus, JobManager } from './analyze-job.js';
26
26
  import { mountSSEProgress } from './sse-progress.js';
27
27
  import { resolveEmbedRunOutcome, withMeasuredEmbeddingCount, } from './embed-run-outcome.js';
28
28
  import { decideEmbeddingResume, mintInterruptedCheckpoint } from '../core/embedding-checkpoint.js';
29
29
  import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } from '../core/embedding-count.js';
30
- import { assertString, escapeRegExp, BadRequestError, createRouteLimiter } from './validation.js';
30
+ import { assertString, BadRequestError, createRouteLimiter } from './validation.js';
31
+ import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js';
32
+ import { runGrepScanInWorker } from './grep-scan.js';
31
33
  import { extractRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
32
34
  import { createAnalyzeUploadHandler } from './analyze-upload.js';
33
35
  import { assertServeAuthForPublicOrigin, createPublicOriginMatcher, createWriteOriginGuard, logOriginPolicy, PUBLIC_ORIGIN_ENV, resolveTrustProxy, TRUST_PROXY_ENV, warnIfRateLimitKeysCollapse, } from './middleware.js';
@@ -616,6 +618,9 @@ export const createServer = async (port, host = '127.0.0.1') => {
616
618
  callback(null, isAllowedOrigin(origin));
617
619
  },
618
620
  }));
621
+ // Optional protocol-layer auth for the MCP route. Keep this before the
622
+ // global body parser so rejected requests do not consume the JSON budget.
623
+ installServeMcpAuth(app);
619
624
  app.use(express.json({ limit: '10mb' }));
620
625
  // Origin guard for write routes: loopback, the server's own bound host, and
621
626
  // any configured public origin — prevents CSRF from other devices.
@@ -1126,75 +1131,29 @@ export const createServer = async (port, host = '127.0.0.1') => {
1126
1131
  res.status(404).json({ error: 'Repository not found' });
1127
1132
  return;
1128
1133
  }
1129
- // Type-confusion guard (CodeQL js/type-confusion-through-parameter-tampering):
1130
- // req.query.pattern is `string | string[] | ParsedQs` — without an explicit
1131
- // type check, the `.length` guard below counts array elements instead of
1132
- // characters, allowing arbitrarily long patterns through.
1133
- const rawPattern = req.query.pattern;
1134
- if (rawPattern === undefined) {
1135
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
1136
- return;
1137
- }
1138
- const pattern = assertString(rawPattern, 'pattern');
1139
- if (pattern.length === 0) {
1140
- res.status(400).json({ error: 'Missing "pattern" query parameter' });
1141
- return;
1142
- }
1143
- // Length cap: applies to both literal and regex modes as a defense-in-depth
1144
- // bound against pathological input.
1145
- if (pattern.length > 200) {
1146
- res.status(400).json({ error: 'Pattern too long (max 200 characters)' });
1147
- return;
1148
- }
1149
- // Treat user input as a literal substring in all cases to prevent
1150
- // regex-injection/ReDoS via attacker-controlled regex syntax.
1151
- const effectivePattern = escapeRegExp(pattern);
1152
- // Validate regex syntax (catches both opt-in user regex and any escapeRegExp bug)
1153
- let regex;
1154
- try {
1155
- regex = new RegExp(effectivePattern, 'gim');
1156
- }
1157
- catch {
1158
- res.status(400).json({ error: 'Invalid regex pattern' });
1159
- return;
1160
- }
1161
- const parsedLimit = Number(req.query.limit ?? 50);
1162
- const limit = Number.isFinite(parsedLimit)
1163
- ? Math.max(1, Math.min(200, Math.trunc(parsedLimit)))
1164
- : 50;
1165
- const results = [];
1134
+ // Pattern parsing lives in grep-params.ts (unit-testable without
1135
+ // Express + LadybugDB). Matching runs in a worker so terminate() can
1136
+ // cut a stuck regex.test() when the wall-clock budget expires.
1137
+ const { regex, fileFilter, limit } = parseGrepQuery(req.query);
1166
1138
  const repoRoot = path.resolve(entry.path);
1167
- // Get file paths from the graph (lightweight — no content loaded)
1168
1139
  const lbugPath = path.join(entry.storagePath, 'lbug');
1169
1140
  const fileRows = await withLbugDb(lbugPath, () => executeQuery(`MATCH (n:File) WHERE n.content IS NOT NULL RETURN n.filePath AS filePath`), { readOnly: true });
1170
- // Search files on disk one at a time (constant memory)
1141
+ const filePaths = [];
1171
1142
  for (const row of fileRows) {
1172
- if (results.length >= limit)
1173
- break;
1174
1143
  const filePath = row.filePath || '';
1175
- const fullPath = path.resolve(repoRoot, filePath);
1176
- // Path traversal guard
1177
- const safeRepoRoot = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep;
1178
- if (!fullPath.startsWith(safeRepoRoot) && fullPath !== repoRoot)
1144
+ if (fileFilter && !filePath.toLowerCase().includes(fileFilter))
1179
1145
  continue;
1180
- let content;
1181
- try {
1182
- content = await fs.readFile(fullPath, 'utf-8');
1183
- }
1184
- catch {
1185
- continue; // File may have been deleted since indexing
1186
- }
1187
- const lines = content.split('\n');
1188
- for (let i = 0; i < lines.length; i++) {
1189
- if (results.length >= limit)
1190
- break;
1191
- if (regex.test(lines[i])) {
1192
- results.push({ filePath, line: i + 1, text: lines[i].trim().slice(0, 200) });
1193
- }
1194
- regex.lastIndex = 0;
1195
- }
1146
+ filePaths.push(filePath);
1196
1147
  }
1197
- res.json({ results });
1148
+ const { results, timedOut } = await runGrepScanInWorker({
1149
+ repoRoot,
1150
+ filePaths,
1151
+ pattern: regex.source,
1152
+ flags: regex.flags,
1153
+ limit,
1154
+ deadlineMs: Date.now() + GREP_TIME_BUDGET_MS,
1155
+ });
1156
+ res.json({ results, ...(timedOut ? { timedOut: true } : {}) });
1198
1157
  }
1199
1158
  catch (err) {
1200
1159
  res.status(statusFromError(err)).json({ error: err.message || 'Grep failed' });
@@ -0,0 +1,18 @@
1
+ /** Hard cap on pattern length — unchanged from the literal-only era. */
2
+ export declare const GREP_PATTERN_MAX_LENGTH = 200;
3
+ /** Wall-clock budget for one /api/grep call; parent terminate()s the scan worker. */
4
+ export declare const GREP_TIME_BUDGET_MS = 5000;
5
+ export declare const GREP_DEFAULT_LIMIT = 50;
6
+ export declare const GREP_MAX_LIMIT = 200;
7
+ export interface ParsedGrepQuery {
8
+ regex: RegExp;
9
+ /** Lowercased path substring; '' disables path filtering. */
10
+ fileFilter: string;
11
+ limit: number;
12
+ }
13
+ /**
14
+ * Parse /api/grep query parameters into a ready-to-use regex + filters.
15
+ * Throws BadRequestError (mapped to HTTP 400 by statusFromError) on
16
+ * missing/over-long patterns or invalid regex syntax.
17
+ */
18
+ export declare function parseGrepQuery(query: Record<string, unknown>): ParsedGrepQuery;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Query-parameter parsing for GET /api/grep.
3
+ *
4
+ * Extracted from api.ts so the contract is unit-testable without pulling
5
+ * Express + the LadybugDB native adapter into the test run (same rationale
6
+ * as the #2790 helper extraction).
7
+ *
8
+ * Contract fix (Patch 12): the grep tool schema in gitnexus-web has always
9
+ * promised regex search with an optional path-substring filter and
10
+ * case-sensitivity control, but the handler used to escapeRegExp() every
11
+ * pattern into a literal substring — an agent asking for "sign|Sign" got
12
+ * zero hits and concluded the code didn't exist, and the schema's own
13
+ * example ("console\.log") could never match. This restores the promised
14
+ * semantics. Matching runs in a worker_threads worker (`grep-worker.ts`) so
15
+ * `terminate()` can interrupt a catastrophic `regex.test()` when the
16
+ * wall-clock budget expires; the parent event loop stays responsive.
17
+ * Bounded mitigations:
18
+ * - pattern length cap (200 chars, unchanged from the literal-only era)
19
+ * - line-by-line matching (each regex.test call sees one source line)
20
+ * - a wall-clock budget the parent enforces via worker terminate()
21
+ * - result cap unchanged (limit, max 200)
22
+ * - literal=1 opt-out restores the old escaped-substring immunity
23
+ */
24
+ import { assertString, escapeRegExp, BadRequestError } from './validation.js';
25
+ /** Hard cap on pattern length — unchanged from the literal-only era. */
26
+ export const GREP_PATTERN_MAX_LENGTH = 200;
27
+ /** Wall-clock budget for one /api/grep call; parent terminate()s the scan worker. */
28
+ export const GREP_TIME_BUDGET_MS = 5_000;
29
+ export const GREP_DEFAULT_LIMIT = 50;
30
+ export const GREP_MAX_LIMIT = 200;
31
+ const isFlagTrue = (value, name) => {
32
+ const s = assertString(value ?? '', name).toLowerCase();
33
+ return s === '1' || s === 'true';
34
+ };
35
+ /**
36
+ * Parse /api/grep query parameters into a ready-to-use regex + filters.
37
+ * Throws BadRequestError (mapped to HTTP 400 by statusFromError) on
38
+ * missing/over-long patterns or invalid regex syntax.
39
+ */
40
+ export function parseGrepQuery(query) {
41
+ if (query.pattern === undefined) {
42
+ throw new BadRequestError('Missing "pattern" query parameter');
43
+ }
44
+ const pattern = assertString(query.pattern, 'pattern');
45
+ if (pattern.length === 0) {
46
+ throw new BadRequestError('Missing "pattern" query parameter');
47
+ }
48
+ if (pattern.length > GREP_PATTERN_MAX_LENGTH) {
49
+ throw new BadRequestError(`Pattern too long (max ${GREP_PATTERN_MAX_LENGTH} characters)`);
50
+ }
51
+ // Regex semantics by default — what the tool schema always promised.
52
+ // literal=1 opts back into the escaped-substring behaviour of the
53
+ // literal-only era for callers that want it verbatim.
54
+ const caseSensitive = isFlagTrue(query.caseSensitive, 'caseSensitive');
55
+ const flags = caseSensitive ? '' : 'i';
56
+ let regex;
57
+ try {
58
+ // Deliberately no 'g' flag: the handler tests line-by-line and a
59
+ // stateful lastIndex across lines would skip matches (the old handler
60
+ // had to reset it manually). No 'm' either: each test receives a
61
+ // single line, so ^/$ already anchor at string boundaries — 'm'
62
+ // would be a no-op.
63
+ if (isFlagTrue(query.literal, 'literal')) {
64
+ regex = new RegExp(escapeRegExp(pattern), flags);
65
+ }
66
+ else {
67
+ // Intentional: /api/grep advertises real regex (see file header + SECURITY.md).
68
+ // ReDoS is mitigated by running the scan in a worker and terminate()-ing it.
69
+ // codeql[js/regex-injection]
70
+ regex = new RegExp(pattern, flags);
71
+ }
72
+ }
73
+ catch {
74
+ throw new BadRequestError('Invalid regex pattern');
75
+ }
76
+ // Path-substring filter, case-insensitive ("Controller.java", "src/api").
77
+ const fileFilter = assertString(query.fileFilter ?? '', 'fileFilter').toLowerCase();
78
+ const parsedLimit = Number(query.limit ?? GREP_DEFAULT_LIMIT);
79
+ const limit = Number.isFinite(parsedLimit)
80
+ ? Math.max(1, Math.min(GREP_MAX_LIMIT, Math.trunc(parsedLimit)))
81
+ : GREP_DEFAULT_LIMIT;
82
+ return { regex, fileFilter, limit };
83
+ }
@@ -0,0 +1,23 @@
1
+ export interface GrepHit {
2
+ filePath: string;
3
+ line: number;
4
+ text: string;
5
+ }
6
+ export interface GrepScanInput {
7
+ repoRoot: string;
8
+ /** Repo-relative paths already filtered by fileFilter. */
9
+ filePaths: string[];
10
+ pattern: string;
11
+ flags: string;
12
+ limit: number;
13
+ /** Absolute Date.now() deadline. */
14
+ deadlineMs: number;
15
+ }
16
+ export interface GrepScanResult {
17
+ results: GrepHit[];
18
+ timedOut: boolean;
19
+ }
20
+ export type GrepProgress = (partial: GrepScanResult) => void;
21
+ export declare function scanGrepFiles(input: GrepScanInput, onProgress?: GrepProgress): Promise<GrepScanResult>;
22
+ export declare function grepWorkerPath(): string;
23
+ export declare function runGrepScanInWorker(input: GrepScanInput): Promise<GrepScanResult>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Filesystem grep scan used by GET /api/grep.
3
+ *
4
+ * Matching runs in a worker_threads worker (see grep-worker.ts) so a
5
+ * catastrophic `regex.test()` can be killed with terminate() when the
6
+ * wall-clock budget expires. The parent event loop stays responsive.
7
+ */
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { fileURLToPath, pathToFileURL } from 'node:url';
12
+ import { Worker } from 'node:worker_threads';
13
+ export async function scanGrepFiles(input, onProgress) {
14
+ const regex = new RegExp(input.pattern, input.flags);
15
+ const results = [];
16
+ const repoRoot = path.resolve(input.repoRoot);
17
+ const safeRepoRoot = repoRoot.endsWith(path.sep) ? repoRoot : repoRoot + path.sep;
18
+ let timedOut = false;
19
+ files: for (const filePath of input.filePaths) {
20
+ if (results.length >= input.limit)
21
+ break;
22
+ if (Date.now() > input.deadlineMs) {
23
+ timedOut = true;
24
+ break;
25
+ }
26
+ const fullPath = path.resolve(repoRoot, filePath);
27
+ if (!fullPath.startsWith(safeRepoRoot) && fullPath !== repoRoot)
28
+ continue;
29
+ let content;
30
+ try {
31
+ content = await fs.readFile(fullPath, 'utf-8');
32
+ }
33
+ catch {
34
+ continue;
35
+ }
36
+ const lines = content.split('\n');
37
+ for (let i = 0; i < lines.length; i++) {
38
+ if (results.length >= input.limit)
39
+ break files;
40
+ if ((i & 255) === 0 && Date.now() > input.deadlineMs) {
41
+ timedOut = true;
42
+ break files;
43
+ }
44
+ regex.lastIndex = 0;
45
+ if (regex.test(lines[i])) {
46
+ results.push({ filePath, line: i + 1, text: lines[i].trim().slice(0, 200) });
47
+ }
48
+ }
49
+ onProgress?.({ results: results.slice(), timedOut });
50
+ }
51
+ return { results, timedOut };
52
+ }
53
+ const _require = createRequire(import.meta.url);
54
+ export function grepWorkerPath() {
55
+ const callerPath = fileURLToPath(import.meta.url);
56
+ const isDev = callerPath.endsWith('.ts');
57
+ return path.join(path.dirname(callerPath), isDev ? 'grep-worker.ts' : 'grep-worker.js');
58
+ }
59
+ export function runGrepScanInWorker(input) {
60
+ const callerPath = fileURLToPath(import.meta.url);
61
+ const isDev = callerPath.endsWith('.ts');
62
+ const tsxHookArgs = isDev
63
+ ? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
64
+ : [];
65
+ return new Promise((resolve, reject) => {
66
+ let settled = false;
67
+ let latest = { results: [], timedOut: false };
68
+ const worker = new Worker(grepWorkerPath(), {
69
+ workerData: input,
70
+ execArgv: tsxHookArgs,
71
+ });
72
+ const finish = (result) => {
73
+ if (settled)
74
+ return;
75
+ settled = true;
76
+ clearTimeout(timer);
77
+ void worker.terminate();
78
+ resolve(result);
79
+ };
80
+ const remain = Math.max(1, input.deadlineMs - Date.now());
81
+ const timer = setTimeout(() => {
82
+ finish({ results: latest.results, timedOut: true });
83
+ }, remain);
84
+ worker.on('message', (msg) => {
85
+ if (msg.type === 'progress') {
86
+ latest = { results: msg.results, timedOut: msg.timedOut };
87
+ return;
88
+ }
89
+ if (msg.type === 'done') {
90
+ finish({ results: msg.results, timedOut: msg.timedOut });
91
+ }
92
+ });
93
+ worker.on('error', (err) => {
94
+ if (settled)
95
+ return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ void worker.terminate();
99
+ reject(err);
100
+ });
101
+ worker.on('exit', () => {
102
+ if (settled)
103
+ return;
104
+ finish({ results: latest.results, timedOut: true });
105
+ });
106
+ });
107
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ import { parentPort, workerData } from 'node:worker_threads';
2
+ if (!parentPort) {
3
+ throw new Error('grep-worker must run as a worker_threads worker');
4
+ }
5
+ const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js';
6
+ const { scanGrepFiles } = await import(new URL(`./grep-scan${ext}`, import.meta.url).href);
7
+ const port = parentPort;
8
+ const input = workerData;
9
+ const out = await scanGrepFiles(input, (partial) => {
10
+ port.postMessage({ type: 'progress', ...partial });
11
+ });
12
+ port.postMessage({ type: 'done', ...out });
@@ -9,4 +9,12 @@
9
9
  */
10
10
  import type { Express } from 'express';
11
11
  import type { LocalBackend } from '../mcp/local/local-backend.js';
12
+ /**
13
+ * Protect serve's /api/mcp route when the shared MCP bearer token is configured.
14
+ *
15
+ * This middleware must be installed before Express's global JSON parser so an
16
+ * unauthenticated request body is rejected before it is parsed. The standalone
17
+ * `gitnexus mcp --http` server resolves the same environment variable.
18
+ */
19
+ export declare function installServeMcpAuth(app: Express, env?: NodeJS.ProcessEnv): boolean;
12
20
  export declare function mountMCPEndpoints(app: Express, backend: LocalBackend): Promise<() => Promise<void>>;
@@ -7,9 +7,24 @@
7
7
  *
8
8
  * Used by server/api.ts to wire up the full web server.
9
9
  */
10
- import { createStreamableHttpHandler } from '../mcp/http-transport.js';
10
+ import { createAuthMiddleware, createStreamableHttpHandler, resolveAuthToken, } from '../mcp/http-transport.js';
11
11
  import { createMcpRepositoryPolicy } from '../mcp/repository-policy.js';
12
12
  import { logger } from '../core/logger.js';
13
+ /**
14
+ * Protect serve's /api/mcp route when the shared MCP bearer token is configured.
15
+ *
16
+ * This middleware must be installed before Express's global JSON parser so an
17
+ * unauthenticated request body is rejected before it is parsed. The standalone
18
+ * `gitnexus mcp --http` server resolves the same environment variable.
19
+ */
20
+ export function installServeMcpAuth(app, env = process.env) {
21
+ const authToken = resolveAuthToken(undefined, env);
22
+ if (!authToken)
23
+ return false;
24
+ app.use('/api/mcp', createAuthMiddleware(authToken));
25
+ logger.info('Bearer authentication enabled for serve /api/mcp');
26
+ return true;
27
+ }
13
28
  export async function mountMCPEndpoints(app, backend) {
14
29
  const repositoryPolicy = await createMcpRepositoryPolicy(backend);
15
30
  const { handler, cleanup } = createStreamableHttpHandler(backend, { repositoryPolicy });