codex-plugin-doctor 1.76.0 → 1.78.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.
- package/README.md +21 -2
- package/dist/core/init-ci.d.ts +1 -0
- package/dist/core/init-ci.js +16 -2
- package/dist/core/mcp-prompt-catalog.d.ts +36 -0
- package/dist/core/mcp-prompt-catalog.js +53 -0
- package/dist/core/mcp-prompt-definition.d.ts +24 -0
- package/dist/core/mcp-prompt-definition.js +107 -0
- package/dist/core/mcp-prompt-diff.d.ts +43 -0
- package/dist/core/mcp-prompt-diff.js +132 -0
- package/dist/core/mcp-prompt-file.d.ts +31 -0
- package/dist/core/mcp-prompt-file.js +118 -0
- package/dist/core/mcp-prompt-impact.d.ts +21 -0
- package/dist/core/mcp-prompt-impact.js +93 -0
- package/dist/core/mcp-stdio-tool-catalog.d.ts +9 -3
- package/dist/core/mcp-stdio-tool-catalog.js +74 -55
- package/dist/core/mcp-tool-catalog.d.ts +44 -5
- package/dist/core/mcp-tool-catalog.js +76 -31
- package/dist/core/mcp-tool-diff.d.ts +1 -0
- package/dist/core/mcp-tool-diff.js +1 -1
- package/dist/core/mcp-tool-file.d.ts +20 -0
- package/dist/core/mcp-tool-file.js +1 -1
- package/dist/core/mcp-tool-response-file.d.ts +6 -4
- package/dist/core/mcp-tool-response-file.js +8 -8
- package/dist/core/output-contract.js +177 -0
- package/dist/reporting/quote-server-text.d.ts +1 -0
- package/dist/reporting/quote-server-text.js +13 -0
- package/dist/reporting/render-mcp-prompt-catalog-report.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-catalog-report.js +31 -0
- package/dist/reporting/render-mcp-prompt-diff-markdown.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-diff-markdown.js +39 -0
- package/dist/reporting/render-mcp-prompt-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-prompt-diff-report.js +48 -0
- package/dist/reporting/render-mcp-prompt-file-report.d.ts +5 -0
- package/dist/reporting/render-mcp-prompt-file-report.js +33 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.js +3 -16
- package/dist/run-cli.d.ts +7 -0
- package/dist/run-cli.js +223 -150
- 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,
|
|
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
|
|
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(
|
|
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.",
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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,
|
|
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.",
|
|
409
|
-
return
|
|
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 =
|
|
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 =
|
|
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([
|
|
463
|
-
reason =
|
|
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([
|
|
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.
|
|
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([
|
|
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,
|
|
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,
|
|
500
|
-
: { status: "incomplete", protocol, discovery, catalog, enumeration: "incomplete", findings,
|
|
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,
|
|
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.",
|
|
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.",
|
|
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.
|
|
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,
|
|
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",
|
|
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.
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
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
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
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 {};
|