mcp-probe-kit 3.0.11 → 3.0.13

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.
@@ -1,6 +1,7 @@
1
1
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
2
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
3
- import { execFileSync, spawn } from "node:child_process";
3
+ import { execFileSync } from "node:child_process";
4
+ import spawn from "cross-spawn";
4
5
  import * as fs from "node:fs";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
@@ -60,9 +61,9 @@ function isBridgeEnabled() {
60
61
  }
61
62
  return !/^(0|false|no|off)$/i.test(raw.trim());
62
63
  }
63
- function splitArgs(raw) {
64
+ function splitArgs(raw, fallback = DEFAULT_GITNEXUS_ARGS) {
64
65
  if (!raw) {
65
- return [...DEFAULT_GITNEXUS_ARGS];
66
+ return [...fallback];
66
67
  }
67
68
  return raw
68
69
  .trim()
@@ -118,14 +119,14 @@ function parseAvailableReposFromError(text) {
118
119
  .map((item) => item.trim())
119
120
  .filter(Boolean);
120
121
  }
121
- function resolveBridgeCommand() {
122
- const command = (process.env.MCP_GITNEXUS_COMMAND || "npx").trim() || "npx";
123
- const args = splitArgs(process.env.MCP_GITNEXUS_ARGS);
124
- return resolveSpawnCommand(command, args);
122
+ function isGitNexusCliCommand(command) {
123
+ const normalized = path.basename((command || "").trim()).toLowerCase();
124
+ return normalized === "gitnexus"
125
+ || normalized === "gitnexus.cmd"
126
+ || normalized === "gitnexus.exe"
127
+ || normalized === "gitnexus.bat";
125
128
  }
126
- function resolveGitNexusCliCommand(subcommand) {
127
- const command = (process.env.MCP_GITNEXUS_COMMAND || "npx").trim() || "npx";
128
- const bridgeArgs = splitArgs(process.env.MCP_GITNEXUS_ARGS);
129
+ function resolveNpxPackageArgs(bridgeArgs) {
129
130
  const flags = [];
130
131
  let packageSpec = "gitnexus@latest";
131
132
  for (const arg of bridgeArgs) {
@@ -139,17 +140,58 @@ function resolveGitNexusCliCommand(subcommand) {
139
140
  packageSpec = arg;
140
141
  break;
141
142
  }
142
- return resolveSpawnCommand(command, [...flags, packageSpec, subcommand]);
143
+ return [...flags, packageSpec];
143
144
  }
144
- export function resolveExecutableCommand(command, platform = process.platform) {
145
+ export function resolveGitNexusBridgeCommand(env = process.env, platform = process.platform) {
146
+ const explicitCommand = env.MCP_GITNEXUS_COMMAND?.trim();
147
+ if (explicitCommand) {
148
+ const args = splitArgs(env.MCP_GITNEXUS_ARGS, isGitNexusCliCommand(explicitCommand) ? ["mcp"] : DEFAULT_GITNEXUS_ARGS);
149
+ return {
150
+ ...resolveSpawnCommand(explicitCommand, args, platform, env),
151
+ strategy: "env",
152
+ };
153
+ }
154
+ const localCli = findExecutablePath("gitnexus", platform, env);
155
+ if (localCli) {
156
+ return {
157
+ command: localCli,
158
+ args: ["mcp"],
159
+ strategy: "local",
160
+ };
161
+ }
162
+ return {
163
+ ...resolveSpawnCommand("npx", splitArgs(env.MCP_GITNEXUS_ARGS), platform, env),
164
+ strategy: "npx",
165
+ };
166
+ }
167
+ function resolveGitNexusCliCommand(subcommand, env = process.env, platform = process.platform) {
168
+ const explicitCommand = env.MCP_GITNEXUS_COMMAND?.trim();
169
+ if (explicitCommand) {
170
+ if (isGitNexusCliCommand(explicitCommand)) {
171
+ return resolveSpawnCommand(explicitCommand, [subcommand], platform, env);
172
+ }
173
+ const bridgeArgs = splitArgs(env.MCP_GITNEXUS_ARGS);
174
+ return resolveSpawnCommand(explicitCommand, [...resolveNpxPackageArgs(bridgeArgs), subcommand], platform, env);
175
+ }
176
+ const localCli = findExecutablePath("gitnexus", platform, env);
177
+ if (localCli) {
178
+ return {
179
+ command: localCli,
180
+ args: [subcommand],
181
+ };
182
+ }
183
+ const bridgeArgs = splitArgs(env.MCP_GITNEXUS_ARGS);
184
+ return resolveSpawnCommand("npx", [...resolveNpxPackageArgs(bridgeArgs), subcommand], platform, env);
185
+ }
186
+ export function resolveExecutableCommand(command, platform = process.platform, env = process.env) {
145
187
  const normalized = (command || "").trim();
146
188
  if (!normalized) {
147
- return resolveExecutableCommand("npx", platform);
189
+ return resolveExecutableCommand("npx", platform, env);
148
190
  }
149
191
  if (path.isAbsolute(normalized) && fs.existsSync(normalized)) {
150
192
  return normalized;
151
193
  }
152
- const found = findExecutablePath(normalized, platform);
194
+ const found = findExecutablePath(normalized, platform, env);
153
195
  if (found) {
154
196
  return found;
155
197
  }
@@ -165,7 +207,7 @@ export function resolveExecutableCommand(command, platform = process.platform) {
165
207
  }
166
208
  return normalized;
167
209
  }
168
- function findExecutablePath(command, platform = process.platform) {
210
+ function findExecutablePath(command, platform = process.platform, env = process.env) {
169
211
  const trimmed = (command || "").trim();
170
212
  if (!trimmed || path.isAbsolute(trimmed)) {
171
213
  return undefined;
@@ -196,6 +238,7 @@ function findExecutablePath(command, platform = process.platform) {
196
238
  try {
197
239
  const output = execFileSync("where.exe", [trimmed], {
198
240
  encoding: "utf-8",
241
+ env,
199
242
  stdio: ["ignore", "pipe", "ignore"],
200
243
  windowsHide: true,
201
244
  }).trim();
@@ -209,7 +252,7 @@ function findExecutablePath(command, platform = process.platform) {
209
252
  // fall back to PATH scan
210
253
  }
211
254
  }
212
- const pathEntries = (process.env.PATH || "")
255
+ const pathEntries = (env.PATH || "")
213
256
  .split(path.delimiter)
214
257
  .map((entry) => entry.trim())
215
258
  .filter(Boolean);
@@ -231,54 +274,215 @@ function findExecutablePath(command, platform = process.platform) {
231
274
  }
232
275
  if (platform === "win32" && trimmed.toLowerCase() === "git") {
233
276
  const commonCandidates = [
234
- path.join(process.env["ProgramFiles"] || "C:\\Program Files", "Git", "cmd", "git.exe"),
235
- path.join(process.env["ProgramFiles"] || "C:\\Program Files", "Git", "bin", "git.exe"),
236
- path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "cmd", "git.exe"),
277
+ path.join(env["ProgramFiles"] || "C:\\Program Files", "Git", "cmd", "git.exe"),
278
+ path.join(env["ProgramFiles"] || "C:\\Program Files", "Git", "bin", "git.exe"),
279
+ path.join(env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Git", "cmd", "git.exe"),
237
280
  ];
238
281
  return preferredPath(commonCandidates);
239
282
  }
240
283
  return undefined;
241
284
  }
242
- function shouldWrapWithCmd(rawCommand, executable, platform = process.platform) {
243
- if (platform !== "win32") {
244
- return false;
285
+ export function resolveSpawnCommand(command, args, platform = process.platform, env = process.env) {
286
+ const executable = resolveExecutableCommand(command, platform, env);
287
+ return {
288
+ command: executable,
289
+ args,
290
+ };
291
+ }
292
+ function toRecord(value) {
293
+ return value && typeof value === "object" && !Array.isArray(value)
294
+ ? value
295
+ : undefined;
296
+ }
297
+ function extractStructuredStatus(value) {
298
+ const status = toRecord(value)?.status;
299
+ return typeof status === "string" ? status : undefined;
300
+ }
301
+ function extractStructuredMessage(value) {
302
+ const record = toRecord(value);
303
+ if (!record) {
304
+ return undefined;
245
305
  }
246
- const rawLower = (rawCommand || "").trim().toLowerCase();
247
- const executableLower = (executable || "").trim().toLowerCase();
248
- const ext = path.extname(executableLower);
249
- if (!rawLower && !executableLower) {
250
- return true;
306
+ for (const key of ["message", "summary", "explanation", "detail"]) {
307
+ const candidate = record[key];
308
+ if (typeof candidate === "string" && candidate.trim()) {
309
+ return candidate.trim();
310
+ }
251
311
  }
252
- if (ext === ".cmd" || ext === ".bat") {
253
- return true;
312
+ return undefined;
313
+ }
314
+ function extractStructuredCandidates(value) {
315
+ const candidates = toRecord(value)?.candidates;
316
+ if (!Array.isArray(candidates)) {
317
+ return [];
254
318
  }
255
- if (ext === ".exe") {
256
- return false;
319
+ return candidates
320
+ .filter((item) => item && typeof item === "object" && !Array.isArray(item))
321
+ .map((item) => ({ ...item }));
322
+ }
323
+ function buildAmbiguities(executions) {
324
+ return executions
325
+ .filter((item) => item.ok && item.status === "ambiguous")
326
+ .map((item) => {
327
+ const candidates = extractStructuredCandidates(item.structuredContent);
328
+ const message = extractStructuredMessage(item.structuredContent) || item.text;
329
+ return {
330
+ tool: item.tool,
331
+ message: message ? shorten(message, 220) : undefined,
332
+ candidates,
333
+ };
334
+ });
335
+ }
336
+ const QUERY_STOP_WORDS = new Set([
337
+ "the", "and", "for", "with", "from", "into", "that", "this", "user", "flow",
338
+ "sign", "code", "project", "module",
339
+ "理解", "分析", "流程", "模块", "项目", "代码", "查询", "相关", "功能",
340
+ ]);
341
+ function tokenizeQueryHints(...values) {
342
+ const tokens = new Set();
343
+ for (const value of values) {
344
+ for (const token of (value || "").toLowerCase().split(/[^a-z0-9_\u4e00-\u9fa5]+/)) {
345
+ const normalized = token.trim();
346
+ if (!normalized || QUERY_STOP_WORDS.has(normalized)) {
347
+ continue;
348
+ }
349
+ if (normalized.length < 3 && !/[\u4e00-\u9fa5]/.test(normalized)) {
350
+ continue;
351
+ }
352
+ tokens.add(normalized);
353
+ }
257
354
  }
258
- return rawLower === "npx" || rawLower === "npm";
355
+ return [...tokens];
259
356
  }
260
- function quoteForCmd(executable) {
261
- if (!executable.includes(" ")) {
262
- return executable;
357
+ function collectStringFields(value, depth = 0, bag = [], currentKey = "") {
358
+ if (depth > 3 || value == null) {
359
+ return bag;
360
+ }
361
+ if (typeof value === "string") {
362
+ const normalized = value.trim().toLowerCase();
363
+ if (normalized) {
364
+ bag.push({ key: currentKey.toLowerCase(), value: normalized });
365
+ }
366
+ return bag;
263
367
  }
264
- if (executable.startsWith("\"") && executable.endsWith("\"")) {
265
- return executable;
368
+ if (Array.isArray(value)) {
369
+ for (const item of value.slice(0, 12)) {
370
+ collectStringFields(item, depth + 1, bag, currentKey);
371
+ }
372
+ return bag;
373
+ }
374
+ if (typeof value === "object") {
375
+ for (const [key, item] of Object.entries(value).slice(0, 20)) {
376
+ collectStringFields(item, depth + 1, bag, key);
377
+ }
266
378
  }
267
- return `"${executable}"`;
379
+ return bag;
268
380
  }
269
- export function resolveSpawnCommand(command, args, platform = process.platform) {
270
- const executable = resolveExecutableCommand(command, platform);
271
- if (!shouldWrapWithCmd(command, executable, platform)) {
272
- return {
273
- command: executable,
274
- args,
275
- };
381
+ function scoreQueryCandidate(candidate, terms) {
382
+ const fields = collectStringFields(candidate);
383
+ if (fields.length === 0 || terms.length === 0) {
384
+ return 0;
385
+ }
386
+ let score = 0;
387
+ let matchedTerms = 0;
388
+ for (const term of terms) {
389
+ let matched = false;
390
+ for (const field of fields) {
391
+ if (!field.value.includes(term)) {
392
+ continue;
393
+ }
394
+ matched = true;
395
+ if (field.key.includes("name") || field.key.includes("title") || field.key.includes("label")) {
396
+ score += field.value === term ? 24 : 16;
397
+ }
398
+ else if (field.key.includes("path") || field.key.includes("file") || field.key.includes("module")) {
399
+ score += 10;
400
+ }
401
+ else {
402
+ score += 4;
403
+ }
404
+ }
405
+ if (matched) {
406
+ matchedTerms += 1;
407
+ }
408
+ }
409
+ if (matchedTerms > 0) {
410
+ score += matchedTerms * 5;
411
+ }
412
+ if (matchedTerms === terms.length) {
413
+ score += 12;
414
+ }
415
+ const candidateRecord = toRecord(candidate);
416
+ const priority = candidateRecord?.priority;
417
+ if (typeof priority === "number" && Number.isFinite(priority)) {
418
+ score += priority;
419
+ }
420
+ return score;
421
+ }
422
+ function describeQueryTopMatches(structuredContent) {
423
+ const processes = toRecord(structuredContent)?.processes;
424
+ if (!Array.isArray(processes) || processes.length === 0) {
425
+ return undefined;
426
+ }
427
+ const labels = processes
428
+ .slice(0, 3)
429
+ .map((item) => {
430
+ const record = toRecord(item);
431
+ return [
432
+ record?.heuristicLabel,
433
+ record?.title,
434
+ record?.name,
435
+ record?.processName,
436
+ ].find((value) => typeof value === "string" && value.trim());
437
+ })
438
+ .filter((value) => typeof value === "string" && value.trim().length > 0);
439
+ return labels.length > 0 ? `Top matches: ${labels.join(" | ")}` : undefined;
440
+ }
441
+ export function rerankQueryStructuredContent(structuredContent, hints) {
442
+ const record = toRecord(structuredContent);
443
+ if (!record) {
444
+ return { structuredContent, changed: false };
445
+ }
446
+ const terms = tokenizeQueryHints(hints.query, hints.goal, hints.taskContext);
447
+ if (terms.length === 0) {
448
+ return { structuredContent, changed: false };
449
+ }
450
+ let changed = false;
451
+ const next = { ...record };
452
+ for (const key of ["processes", "definitions"]) {
453
+ const value = next[key];
454
+ if (!Array.isArray(value) || value.length < 2) {
455
+ continue;
456
+ }
457
+ const reranked = value
458
+ .map((item, index) => ({
459
+ item,
460
+ index,
461
+ score: scoreQueryCandidate(item, terms),
462
+ }))
463
+ .sort((a, b) => (b.score - a.score) || (a.index - b.index))
464
+ .map((entry) => entry.item);
465
+ const orderChanged = reranked.some((item, index) => item !== value[index]);
466
+ if (orderChanged) {
467
+ next[key] = reranked;
468
+ changed = true;
469
+ }
276
470
  }
277
471
  return {
278
- command: process.env.ComSpec || "cmd.exe",
279
- args: ["/d", "/s", "/c", quoteForCmd(executable), ...args],
472
+ structuredContent: next,
473
+ changed,
474
+ note: changed ? describeQueryTopMatches(next) : undefined,
280
475
  };
281
476
  }
477
+ function describeExecutionSummary(item) {
478
+ if (item.tool === "query") {
479
+ const rerankedTopMatches = describeQueryTopMatches(item.structuredContent);
480
+ if (rerankedTopMatches) {
481
+ return shorten(rerankedTopMatches, 110);
482
+ }
483
+ }
484
+ return shorten(item.text || "已返回结构化结果", 110);
485
+ }
282
486
  function extractText(result) {
283
487
  if (!result || typeof result !== "object") {
284
488
  return "";
@@ -539,10 +743,10 @@ function resolveMode(request) {
539
743
  if (mode === "query" || mode === "context" || mode === "impact") {
540
744
  return mode;
541
745
  }
542
- if (request.target && request.direction) {
746
+ if ((request.target || request.uid) && request.direction) {
543
747
  return "impact";
544
748
  }
545
- if (request.target && !request.query) {
749
+ if ((request.target || request.uid) && !request.query) {
546
750
  return "context";
547
751
  }
548
752
  return "query";
@@ -560,6 +764,10 @@ async function callBridgeTool(client, tool, args, signal, workspace) {
560
764
  const durationMs = Date.now() - startedAt;
561
765
  const text = extractText(result);
562
766
  const isError = Boolean(result.isError);
767
+ const structuredContent = workspace
768
+ ? mapValueToSourceRoot(result.structuredContent, workspace)
769
+ : result.structuredContent;
770
+ const status = extractStructuredStatus(structuredContent);
563
771
  if (isError) {
564
772
  return {
565
773
  tool,
@@ -567,9 +775,8 @@ async function callBridgeTool(client, tool, args, signal, workspace) {
567
775
  ok: false,
568
776
  durationMs,
569
777
  text: workspace ? mapValueToSourceRoot(text, workspace) : text,
570
- structuredContent: workspace
571
- ? mapValueToSourceRoot(result.structuredContent, workspace)
572
- : result.structuredContent,
778
+ structuredContent,
779
+ status,
573
780
  error: workspace
574
781
  ? mapValueToSourceRoot(text || `GitNexus 工具 ${tool} 返回错误`, workspace)
575
782
  : text || `GitNexus 工具 ${tool} 返回错误`,
@@ -581,9 +788,8 @@ async function callBridgeTool(client, tool, args, signal, workspace) {
581
788
  ok: true,
582
789
  durationMs,
583
790
  text: workspace ? mapValueToSourceRoot(text, workspace) : text,
584
- structuredContent: workspace
585
- ? mapValueToSourceRoot(result.structuredContent, workspace)
586
- : result.structuredContent,
791
+ structuredContent,
792
+ status,
587
793
  };
588
794
  }
589
795
  catch (error) {
@@ -603,6 +809,7 @@ export async function runCodeInsightBridge(request) {
603
809
  const modeRequested = request.mode || "auto";
604
810
  const modeResolved = resolveMode(request);
605
811
  const requestedProjectRoot = resolveRequestedProjectRoot(request.projectRoot);
812
+ const launcher = resolveGitNexusBridgeCommand();
606
813
  if (!isBridgeEnabled() || isEnvDisabled("MCP_ENABLE_GITNEXUS_BRIDGE")) {
607
814
  const sourceRoot = findGitRoot(requestedProjectRoot) || requestedProjectRoot;
608
815
  return {
@@ -616,6 +823,8 @@ export async function runCodeInsightBridge(request) {
616
823
  executions: [],
617
824
  warnings: ["bridge_disabled"],
618
825
  repo: resolvePreferredRepoName(request.repo),
826
+ launcherStrategy: launcher.strategy,
827
+ ambiguities: [],
619
828
  workspaceMode: "direct",
620
829
  sourceRoot,
621
830
  analysisRoot: sourceRoot,
@@ -635,6 +844,8 @@ export async function runCodeInsightBridge(request) {
635
844
  executions: [],
636
845
  warnings: ["bridge_failure_cached"],
637
846
  repo: resolvePreferredRepoName(request.repo),
847
+ launcherStrategy: launcher.strategy,
848
+ ambiguities: [],
638
849
  workspaceMode: "direct",
639
850
  sourceRoot,
640
851
  analysisRoot: sourceRoot,
@@ -654,6 +865,8 @@ export async function runCodeInsightBridge(request) {
654
865
  executions: [],
655
866
  warnings: ["project_root_required", "unsafe_home_directory"],
656
867
  repo: resolvePreferredRepoName(request.repo),
868
+ launcherStrategy: launcher.strategy,
869
+ ambiguities: [],
657
870
  workspaceMode: "direct",
658
871
  sourceRoot: requestedProjectRoot,
659
872
  analysisRoot: requestedProjectRoot,
@@ -665,7 +878,7 @@ export async function runCodeInsightBridge(request) {
665
878
  const effectiveRepo = workspace.workspaceMode === "temp-repo"
666
879
  ? workspace.repoName
667
880
  : resolvePreferredRepoName(request.repo) || workspace.repoName;
668
- const { command, args } = resolveBridgeCommand();
881
+ const { command, args } = launcher;
669
882
  const warnings = [];
670
883
  if (workspace.workspaceMode === "temp-repo") {
671
884
  warnings.push("temp_repo_workspace");
@@ -702,30 +915,50 @@ export async function runCodeInsightBridge(request) {
702
915
  warnings.push("缺少 query 参数,已跳过 query");
703
916
  return;
704
917
  }
705
- executions.push(await callBridgeTool(client, "query", {
918
+ const execution = await callBridgeTool(client, "query", {
706
919
  query: queryText,
707
920
  ...(request.goal ? { goal: request.goal } : {}),
708
921
  ...(request.taskContext ? { task_context: request.taskContext } : {}),
922
+ ...(request.includeContent ? { include_content: true } : {}),
709
923
  ...(effectiveRepo ? { repo: effectiveRepo } : {}),
710
- }, request.signal, workspace));
924
+ }, request.signal, workspace);
925
+ if (execution.ok) {
926
+ const reranked = rerankQueryStructuredContent(execution.structuredContent, {
927
+ query: queryText,
928
+ goal: request.goal,
929
+ taskContext: request.taskContext,
930
+ });
931
+ execution.structuredContent = reranked.structuredContent;
932
+ if (reranked.changed) {
933
+ warnings.push("query_results_reranked");
934
+ if (reranked.note) {
935
+ execution.text = execution.text
936
+ ? `${reranked.note}\n\n${execution.text}`
937
+ : reranked.note;
938
+ }
939
+ }
940
+ }
941
+ executions.push(execution);
711
942
  };
712
943
  const runContext = async () => {
713
- if (!request.target) {
714
- warnings.push("缺少 target 参数,已跳过 context");
944
+ if (!request.target && !request.uid) {
945
+ warnings.push("缺少 target/uid 参数,已跳过 context");
715
946
  return;
716
947
  }
717
948
  executions.push(await callBridgeTool(client, "context", {
718
- name: request.target,
949
+ ...(request.uid ? { uid: request.uid } : { name: request.target }),
950
+ ...(request.filePath ? { file_path: request.filePath } : {}),
951
+ ...(request.includeContent ? { include_content: true } : {}),
719
952
  ...(effectiveRepo ? { repo: effectiveRepo } : {}),
720
953
  }, request.signal, workspace));
721
954
  };
722
955
  const runImpact = async () => {
723
- if (!request.target) {
724
- warnings.push("缺少 target 参数,已跳过 impact");
956
+ if (!request.target && !request.uid) {
957
+ warnings.push("缺少 target/uid 参数,已跳过 impact");
725
958
  return;
726
959
  }
727
960
  executions.push(await callBridgeTool(client, "impact", {
728
- target: request.target,
961
+ target: request.uid || request.target,
729
962
  direction: request.direction || "upstream",
730
963
  ...(request.maxDepth ? { maxDepth: request.maxDepth } : {}),
731
964
  ...(typeof request.includeTests === "boolean"
@@ -736,10 +969,10 @@ export async function runCodeInsightBridge(request) {
736
969
  };
737
970
  if (modeRequested === "auto") {
738
971
  await runQuery();
739
- if (request.target) {
972
+ if (request.target || request.uid) {
740
973
  await runContext();
741
974
  }
742
- if (request.target && request.direction) {
975
+ if ((request.target || request.uid) && request.direction) {
743
976
  await runImpact();
744
977
  }
745
978
  }
@@ -772,6 +1005,8 @@ export async function runCodeInsightBridge(request) {
772
1005
  executions: [],
773
1006
  warnings: ["bridge_unavailable", ...warnings],
774
1007
  repo: effectiveRepo,
1008
+ launcherStrategy: launcher.strategy,
1009
+ ambiguities: [],
775
1010
  workspaceMode: workspace.workspaceMode,
776
1011
  sourceRoot: workspace.sourceRoot,
777
1012
  analysisRoot: workspace.analysisRoot,
@@ -800,6 +1035,7 @@ export async function runCodeInsightBridge(request) {
800
1035
  }
801
1036
  bridgeFailureUntil = 0;
802
1037
  bridgeFailureReason = "";
1038
+ const ambiguities = buildAmbiguities(successful);
803
1039
  if (successful.length === 0) {
804
1040
  const fallbackError = failed.length > 0
805
1041
  ? shorten(failed.map((item) => `${item.tool}: ${item.error || "unknown error"}`).join(" | "), 220)
@@ -815,14 +1051,19 @@ export async function runCodeInsightBridge(request) {
815
1051
  executions,
816
1052
  warnings: ["bridge_call_failed", ...warnings],
817
1053
  repo: effectiveRepo,
1054
+ launcherStrategy: launcher.strategy,
1055
+ ambiguities,
818
1056
  workspaceMode: workspace.workspaceMode,
819
1057
  sourceRoot: workspace.sourceRoot,
820
1058
  analysisRoot: workspace.analysisRoot,
821
1059
  pathMapped: workspace.pathMapped,
822
1060
  };
823
1061
  }
824
- const summaryParts = successful.map((item) => `${item.tool}: ${shorten(item.text || "已返回结构化结果", 110)}`);
825
- const summary = `GitNexus 图谱增强已启用(${successful.length}/${executions.length} 成功): ${summaryParts.join(" | ")}`;
1062
+ const summary = ambiguities.length > 0
1063
+ ? `GitNexus 返回了 ${ambiguities.length} 个歧义结果,请指定 uid file_path 后重试。`
1064
+ : `GitNexus 图谱增强已启用(${successful.length}/${executions.length} 成功): ${successful
1065
+ .map((item) => `${item.tool}: ${describeExecutionSummary(item)}`)
1066
+ .join(" | ")}`;
826
1067
  return {
827
1068
  provider: "gitnexus",
828
1069
  enabled: true,
@@ -834,6 +1075,8 @@ export async function runCodeInsightBridge(request) {
834
1075
  executions,
835
1076
  warnings,
836
1077
  repo: effectiveRepo,
1078
+ launcherStrategy: launcher.strategy,
1079
+ ambiguities,
837
1080
  workspaceMode: workspace.workspaceMode,
838
1081
  sourceRoot: workspace.sourceRoot,
839
1082
  analysisRoot: workspace.analysisRoot,
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from "vitest";
2
- import { codeInsight, resolveCodeInsightQuery } from "../code_insight.js";
2
+ import { buildCodeInsightDelegatedPlan, codeInsight, deriveCodeInsightStatus, resolveCodeInsightQuery, } from "../code_insight.js";
3
3
  import * as os from "node:os";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
@@ -76,6 +76,56 @@ describe("code_insight 单元测试", () => {
76
76
  expect(resolved.finalQuery).toMatch(/核心流程/);
77
77
  expect(resolved.finalQuery).toMatch(/依赖关系/);
78
78
  });
79
+ test("歧义结果会生成候选选择 delegated plan", () => {
80
+ const status = deriveCodeInsightStatus({
81
+ available: true,
82
+ ambiguities: [
83
+ {
84
+ tool: "context",
85
+ message: "找到多个 login 符号",
86
+ candidates: [{ uid: "Method:auth.ts:login:12", file_path: "/repo/src/auth.ts" }],
87
+ },
88
+ ],
89
+ executions: [{ tool: "context", ok: true, durationMs: 10, args: {}, status: "ambiguous" }],
90
+ });
91
+ const plan = buildCodeInsightDelegatedPlan({
92
+ status,
93
+ ambiguities: [
94
+ {
95
+ tool: "context",
96
+ message: "找到多个 login 符号",
97
+ candidates: [{ uid: "Method:auth.ts:login:12", file_path: "/repo/src/auth.ts" }],
98
+ },
99
+ ],
100
+ showPlan: true,
101
+ });
102
+ expect(status).toBe("ambiguous");
103
+ expect(plan?.kind).toBe("ambiguity");
104
+ expect(plan?.steps[1].action).toMatch(/uid 或 file_path/);
105
+ });
106
+ test("未显式要求保存时不生成 docs delegated plan", async () => {
107
+ const prev = process.env.MCP_ENABLE_GITNEXUS_BRIDGE;
108
+ process.env.MCP_ENABLE_GITNEXUS_BRIDGE = "0";
109
+ try {
110
+ const result = await codeInsight({
111
+ mode: "auto",
112
+ });
113
+ expect(result.isError).toBe(false);
114
+ const text = String(result.content?.[0]?.text || "");
115
+ const structured = result.structuredContent;
116
+ expect(text).not.toMatch(/delegated plan/);
117
+ expect(structured.plan).toBeUndefined();
118
+ expect(structured.projectDocs).toBeUndefined();
119
+ }
120
+ finally {
121
+ if (prev === undefined) {
122
+ delete process.env.MCP_ENABLE_GITNEXUS_BRIDGE;
123
+ }
124
+ else {
125
+ process.env.MCP_ENABLE_GITNEXUS_BRIDGE = prev;
126
+ }
127
+ }
128
+ });
79
129
  test("返回 docs 保存指引而不直接代写文件", async () => {
80
130
  const prev = process.env.MCP_ENABLE_GITNEXUS_BRIDGE;
81
131
  process.env.MCP_ENABLE_GITNEXUS_BRIDGE = "0";