codex-plugin-doctor 1.76.0 → 1.77.0

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 (36) hide show
  1. package/README.md +18 -2
  2. package/dist/core/mcp-prompt-catalog.d.ts +36 -0
  3. package/dist/core/mcp-prompt-catalog.js +53 -0
  4. package/dist/core/mcp-prompt-definition.d.ts +24 -0
  5. package/dist/core/mcp-prompt-definition.js +107 -0
  6. package/dist/core/mcp-prompt-diff.d.ts +43 -0
  7. package/dist/core/mcp-prompt-diff.js +132 -0
  8. package/dist/core/mcp-prompt-file.d.ts +31 -0
  9. package/dist/core/mcp-prompt-file.js +118 -0
  10. package/dist/core/mcp-prompt-impact.d.ts +21 -0
  11. package/dist/core/mcp-prompt-impact.js +93 -0
  12. package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
  13. package/dist/core/mcp-stdio-tool-catalog.js +74 -55
  14. package/dist/core/mcp-tool-catalog.d.ts +44 -5
  15. package/dist/core/mcp-tool-catalog.js +76 -31
  16. package/dist/core/mcp-tool-diff.d.ts +1 -0
  17. package/dist/core/mcp-tool-diff.js +1 -1
  18. package/dist/core/mcp-tool-file.d.ts +20 -0
  19. package/dist/core/mcp-tool-file.js +1 -1
  20. package/dist/core/mcp-tool-response-file.d.ts +6 -4
  21. package/dist/core/mcp-tool-response-file.js +8 -8
  22. package/dist/core/output-contract.js +177 -0
  23. package/dist/reporting/quote-server-text.d.ts +1 -0
  24. package/dist/reporting/quote-server-text.js +13 -0
  25. package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
  26. package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
  27. package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
  28. package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
  29. package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
  30. package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
  31. package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
  32. package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
  33. package/dist/reporting/render-mcp-tool-diff-markdown.js +3 -16
  34. package/dist/run-cli.d.ts +7 -0
  35. package/dist/run-cli.js +203 -140
  36. package/package.json +1 -1
@@ -0,0 +1,93 @@
1
+ export const PROMPT_CHANGE_REASONS = [
2
+ "prompt-removed",
3
+ "prompt-argument-removed",
4
+ "prompt-required-argument-added",
5
+ "prompt-argument-now-required",
6
+ "prompt-argument-fields-unclassified",
7
+ "other-fields-unclassified"
8
+ ];
9
+ export const MAX_PROMPT_CHANGE_LOCATIONS = 100;
10
+ const UNCLASSIFIED_REASONS = new Set(["prompt-argument-fields-unclassified", "other-fields-unclassified"]);
11
+ // Argument fields with a classified effect; a change to any other argument field is unclassified.
12
+ const CLASSIFIED_ARGUMENT_FIELDS = new Set(["name", "title", "description", "required"]);
13
+ function isJsonObject(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ // Arguments by name, in list order; null when the list is not one of uniquely named argument objects.
17
+ function argumentsByName(value) {
18
+ if (value === undefined)
19
+ return new Map();
20
+ if (!Array.isArray(value))
21
+ return null;
22
+ const byName = new Map();
23
+ for (const argument of value) {
24
+ if (!isJsonObject(argument) || typeof argument.name !== "string" || byName.has(argument.name))
25
+ return null;
26
+ byName.set(argument.name, argument);
27
+ }
28
+ return byName;
29
+ }
30
+ function unclassifiedFields(argument) {
31
+ const fields = Object.create(null);
32
+ for (const key of Object.keys(argument)) {
33
+ if (!CLASSIFIED_ARGUMENT_FIELDS.has(key))
34
+ fields[key] = argument[key];
35
+ }
36
+ return fields;
37
+ }
38
+ function classifyArguments(before, after, equal, add) {
39
+ const beforeArguments = argumentsByName(before);
40
+ const afterArguments = argumentsByName(after);
41
+ // Inspected definitions always have readable arguments; anything else cannot be compared by name.
42
+ if (beforeArguments === null || afterArguments === null) {
43
+ add("prompt-argument-fields-unclassified", null);
44
+ return;
45
+ }
46
+ for (const [name, beforeArgument] of beforeArguments) {
47
+ const afterArgument = afterArguments.get(name);
48
+ if (afterArgument === undefined) {
49
+ add("prompt-argument-removed", name);
50
+ continue;
51
+ }
52
+ if (beforeArgument.required !== true && afterArgument.required === true)
53
+ add("prompt-argument-now-required", name);
54
+ if (!equal(unclassifiedFields(beforeArgument), unclassifiedFields(afterArgument)))
55
+ add("prompt-argument-fields-unclassified", name);
56
+ }
57
+ for (const [name, afterArgument] of afterArguments) {
58
+ if (!beforeArguments.has(name) && afterArgument.required === true)
59
+ add("prompt-required-argument-added", name);
60
+ }
61
+ }
62
+ function result(reasons, locations) {
63
+ const ordered = PROMPT_CHANGE_REASONS.filter((reason) => reasons.has(reason));
64
+ const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
65
+ ? "breaking"
66
+ : ordered.length > 0 ? "unclassified" : "compatible";
67
+ return locations === null ? { impact, reasons: ordered } : { impact, reasons: ordered, locations };
68
+ }
69
+ export function classifyRemovedPrompt() {
70
+ return { impact: "breaking", reasons: ["prompt-removed"] };
71
+ }
72
+ export function classifyAddedPrompt() {
73
+ return { impact: "compatible", reasons: [] };
74
+ }
75
+ export function classifyChangedPrompt(before, after, fields, equal, options = {}) {
76
+ const reasons = new Set();
77
+ // In comparison order: arguments in before order, then added arguments, then the prompt itself.
78
+ const locations = options.locate ? [] : null;
79
+ const seen = new Set();
80
+ const add = (reason, argument) => {
81
+ reasons.add(reason);
82
+ const key = JSON.stringify([reason, argument]);
83
+ if (locations !== null && locations.length < MAX_PROMPT_CHANGE_LOCATIONS && !seen.has(key)) {
84
+ seen.add(key);
85
+ locations.push({ reason, argument });
86
+ }
87
+ };
88
+ if (fields.includes("arguments"))
89
+ classifyArguments(before.arguments, after.arguments, equal, add);
90
+ if (fields.includes("other"))
91
+ add("other-fields-unclassified", null);
92
+ return result(reasons, locations);
93
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import type { RuntimeSandboxMode } from "../domain/types.js";
3
- import { type McpToolCatalogSnapshot } from "./mcp-tool-catalog.js";
2
+ import type { Finding, RuntimeExecutionEvidence, RuntimeSandboxMode } from "../domain/types.js";
3
+ import { type McpCatalogCapture, type McpCatalogList, type McpToolCatalogProtocol, type McpToolCatalogSnapshot } from "./mcp-tool-catalog.js";
4
4
  export interface McpStdioServer {
5
5
  packageRoot: string;
6
6
  command: string;
@@ -28,5 +28,11 @@ export interface McpStdioToolCatalogOptions {
28
28
  shutdownMs?: number;
29
29
  };
30
30
  }
31
- export declare function resolveMcpStdioServer(targetPath: string, serverName: string): Promise<McpStdioServerResolution>;
31
+ export declare function resolveMcpStdioServer(targetPath: string, serverName: string, command?: "tools" | "prompts"): Promise<McpStdioServerResolution>;
32
32
  export declare function captureStdioMcpToolCatalog(server: McpStdioServer, options?: McpStdioToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
33
+ export interface McpStdioCatalogCapture<F extends Finding> {
34
+ capture: McpCatalogCapture<F>;
35
+ protocol: McpToolCatalogProtocol;
36
+ execution: RuntimeExecutionEvidence | null;
37
+ }
38
+ export declare function captureStdioMcpCatalog<F extends Finding>(server: McpStdioServer, list: McpCatalogList<F>, options?: McpStdioToolCatalogOptions): Promise<McpStdioCatalogCapture<F>>;
@@ -5,7 +5,7 @@ import path from "node:path";
5
5
  import { packageVersion } from "../version.js";
6
6
  import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
7
7
  import { classifyMcpDiscoverMessage, serializeMcpDiscoverRequest } from "./mcp-discovery.js";
8
- import { buildMcpToolCatalogReport, catalogFinding, createMcpToolCatalogInspector, finalCatalogStatus, hasMatchingEnvelope, MAX_CATALOG_PAGES, MAX_CATALOG_RESPONSE_BYTES, MAX_CATALOG_TOTAL_RESPONSE_BYTES, parseToolsResult, serializeToolsListRequest, validJsonRpcError } from "./mcp-tool-catalog.js";
8
+ import { buildMcpToolCatalogReport, catalogFinding, finalCatalogStatus, hasMatchingEnvelope, listCursorLoop, listMethodUnsupported, listResponseInvalid, MAX_CATALOG_PAGES, MAX_CATALOG_RESPONSE_BYTES, MAX_CATALOG_TOTAL_RESPONSE_BYTES, MCP_TOOLS_LIST, modernInvalidFix, parseListResult, serializeListRequest, validJsonRpcError } from "./mcp-tool-catalog.js";
9
9
  import { resolveContainedPackagePath } from "./package-path.js";
10
10
  import { readJsonFile } from "./read-json-file.js";
11
11
  import { cleanupDockerContainer, resolveRuntimeCwd } from "./runtime-probe.js";
@@ -13,7 +13,7 @@ import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxEr
13
13
  const MODERN_VERSION = "2026-07-28";
14
14
  const LEGACY_REQUEST_VERSION = "2025-11-25";
15
15
  const LEGACY_VERSIONS = new Set(["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"]);
16
- // Legacy tools/list results carry no cache metadata; saved responses use the most conservative values.
16
+ // Legacy list results carry no cache metadata; saved responses use the most conservative values.
17
17
  const LEGACY_CACHE = { ttlMs: 0, cacheScope: "private" };
18
18
  const NATIVE_STARTUP_TIMEOUT_MS = 10_000;
19
19
  const REQUEST_TIMEOUT_MS = 5_000;
@@ -56,7 +56,7 @@ async function readManifestMcpServers(rootPath) {
56
56
  }
57
57
  // Reads the server the same way runtime planning does: the manifest mcpServers file first,
58
58
  // then .mcp.json for packages without one. Messages never echo paths or configured values.
59
- export async function resolveMcpStdioServer(targetPath, serverName) {
59
+ export async function resolveMcpStdioServer(targetPath, serverName, command = "tools") {
60
60
  let packageRoot;
61
61
  try {
62
62
  packageRoot = await realpath(path.resolve(targetPath));
@@ -104,7 +104,7 @@ export async function resolveMcpStdioServer(targetPath, serverName) {
104
104
  return failure("The named MCP server is not in the package configuration.");
105
105
  const config = normalized.servers[serverName];
106
106
  if (typeof config.url === "string") {
107
- return failure("The named MCP server uses HTTP. Run doctor tools <url> --allow-network for HTTP servers.");
107
+ return failure(`The named MCP server uses HTTP. Run doctor ${command} <url> --allow-network for HTTP servers.`);
108
108
  }
109
109
  if (typeof config.command !== "string" || config.command.length === 0) {
110
110
  return failure("The named MCP server has no command to start.");
@@ -312,10 +312,10 @@ const legacyImpact = "Clients that only support modern MCP versions cannot use t
312
312
  const legacyFix = "Implement server/discover and per-request metadata for MCP 2026-07-28 to support modern clients.";
313
313
  const stdoutInvalidImpact = "stdio clients cannot reliably read replies when stdout carries anything other than newline-delimited JSON-RPC messages.";
314
314
  const stdoutInvalidFix = "Write only MCP messages to stdout and send logs to stderr.";
315
- function stopFinding(reason, docker) {
315
+ function stopFinding(reason, docker, noun) {
316
316
  switch (reason) {
317
317
  case "startup-failed":
318
- return discoveryFinding("plugin.discovery.stdio.startup_failed", "fail", "The MCP server process could not be started.", "No tool definitions can be read from a server that does not start.", docker
318
+ return discoveryFinding("plugin.discovery.stdio.startup_failed", "fail", "The MCP server process could not be started.", `No ${noun} definitions can be read from a server that does not start.`, docker
319
319
  ? "Verify Docker is installed and the Docker daemon is available."
320
320
  : "Verify the configured command is installed and runs from the server working directory without a shell.");
321
321
  case "stdout-invalid":
@@ -327,29 +327,33 @@ function stopFinding(reason, docker) {
327
327
  return discoveryFinding("plugin.discovery.transport.timeout", "fail", "Server discovery did not complete within the inspection time limit.", "The discovery reply cannot be read within the inspection bounds.", "Reduce server startup latency, then inspect again.");
328
328
  }
329
329
  }
330
- const modernEra = {
331
- request: serializeToolsListRequest,
332
- parse: parseToolsResult,
333
- invalidFix: "Return a matching JSON-RPC complete result with valid tools and cache metadata."
334
- };
335
- const legacyEra = {
336
- request: (id, cursor) => JSON.stringify({
337
- jsonrpc: "2.0",
338
- id,
339
- method: "tools/list",
340
- ...(cursor === undefined ? {} : { params: { cursor } })
341
- }),
342
- parse: (message, id) => {
343
- if (!hasMatchingEnvelope(message, id) || !Object.hasOwn(message, "result") || !isPlainObject(message.result)
344
- || !Array.isArray(message.result.tools)) {
345
- return null;
346
- }
347
- if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string")
348
- return null;
349
- return { tools: message.result.tools, cursor: message.result.nextCursor, cache: LEGACY_CACHE };
350
- },
351
- invalidFix: "Return a matching JSON-RPC result with a tools array and an optional string nextCursor."
352
- };
330
+ function modernEra(list) {
331
+ return {
332
+ request: (id, cursor) => serializeListRequest(list.method, id, cursor),
333
+ parse: (message, id) => parseListResult(message, id, list.capability),
334
+ invalidFix: modernInvalidFix(list)
335
+ };
336
+ }
337
+ function legacyEra(list) {
338
+ return {
339
+ request: (id, cursor) => JSON.stringify({
340
+ jsonrpc: "2.0",
341
+ id,
342
+ method: list.method,
343
+ ...(cursor === undefined ? {} : { params: { cursor } })
344
+ }),
345
+ parse: (message, id) => {
346
+ if (!hasMatchingEnvelope(message, id) || !Object.hasOwn(message, "result") || !isPlainObject(message.result)
347
+ || !Array.isArray(message.result[list.capability])) {
348
+ return null;
349
+ }
350
+ if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string")
351
+ return null;
352
+ return { items: message.result[list.capability], cursor: message.result.nextCursor, cache: LEGACY_CACHE };
353
+ },
354
+ invalidFix: `Return a matching JSON-RPC result with a ${list.capability} array and an optional string nextCursor.`
355
+ };
356
+ }
353
357
  function legacyInitializeRequest() {
354
358
  return JSON.stringify({
355
359
  jsonrpc: "2.0",
@@ -374,6 +378,13 @@ function validInitializeResult(message) {
374
378
  // Starts one configured stdio server, reads its tool list without calling tools, and stops it.
375
379
  // Tools and cache metadata are returned only for a complete enumeration.
376
380
  export async function captureStdioMcpToolCatalog(server, options = {}) {
381
+ const { capture, protocol, execution } = await captureStdioMcpCatalog(server, MCP_TOOLS_LIST, options);
382
+ const { complete, pagesRead, checked, reason } = capture.catalog;
383
+ return finish(buildMcpToolCatalogReport(capture.discovery, capture.status, { complete, pagesRead, toolsChecked: checked, reason }, capture.enumeration, capture.findings), protocol, execution, capture.items, capture.cache);
384
+ }
385
+ // Starts one configured stdio server, reads one definition list without using the definitions, and stops it.
386
+ // Definitions and cache metadata are returned only for a complete enumeration.
387
+ export async function captureStdioMcpCatalog(server, list, options = {}) {
377
388
  const docker = options.sandbox === "docker";
378
389
  const now = options.now ?? (() => performance.now());
379
390
  const startupMs = options.timeouts?.startupMs ?? (docker ? DOCKER_RUNTIME_STARTUP_TIMEOUT_MS : NATIVE_STARTUP_TIMEOUT_MS);
@@ -382,7 +393,7 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
382
393
  const deadline = now() + (options.timeouts?.totalMs ?? (docker ? DOCKER_TOTAL_TIMEOUT_MS : NATIVE_TOTAL_TIMEOUT_MS));
383
394
  const spawnImpl = options.spawnImpl ?? spawn;
384
395
  const removeContainer = options.removeContainerImpl ?? (async (containerName) => await cleanupDockerContainer(containerName) === null);
385
- const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
396
+ const emptyCatalog = { complete: false, pagesRead: 0, checked: 0, reason: null };
386
397
  const buildLaunch = () => docker
387
398
  ? buildRuntimeLaunch({
388
399
  sandbox: "docker",
@@ -405,8 +416,12 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
405
416
  firstLaunch = buildLaunch();
406
417
  }
407
418
  catch (error) {
408
- const discovery = stdioDiscovery("blocked", "skipped", [discoveryFinding("plugin.discovery.sandbox.unsupported", "warn", error instanceof RuntimeSandboxError ? error.message : "The MCP server cannot run in the requested sandbox.", "The tool catalog was not read because the server cannot be isolated as requested.", "Use a Node.js command inside the package root with --sandbox docker, or review the command before running it without a sandbox.")]);
409
- return finish(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason: "sandbox-unsupported" }, "not-tested", []), { era: null, version: null }, null, null, null);
419
+ const discovery = stdioDiscovery("blocked", "skipped", [discoveryFinding("plugin.discovery.sandbox.unsupported", "warn", error instanceof RuntimeSandboxError ? error.message : "The MCP server cannot run in the requested sandbox.", `The ${list.noun} catalog was not read because the server cannot be isolated as requested.`, "Use a Node.js command inside the package root with --sandbox docker, or review the command before running it without a sandbox.")]);
420
+ return {
421
+ capture: { discovery, status: "blocked", catalog: { ...emptyCatalog, reason: "sandbox-unsupported" }, enumeration: "not-tested", findings: [], items: null, cache: null },
422
+ protocol: { era: null, version: null },
423
+ execution: null
424
+ };
410
425
  }
411
426
  const started = [];
412
427
  let session;
@@ -422,7 +437,7 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
422
437
  return outcome.kind === "timeout" && remaining < timeoutMs ? { kind: "timeout", reason: "total-time-limit" } : outcome;
423
438
  };
424
439
  const enumerate = async (era, protocol, discovery) => {
425
- const inspector = createMcpToolCatalogInspector();
440
+ const inspector = list.createInspector();
426
441
  const findings = inspector.findings;
427
442
  const seenCursors = new Set();
428
443
  let pagesRead = 0;
@@ -448,7 +463,7 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
448
463
  reason = "page-limit";
449
464
  break;
450
465
  }
451
- const id = `tools-list-${pagesRead + 1}`;
466
+ const id = `${list.capability}-list-${pagesRead + 1}`;
452
467
  const outcome = await send(id, era.request(id, cursor), requestMs);
453
468
  if (outcome.kind !== "message") {
454
469
  if (outcome.kind === "stopped")
@@ -459,13 +474,13 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
459
474
  }
460
475
  const message = outcome.message;
461
476
  if (hasMatchingEnvelope(message, id) && Object.hasOwn(message, "error") && validJsonRpcError(message.error) && message.error.code === -32601) {
462
- if (addFindings([catalogFinding("plugin.catalog.tools_list.method_unsupported", "warn", "The MCP server advertised tools but does not implement tools/list.", "Tool definitions cannot be enumerated from the advertised capability.", "Implement tools/list for the advertised MCP tools capability.")]))
463
- reason = "tools-list-method-unsupported";
477
+ if (addFindings([listMethodUnsupported(list, "MCP server")]))
478
+ reason = `${list.capability}-list-method-unsupported`;
464
479
  break;
465
480
  }
466
481
  const page = era.parse(message, id);
467
482
  if (page === null) {
468
- if (addFindings([catalogFinding("plugin.catalog.response.invalid", "fail", "The MCP server returned an invalid tools/list response.", "The tool catalog cannot be safely enumerated from this response.", era.invalidFix)]))
483
+ if (addFindings([listResponseInvalid(list, "MCP server", era.invalidFix)]))
469
484
  reason = "response-invalid";
470
485
  break;
471
486
  }
@@ -474,13 +489,13 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
474
489
  ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
475
490
  cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
476
491
  };
477
- reason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
492
+ reason = inspector.inspectPage(page.items, pagesRead, () => deadline - now() <= 0);
478
493
  if (reason !== null)
479
494
  break;
480
495
  if (page.cursor === undefined)
481
496
  break;
482
497
  if (seenCursors.has(page.cursor)) {
483
- if (addFindings([catalogFinding("plugin.catalog.cursor.loop", "warn", "The MCP server repeated a tools/list cursor.", "The bounded catalog enumeration cannot determine a complete result set.", "Return each continuation cursor at most once for a single enumeration.")]))
498
+ if (addFindings([listCursorLoop(list, "MCP server")]))
484
499
  reason = "cursor-loop";
485
500
  break;
486
501
  }
@@ -494,13 +509,13 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
494
509
  sessionStopped(trailingStop);
495
510
  if (reason === null && deadline - now() <= 0)
496
511
  reason = "total-time-limit";
497
- const catalog = { complete: reason === null, pagesRead, toolsChecked: inspector.toolsChecked, reason };
512
+ const catalog = { complete: reason === null, pagesRead, checked: inspector.checked, reason };
498
513
  return reason === null
499
- ? { status: finalCatalogStatus(findings), protocol, discovery, catalog, enumeration: "complete", findings, tools: inspector.tools, cache }
500
- : { status: "incomplete", protocol, discovery, catalog, enumeration: "incomplete", findings, tools: null, cache: null };
514
+ ? { status: finalCatalogStatus(findings), protocol, discovery, catalog, enumeration: "complete", findings, items: inspector.items, cache }
515
+ : { status: "incomplete", protocol, discovery, catalog, enumeration: "incomplete", findings, items: null, cache: null };
501
516
  };
502
517
  const notTested = (status, reason, protocol, discovery, findings = []) => ({
503
- status, protocol, discovery, catalog: { ...emptyCatalog, reason }, enumeration: "not-tested", findings, tools: null, cache: null
518
+ status, protocol, discovery, catalog: { ...emptyCatalog, reason }, enumeration: "not-tested", findings: findings, items: null, cache: null
504
519
  });
505
520
  // answeredDiscovery is false when only silence or an exit suggested a legacy server.
506
521
  const runLegacy = async (discovery, restart, answeredDiscovery) => {
@@ -516,18 +531,18 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
516
531
  }
517
532
  const result = validInitializeResult(outcome.message);
518
533
  if (result === null) {
519
- return notTested("incomplete", "initialize-failed", unestablished, discovery, [catalogFinding("plugin.catalog.initialize.invalid", "fail", "The MCP server did not complete the legacy initialize handshake.", "The tool catalog cannot be read from a legacy server that does not initialize.", "Return protocolVersion, capabilities, and serverInfo from initialize, or implement server/discover.")]);
534
+ return notTested("incomplete", "initialize-failed", unestablished, discovery, [catalogFinding("plugin.catalog.initialize.invalid", "fail", "The MCP server did not complete the legacy initialize handshake.", `The ${list.noun} catalog cannot be read from a legacy server that does not initialize.`, "Return protocolVersion, capabilities, and serverInfo from initialize, or implement server/discover.")]);
520
535
  }
521
536
  const version = result.protocolVersion;
522
537
  if (!LEGACY_VERSIONS.has(version)) {
523
- return notTested("unsupported", "protocol-version-unsupported", unknownVersion, discovery, [catalogFinding("plugin.catalog.initialize.version_unsupported", "warn", "The MCP server negotiated a legacy protocol version this inspection does not support.", "The tool catalog was not read because the negotiated version is unknown to this inspection.", "Support MCP 2025-11-25 or MCP 2026-07-28.")]);
538
+ return notTested("unsupported", "protocol-version-unsupported", unknownVersion, discovery, [catalogFinding("plugin.catalog.initialize.version_unsupported", "warn", "The MCP server negotiated a legacy protocol version this inspection does not support.", `The ${list.noun} catalog was not read because the negotiated version is unknown to this inspection.`, "Support MCP 2025-11-25 or MCP 2026-07-28.")]);
524
539
  }
525
540
  const protocol = { era: "legacy", version };
526
541
  session.notify(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
527
542
  const capabilities = result.capabilities;
528
- if (!isPlainObject(capabilities.tools))
543
+ if (!isPlainObject(capabilities[list.capability]))
529
544
  return notTested("not-applicable", null, protocol, discovery);
530
- return enumerate(legacyEra, protocol, discovery);
545
+ return enumerate(legacyEra(list), protocol, discovery);
531
546
  };
532
547
  const run = async () => {
533
548
  start(firstLaunch);
@@ -539,7 +554,7 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
539
554
  }
540
555
  if (probe.kind !== "message") {
541
556
  const reason = probe.reason;
542
- return notTested("incomplete", reason, undetermined, stdioDiscovery("failed", "fail", [stopFinding(reason, docker)]));
557
+ return notTested("incomplete", reason, undetermined, stdioDiscovery("failed", "fail", [stopFinding(reason, docker, list.noun)]));
543
558
  }
544
559
  const outcome = classifyMcpDiscoverMessage(probe.message, DISCOVER_ID);
545
560
  const modernUnknown = { era: "modern", version: null };
@@ -557,9 +572,9 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
557
572
  case "discovered": {
558
573
  const discovery = stdioDiscovery("discovered", "pass", [], outcome.supportedVersions);
559
574
  const protocol = { era: "modern", version: MODERN_VERSION };
560
- if (!Object.hasOwn(outcome.capabilities, "tools"))
575
+ if (!Object.hasOwn(outcome.capabilities, list.capability))
561
576
  return notTested("not-applicable", null, protocol, discovery);
562
- return enumerate(modernEra, protocol, discovery);
577
+ return enumerate(modernEra(list), protocol, discovery);
563
578
  }
564
579
  }
565
580
  };
@@ -577,14 +592,18 @@ export async function captureStdioMcpToolCatalog(server, options = {}) {
577
592
  }
578
593
  }
579
594
  }
595
+ // Findings without a location fit every catalog finding type.
580
596
  const findings = result.findings;
581
597
  if (cleanupFailed && findings.length < 100) {
582
- findings.push(catalogFinding("plugin.catalog.stdio.sandbox_cleanup_failed", "fail", "The Docker runtime container could not be removed after reading the tool list.", "A leftover runtime container weakens cleanup guarantees and may continue consuming local resources.", "Verify the Docker daemon is available, then remove the codex-doctor container."));
598
+ findings.push(catalogFinding("plugin.catalog.stdio.sandbox_cleanup_failed", "fail", `The Docker runtime container could not be removed after reading the ${list.noun} list.`, "A leftover runtime container weakens cleanup guarantees and may continue consuming local resources.", "Verify the Docker daemon is available, then remove the codex-doctor container."));
583
599
  }
584
- const complete = result.tools !== null;
585
- const status = complete ? finalCatalogStatus(findings) : result.status;
586
- const execution = started.some((entry) => entry.session.spawned) ? firstLaunch.evidence : null;
587
- return finish(buildMcpToolCatalogReport(result.discovery, status, result.catalog, result.enumeration, findings), result.protocol, execution, result.tools, result.cache);
600
+ const complete = result.items !== null;
601
+ const { protocol, ...capture } = result;
602
+ return {
603
+ capture: { ...capture, status: complete ? finalCatalogStatus(findings) : result.status },
604
+ protocol,
605
+ execution: started.some((entry) => entry.session.spawned) ? firstLaunch.evidence : null
606
+ };
588
607
  }
589
608
  function finish(report, protocol, execution, tools, cache) {
590
609
  const { schemaVersion, scope, status, discovery, catalog, coverage, findings } = report;
@@ -2,7 +2,7 @@ import { type McpDiscoveryReport, type McpJsonObject } from "./mcp-discovery.js"
2
2
  import { type CatalogFinding } from "./mcp-tool-definition.js";
3
3
  import { type RemoteLookup } from "./remote-network-policy.js";
4
4
  import type { RemoteMcpRequest } from "./remote-mcp-probe.js";
5
- import type { RuntimeExecutionEvidence } from "../domain/types.js";
5
+ import type { Finding, RuntimeExecutionEvidence } from "../domain/types.js";
6
6
  export declare const MAX_CATALOG_PAGES = 5;
7
7
  export declare const MAX_CATALOG_RESPONSE_BYTES: number;
8
8
  export declare const MAX_CATALOG_TOTAL_RESPONSE_BYTES: number;
@@ -59,13 +59,19 @@ export declare function validJsonRpcError(value: unknown): value is McpJsonObjec
59
59
  message: string;
60
60
  };
61
61
  export declare function hasMatchingEnvelope(message: McpJsonObject, requestId: string): boolean;
62
- export declare function parseToolsResult(message: McpJsonObject, requestId: string): {
63
- tools: unknown[];
62
+ export declare function parseListResult(message: McpJsonObject, requestId: string, resultKey: McpCatalogList<Finding>["capability"]): {
63
+ items: unknown[];
64
64
  cursor: string | undefined;
65
65
  cache: McpToolCatalogCache;
66
66
  } | null;
67
67
  export declare function buildMcpToolCatalogReport(discovery: McpDiscoveryReport, status: CatalogStatus, catalog: McpToolCatalogReport["catalog"], catalogEnumeration: EnumerationCoverage, findings: CatalogFinding[]): McpToolCatalogReport;
68
- export declare function finalCatalogStatus(findings: CatalogFinding[]): "pass" | "warn" | "fail";
68
+ export declare function finalCatalogStatus(findings: Finding[]): "pass" | "warn" | "fail";
69
+ type CatalogListName = Pick<McpCatalogList<Finding>, "method" | "capability" | "noun">;
70
+ type CatalogServer = "endpoint" | "MCP server";
71
+ export declare function listMethodUnsupported(list: CatalogListName, server: CatalogServer): CatalogFinding;
72
+ export declare function modernInvalidFix(list: CatalogListName): string;
73
+ export declare function listResponseInvalid(list: CatalogListName, server: CatalogServer, suggestedFix: string): CatalogFinding;
74
+ export declare function listCursorLoop(list: CatalogListName, server: CatalogServer): CatalogFinding;
69
75
  export interface McpToolCatalogInspector {
70
76
  readonly findings: CatalogFinding[];
71
77
  readonly tools: unknown[];
@@ -74,6 +80,39 @@ export interface McpToolCatalogInspector {
74
80
  inspectPage(pageTools: unknown[], page: number, timeExpired: () => boolean): "tool-limit" | "finding-limit" | "total-time-limit" | null;
75
81
  }
76
82
  export declare function createMcpToolCatalogInspector(): McpToolCatalogInspector;
77
- export declare function serializeToolsListRequest(id: string, cursor: string | undefined): string;
83
+ /** A catalog page inspector for one kind of definition, shaped like the tool and prompt inspectors. */
84
+ export interface McpCatalogInspector<F extends Finding> {
85
+ readonly findings: F[];
86
+ readonly items: unknown[];
87
+ readonly checked: number;
88
+ addFindings(items: Finding[]): boolean;
89
+ inspectPage(items: unknown[], page: number, timeExpired: () => boolean): string | null;
90
+ }
91
+ /** What differs between enumerating tool and prompt definitions; discovery, limits, paging, and findings are shared. */
92
+ export interface McpCatalogList<F extends Finding> {
93
+ method: "tools/list" | "prompts/list";
94
+ capability: "tools" | "prompts";
95
+ noun: "tool" | "prompt";
96
+ createInspector(): McpCatalogInspector<F>;
97
+ }
98
+ export declare const MCP_TOOLS_LIST: McpCatalogList<CatalogFinding>;
99
+ /** A capture before it is shaped into a tool or prompt catalog report. */
100
+ export interface McpCatalogCapture<F extends Finding> {
101
+ discovery: McpDiscoveryReport;
102
+ status: CatalogStatus;
103
+ catalog: {
104
+ complete: boolean;
105
+ pagesRead: number;
106
+ checked: number;
107
+ reason: string | null;
108
+ };
109
+ enumeration: EnumerationCoverage;
110
+ findings: F[];
111
+ items: unknown[] | null;
112
+ cache: McpToolCatalogCache | null;
113
+ }
114
+ export declare function serializeListRequest(method: McpCatalogList<Finding>["method"], id: string, cursor: string | undefined): string;
78
115
  export declare function inspectMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogReport>;
79
116
  export declare function captureMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
117
+ export declare function captureMcpCatalog<F extends Finding>(rawUrl: string, list: McpCatalogList<F>, options?: McpToolCatalogOptions): Promise<McpCatalogCapture<F>>;
118
+ export {};