codex-plugin-doctor 1.69.1 → 1.70.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.
@@ -0,0 +1,595 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { realpath, stat } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { packageVersion } from "../version.js";
6
+ import { normalizeMcpConfig } from "./mcp-config-normalizer.js";
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";
9
+ import { resolveContainedPackagePath } from "./package-path.js";
10
+ import { readJsonFile } from "./read-json-file.js";
11
+ import { cleanupDockerContainer, resolveRuntimeCwd } from "./runtime-probe.js";
12
+ import { buildRuntimeLaunch, DOCKER_RUNTIME_STARTUP_TIMEOUT_MS, RuntimeSandboxError } from "./runtime-sandbox.js";
13
+ const MODERN_VERSION = "2026-07-28";
14
+ const LEGACY_REQUEST_VERSION = "2025-11-25";
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.
17
+ const LEGACY_CACHE = { ttlMs: 0, cacheScope: "private" };
18
+ const NATIVE_STARTUP_TIMEOUT_MS = 10_000;
19
+ const REQUEST_TIMEOUT_MS = 5_000;
20
+ const NATIVE_TOTAL_TIMEOUT_MS = 30_000;
21
+ const DOCKER_TOTAL_TIMEOUT_MS = 90_000;
22
+ const SHUTDOWN_GRACE_MS = 2_000;
23
+ const DISCOVER_ID = "discover-1";
24
+ const INITIALIZE_ID = "initialize-1";
25
+ function isPlainObject(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ function failure(message) {
29
+ return { ok: false, message };
30
+ }
31
+ async function isFile(filePath) {
32
+ try {
33
+ return (await stat(filePath)).isFile();
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ async function readManifestMcpServers(rootPath) {
40
+ const manifestPath = path.join(rootPath, ".codex-plugin", "plugin.json");
41
+ if (!(await isFile(manifestPath)))
42
+ return { declared: false };
43
+ let manifest;
44
+ try {
45
+ manifest = await readJsonFile(manifestPath);
46
+ }
47
+ catch {
48
+ // An existing manifest that cannot be read must not silently fall back to .mcp.json.
49
+ return { unreadable: true };
50
+ }
51
+ if (!isPlainObject(manifest))
52
+ return { unreadable: true };
53
+ return Object.hasOwn(manifest, "mcpServers")
54
+ ? { declared: true, value: manifest.mcpServers }
55
+ : { declared: false };
56
+ }
57
+ // Reads the server the same way runtime planning does: the manifest mcpServers file first,
58
+ // then .mcp.json for packages without one. Messages never echo paths or configured values.
59
+ export async function resolveMcpStdioServer(targetPath, serverName) {
60
+ let packageRoot;
61
+ try {
62
+ packageRoot = await realpath(path.resolve(targetPath));
63
+ if (!(await stat(packageRoot)).isDirectory())
64
+ return failure("The package path is not a directory.");
65
+ }
66
+ catch {
67
+ return failure("The package path was not found.");
68
+ }
69
+ const manifestServers = await readManifestMcpServers(packageRoot);
70
+ if ("unreadable" in manifestServers)
71
+ return failure("The plugin manifest could not be read as a JSON object.");
72
+ let configPath;
73
+ let source;
74
+ if (manifestServers.declared) {
75
+ const value = manifestServers.value;
76
+ if (typeof value !== "string" || !value.startsWith("./")) {
77
+ return failure("The plugin manifest mcpServers field must be a non-empty package-relative ./ path.");
78
+ }
79
+ configPath = await resolveContainedPackagePath(packageRoot, value);
80
+ source = "manifest";
81
+ }
82
+ else {
83
+ configPath = await resolveContainedPackagePath(packageRoot, ".mcp.json");
84
+ source = "mcp-json";
85
+ }
86
+ if (configPath === null)
87
+ return failure("The MCP server configuration resolves outside the package root.");
88
+ if (!(await isFile(configPath))) {
89
+ return failure(source === "manifest"
90
+ ? "The plugin manifest mcpServers file was not found."
91
+ : "No MCP server configuration was found. Add .mcp.json or a plugin manifest mcpServers path.");
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = await readJsonFile(configPath);
96
+ }
97
+ catch {
98
+ return failure("The MCP server configuration could not be read as JSON.");
99
+ }
100
+ const normalized = normalizeMcpConfig(parsed);
101
+ if (!normalized.ok)
102
+ return failure("The MCP server configuration does not contain a valid server map.");
103
+ if (!Object.hasOwn(normalized.servers, serverName))
104
+ return failure("The named MCP server is not in the package configuration.");
105
+ const config = normalized.servers[serverName];
106
+ if (typeof config.url === "string") {
107
+ return failure("The named MCP server uses HTTP. Run doctor tools <url> --allow-network for HTTP servers.");
108
+ }
109
+ if (typeof config.command !== "string" || config.command.length === 0) {
110
+ return failure("The named MCP server has no command to start.");
111
+ }
112
+ const cwd = await resolveRuntimeCwd(packageRoot, config.cwd);
113
+ if (cwd === null) {
114
+ return failure("The named MCP server cwd must be an existing directory inside the package root.");
115
+ }
116
+ const args = Array.isArray(config.args) ? config.args.filter((value) => typeof value === "string") : [];
117
+ return { ok: true, server: { packageRoot, command: config.command, args, cwd, source } };
118
+ }
119
+ // A session whose process never started, for launches that spawn rejects synchronously.
120
+ const unstartedStdioSession = {
121
+ spawned: false,
122
+ stopped: "startup-failed",
123
+ request: () => Promise.resolve({ kind: "stopped", reason: "startup-failed" }),
124
+ notify: () => undefined,
125
+ close: () => Promise.resolve()
126
+ };
127
+ function startStdioSession(launch, spawnImpl) {
128
+ let child;
129
+ try {
130
+ child = spawnImpl(launch.command, launch.args, {
131
+ cwd: launch.cwd,
132
+ // Server stderr is never read or reported; reports exclude raw server output.
133
+ stdio: ["pipe", "pipe", "ignore"],
134
+ windowsHide: true
135
+ });
136
+ }
137
+ catch {
138
+ // spawn throws synchronously for some launches (NUL bytes, Windows .cmd/.bat shims);
139
+ // its message can echo the command, so it is reduced to a startup failure.
140
+ return unstartedStdioSession;
141
+ }
142
+ const pending = new Map();
143
+ const exitWaiters = new Set();
144
+ const decoder = new TextDecoder("utf-8", { fatal: true });
145
+ let spawned = false;
146
+ let exited = false;
147
+ let stop = null;
148
+ let lineChunks = [];
149
+ let lineBytes = 0;
150
+ let totalBytes = 0;
151
+ const halt = (reason) => {
152
+ if (stop !== null)
153
+ return;
154
+ stop = reason;
155
+ for (const entry of pending.values()) {
156
+ clearTimeout(entry.timer);
157
+ entry.resolve({ kind: "stopped", reason });
158
+ }
159
+ pending.clear();
160
+ };
161
+ const handleLine = (line) => {
162
+ let text;
163
+ try {
164
+ text = decoder.decode(line);
165
+ }
166
+ catch {
167
+ halt("stdout-invalid");
168
+ return;
169
+ }
170
+ if (text.endsWith("\r"))
171
+ text = text.slice(0, -1);
172
+ if (text.trim().length === 0)
173
+ return;
174
+ let message;
175
+ try {
176
+ message = JSON.parse(text);
177
+ }
178
+ catch {
179
+ halt("stdout-invalid");
180
+ return;
181
+ }
182
+ if (!isPlainObject(message) || message.jsonrpc !== "2.0") {
183
+ halt("stdout-invalid");
184
+ return;
185
+ }
186
+ // Notifications and server-initiated requests are neither answered nor reported.
187
+ if (Object.hasOwn(message, "method") || typeof message.id !== "string")
188
+ return;
189
+ const entry = pending.get(message.id);
190
+ if (entry === undefined)
191
+ return;
192
+ pending.delete(message.id);
193
+ clearTimeout(entry.timer);
194
+ entry.resolve({ kind: "message", message });
195
+ };
196
+ child.stdout?.on("data", (chunk) => {
197
+ if (stop !== null)
198
+ return;
199
+ totalBytes += chunk.length;
200
+ if (totalBytes > MAX_CATALOG_TOTAL_RESPONSE_BYTES) {
201
+ halt("total-response-byte-limit");
202
+ return;
203
+ }
204
+ let offset = 0;
205
+ while (stop === null) {
206
+ const newline = chunk.indexOf(0x0a, offset);
207
+ const segment = chunk.subarray(offset, newline === -1 ? chunk.length : newline);
208
+ lineBytes += segment.length;
209
+ if (lineBytes > MAX_CATALOG_RESPONSE_BYTES) {
210
+ halt("response-byte-limit");
211
+ return;
212
+ }
213
+ lineChunks.push(segment);
214
+ if (newline === -1)
215
+ return;
216
+ const line = Buffer.concat(lineChunks, lineBytes);
217
+ lineChunks = [];
218
+ lineBytes = 0;
219
+ handleLine(line);
220
+ offset = newline + 1;
221
+ }
222
+ });
223
+ // Closing stdout means no further replies can arrive, whether or not the process has exited yet.
224
+ child.stdout?.on("close", () => halt(spawned ? "server-exited" : "startup-failed"));
225
+ child.stdin?.on("error", () => undefined);
226
+ child.on("spawn", () => {
227
+ spawned = true;
228
+ });
229
+ child.on("error", () => {
230
+ if (!spawned)
231
+ halt("startup-failed");
232
+ });
233
+ child.on("exit", () => {
234
+ exited = true;
235
+ for (const waiter of exitWaiters)
236
+ waiter();
237
+ exitWaiters.clear();
238
+ });
239
+ const waitForExit = (timeoutMs) => new Promise((resolve) => {
240
+ if (exited) {
241
+ resolve(true);
242
+ return;
243
+ }
244
+ const timer = setTimeout(() => {
245
+ exitWaiters.delete(onExit);
246
+ resolve(false);
247
+ }, timeoutMs);
248
+ const onExit = () => {
249
+ clearTimeout(timer);
250
+ resolve(true);
251
+ };
252
+ exitWaiters.add(onExit);
253
+ });
254
+ return {
255
+ get spawned() {
256
+ return spawned;
257
+ },
258
+ get stopped() {
259
+ return stop;
260
+ },
261
+ request(id, body, timeoutMs) {
262
+ if (stop !== null)
263
+ return Promise.resolve({ kind: "stopped", reason: stop });
264
+ return new Promise((resolve) => {
265
+ const timer = setTimeout(() => {
266
+ pending.delete(id);
267
+ resolve({ kind: "timeout", reason: "request-timeout" });
268
+ }, timeoutMs);
269
+ pending.set(id, { resolve, timer });
270
+ child.stdin?.write(`${body}\n`);
271
+ });
272
+ },
273
+ notify(body) {
274
+ if (stop === null)
275
+ child.stdin?.write(`${body}\n`);
276
+ },
277
+ async close(graceMs) {
278
+ halt("server-exited");
279
+ // Graceful stdio shutdown: close stdin, then escalate to SIGTERM and SIGKILL.
280
+ if (spawned && !exited) {
281
+ child.stdin?.end();
282
+ if (!(await waitForExit(graceMs))) {
283
+ child.kill("SIGTERM");
284
+ if (!(await waitForExit(graceMs))) {
285
+ child.kill("SIGKILL");
286
+ await waitForExit(graceMs);
287
+ }
288
+ }
289
+ }
290
+ child.stdin?.destroy();
291
+ child.stdout?.destroy();
292
+ if (!exited)
293
+ child.unref();
294
+ }
295
+ };
296
+ }
297
+ function stdioDiscovery(status, coverage, findings = [], supportedVersions = []) {
298
+ return {
299
+ schemaVersion: 1,
300
+ requestedVersion: MODERN_VERSION,
301
+ scope: "discovery-only",
302
+ status,
303
+ supportedVersions,
304
+ findings,
305
+ coverage: { discovery: coverage, runtime: "not-tested" }
306
+ };
307
+ }
308
+ function discoveryFinding(id, severity, message, impact, suggestedFix) {
309
+ return { id, severity, message, impact, suggestedFix };
310
+ }
311
+ const legacyImpact = "Clients that only support modern MCP versions cannot use this server.";
312
+ const legacyFix = "Implement server/discover and per-request metadata for MCP 2026-07-28 to support modern clients.";
313
+ const stdoutInvalidImpact = "stdio clients cannot reliably read replies when stdout carries anything other than newline-delimited JSON-RPC messages.";
314
+ const stdoutInvalidFix = "Write only MCP messages to stdout and send logs to stderr.";
315
+ function stopFinding(reason, docker) {
316
+ switch (reason) {
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
319
+ ? "Verify Docker is installed and the Docker daemon is available."
320
+ : "Verify the configured command is installed and runs from the server working directory without a shell.");
321
+ case "stdout-invalid":
322
+ return discoveryFinding("plugin.discovery.stdio.output.invalid", "fail", "The MCP server wrote output to stdout that is not a JSON-RPC message.", stdoutInvalidImpact, stdoutInvalidFix);
323
+ case "response-byte-limit":
324
+ case "total-response-byte-limit":
325
+ return discoveryFinding("plugin.discovery.transport.failed", "fail", "The MCP server's discovery output exceeded the inspection byte limits.", "The discovery reply cannot be read within the inspection bounds.", "Keep each stdio message under 1 MiB.");
326
+ default:
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
+ }
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
+ };
353
+ function legacyInitializeRequest() {
354
+ return JSON.stringify({
355
+ jsonrpc: "2.0",
356
+ id: INITIALIZE_ID,
357
+ method: "initialize",
358
+ params: {
359
+ protocolVersion: LEGACY_REQUEST_VERSION,
360
+ capabilities: {},
361
+ clientInfo: { name: "Codex Plugin Doctor", version: packageVersion }
362
+ }
363
+ });
364
+ }
365
+ function validInitializeResult(message) {
366
+ if (!hasMatchingEnvelope(message, INITIALIZE_ID) || !Object.hasOwn(message, "result"))
367
+ return null;
368
+ const result = message.result;
369
+ return isPlainObject(result) && typeof result.protocolVersion === "string" && isPlainObject(result.capabilities)
370
+ && isPlainObject(result.serverInfo) && typeof result.serverInfo.name === "string" && typeof result.serverInfo.version === "string"
371
+ ? result
372
+ : null;
373
+ }
374
+ // Starts one configured stdio server, reads its tool list without calling tools, and stops it.
375
+ // Tools and cache metadata are returned only for a complete enumeration.
376
+ export async function captureStdioMcpToolCatalog(server, options = {}) {
377
+ const docker = options.sandbox === "docker";
378
+ const now = options.now ?? (() => performance.now());
379
+ const startupMs = options.timeouts?.startupMs ?? (docker ? DOCKER_RUNTIME_STARTUP_TIMEOUT_MS : NATIVE_STARTUP_TIMEOUT_MS);
380
+ const requestMs = options.timeouts?.requestMs ?? REQUEST_TIMEOUT_MS;
381
+ const shutdownMs = options.timeouts?.shutdownMs ?? SHUTDOWN_GRACE_MS;
382
+ const deadline = now() + (options.timeouts?.totalMs ?? (docker ? DOCKER_TOTAL_TIMEOUT_MS : NATIVE_TOTAL_TIMEOUT_MS));
383
+ const spawnImpl = options.spawnImpl ?? spawn;
384
+ const removeContainer = options.removeContainerImpl ?? (async (containerName) => await cleanupDockerContainer(containerName) === null);
385
+ const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
386
+ const buildLaunch = () => docker
387
+ ? buildRuntimeLaunch({
388
+ sandbox: "docker",
389
+ packageRoot: server.packageRoot,
390
+ command: server.command,
391
+ args: server.args,
392
+ cwd: server.cwd,
393
+ containerName: `codex-doctor-${randomUUID()}`
394
+ })
395
+ : {
396
+ command: server.command,
397
+ args: server.args,
398
+ cwd: server.cwd,
399
+ containerName: null,
400
+ evidence: { backend: "native", image: null, network: "host", packageMount: "host" }
401
+ };
402
+ let firstLaunch;
403
+ try {
404
+ firstLaunch = buildLaunch();
405
+ }
406
+ catch (error) {
407
+ 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.")]);
408
+ return finish(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason: "sandbox-unsupported" }, "not-tested", []), { era: null, version: null }, null, null, null);
409
+ }
410
+ const started = [];
411
+ let session;
412
+ const start = (launch) => {
413
+ session = startStdioSession(launch, spawnImpl);
414
+ started.push({ session, containerName: launch.containerName });
415
+ };
416
+ const send = async (id, body, timeoutMs) => {
417
+ const remaining = Math.floor(deadline - now());
418
+ if (remaining < 1)
419
+ return { kind: "timeout", reason: "total-time-limit" };
420
+ const outcome = await session.request(id, body, Math.min(timeoutMs, remaining));
421
+ return outcome.kind === "timeout" && remaining < timeoutMs ? { kind: "timeout", reason: "total-time-limit" } : outcome;
422
+ };
423
+ const enumerate = async (era, protocol, discovery) => {
424
+ const inspector = createMcpToolCatalogInspector();
425
+ const findings = inspector.findings;
426
+ const seenCursors = new Set();
427
+ let pagesRead = 0;
428
+ let cursor;
429
+ let cache = null;
430
+ let reason = null;
431
+ const addFindings = (items) => {
432
+ if (inspector.addFindings(items))
433
+ return true;
434
+ reason = "finding-limit";
435
+ return false;
436
+ };
437
+ const sessionStopped = (stop) => {
438
+ if (stop !== "stdout-invalid") {
439
+ reason = stop;
440
+ }
441
+ else if (addFindings([catalogFinding("plugin.catalog.stdio.output.invalid", "fail", "The MCP server wrote output to stdout that is not a JSON-RPC message.", stdoutInvalidImpact, stdoutInvalidFix)])) {
442
+ reason = stop;
443
+ }
444
+ };
445
+ while (reason === null) {
446
+ if (pagesRead >= MAX_CATALOG_PAGES) {
447
+ reason = "page-limit";
448
+ break;
449
+ }
450
+ const id = `tools-list-${pagesRead + 1}`;
451
+ const outcome = await send(id, era.request(id, cursor), requestMs);
452
+ if (outcome.kind !== "message") {
453
+ if (outcome.kind === "stopped")
454
+ sessionStopped(outcome.reason);
455
+ else
456
+ reason = outcome.reason;
457
+ break;
458
+ }
459
+ const message = outcome.message;
460
+ if (hasMatchingEnvelope(message, id) && Object.hasOwn(message, "error") && validJsonRpcError(message.error) && message.error.code === -32601) {
461
+ 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.")]))
462
+ reason = "tools-list-method-unsupported";
463
+ break;
464
+ }
465
+ const page = era.parse(message, id);
466
+ if (page === null) {
467
+ 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)]))
468
+ reason = "response-invalid";
469
+ break;
470
+ }
471
+ pagesRead += 1;
472
+ cache = cache === null ? page.cache : {
473
+ ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
474
+ cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
475
+ };
476
+ reason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
477
+ if (reason !== null)
478
+ break;
479
+ if (page.cursor === undefined)
480
+ break;
481
+ if (seenCursors.has(page.cursor)) {
482
+ 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.")]))
483
+ reason = "cursor-loop";
484
+ break;
485
+ }
486
+ seenCursors.add(page.cursor);
487
+ cursor = page.cursor;
488
+ }
489
+ // Output that arrived in the same chunk as the final page is validated before the catalog is accepted;
490
+ // a server exiting after its last reply is not a failure.
491
+ const trailingStop = session.stopped;
492
+ if (reason === null && trailingStop !== null && trailingStop !== "server-exited")
493
+ sessionStopped(trailingStop);
494
+ if (reason === null && deadline - now() <= 0)
495
+ reason = "total-time-limit";
496
+ const catalog = { complete: reason === null, pagesRead, toolsChecked: inspector.toolsChecked, reason };
497
+ return reason === null
498
+ ? { status: finalCatalogStatus(findings), protocol, discovery, catalog, enumeration: "complete", findings, tools: inspector.tools, cache }
499
+ : { status: "incomplete", protocol, discovery, catalog, enumeration: "incomplete", findings, tools: null, cache: null };
500
+ };
501
+ const notTested = (status, reason, protocol, discovery, findings = []) => ({
502
+ status, protocol, discovery, catalog: { ...emptyCatalog, reason }, enumeration: "not-tested", findings, tools: null, cache: null
503
+ });
504
+ // answeredDiscovery is false when only silence or an exit suggested a legacy server.
505
+ const runLegacy = async (discovery, restart, answeredDiscovery) => {
506
+ if (restart)
507
+ start(buildLaunch());
508
+ const outcome = await send(INITIALIZE_ID, legacyInitializeRequest(), restart ? startupMs : requestMs);
509
+ const unknownVersion = { era: "legacy", version: null };
510
+ const unestablished = answeredDiscovery ? unknownVersion : { era: null, version: null };
511
+ if (outcome.kind !== "message") {
512
+ return notTested("incomplete", outcome.reason, unestablished, discovery, outcome.reason === "stdout-invalid"
513
+ ? [catalogFinding("plugin.catalog.stdio.output.invalid", "fail", "The MCP server wrote output to stdout that is not a JSON-RPC message.", stdoutInvalidImpact, stdoutInvalidFix)]
514
+ : []);
515
+ }
516
+ const result = validInitializeResult(outcome.message);
517
+ if (result === null) {
518
+ 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.")]);
519
+ }
520
+ const version = result.protocolVersion;
521
+ if (!LEGACY_VERSIONS.has(version)) {
522
+ 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.")]);
523
+ }
524
+ const protocol = { era: "legacy", version };
525
+ session.notify(JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }));
526
+ const capabilities = result.capabilities;
527
+ if (!isPlainObject(capabilities.tools))
528
+ return notTested("not-applicable", null, protocol, discovery);
529
+ return enumerate(legacyEra, protocol, discovery);
530
+ };
531
+ const run = async () => {
532
+ start(firstLaunch);
533
+ const probe = await send(DISCOVER_ID, serializeMcpDiscoverRequest(DISCOVER_ID), startupMs);
534
+ const undetermined = { era: null, version: null };
535
+ if ((probe.kind === "stopped" && probe.reason === "server-exited") || (probe.kind === "timeout" && probe.reason === "request-timeout")) {
536
+ // stdio era detection: no reply to discovery identifies a legacy server; restart one that exited.
537
+ return runLegacy(stdioDiscovery("unsupported", "skipped", [discoveryFinding("plugin.discovery.stdio.no_response", "warn", "The MCP server did not answer server discovery; the legacy initialize handshake was used.", legacyImpact, legacyFix)]), probe.kind === "stopped", false);
538
+ }
539
+ if (probe.kind !== "message") {
540
+ const reason = probe.reason;
541
+ return notTested("incomplete", reason, undetermined, stdioDiscovery("failed", "fail", [stopFinding(reason, docker)]));
542
+ }
543
+ const outcome = classifyMcpDiscoverMessage(probe.message, DISCOVER_ID);
544
+ const modernUnknown = { era: "modern", version: null };
545
+ switch (outcome.kind) {
546
+ case "other-error":
547
+ return runLegacy(stdioDiscovery("unsupported", "skipped", [discoveryFinding("plugin.discovery.method.unsupported", "warn", "The MCP server does not implement server discovery; the legacy initialize handshake was used.", legacyImpact, legacyFix)]), false, true);
548
+ case "version-unsupported":
549
+ case "version-not-listed":
550
+ return notTested("unsupported", "discovery-unsupported", modernUnknown, stdioDiscovery("unsupported", "skipped", [discoveryFinding(outcome.kind === "version-unsupported" ? "plugin.discovery.protocol_version.unsupported" : "plugin.discovery.protocol_version.inconsistent", "warn", outcome.kind === "version-unsupported"
551
+ ? "The MCP server does not support the requested protocol version."
552
+ : "The MCP server completed discovery without listing the requested protocol version.", "Discovery-only probing cannot establish support for the requested MCP protocol version.", "Configure a server that supports the requested protocol version, then run discovery again.")], outcome.supportedVersions));
553
+ case "result-invalid":
554
+ case "envelope-invalid":
555
+ return notTested("incomplete", "discovery-failed", modernUnknown, stdioDiscovery("failed", "fail", [discoveryFinding(outcome.kind === "result-invalid" ? "plugin.discovery.result.invalid" : "plugin.discovery.response.invalid", "fail", outcome.kind === "result-invalid" ? "The MCP server returned an invalid discovery result." : "The MCP server returned an invalid discovery response.", "The discovery response cannot safely establish modern MCP discovery support.", "Verify the server returns a valid server/discover response for the requested protocol version.")]));
556
+ case "discovered": {
557
+ const discovery = stdioDiscovery("discovered", "pass", [], outcome.supportedVersions);
558
+ const protocol = { era: "modern", version: MODERN_VERSION };
559
+ if (!Object.hasOwn(outcome.capabilities, "tools"))
560
+ return notTested("not-applicable", null, protocol, discovery);
561
+ return enumerate(modernEra, protocol, discovery);
562
+ }
563
+ }
564
+ };
565
+ let result;
566
+ let cleanupFailed = false;
567
+ try {
568
+ result = await run();
569
+ }
570
+ finally {
571
+ for (const entry of started) {
572
+ await entry.session.close(shutdownMs);
573
+ // A container exists only if the docker client process started.
574
+ if (entry.containerName !== null && entry.session.spawned && !(await removeContainer(entry.containerName))) {
575
+ cleanupFailed = true;
576
+ }
577
+ }
578
+ }
579
+ const findings = result.findings;
580
+ if (cleanupFailed && findings.length < 100) {
581
+ 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."));
582
+ }
583
+ const complete = result.tools !== null;
584
+ const status = complete ? finalCatalogStatus(findings) : result.status;
585
+ const execution = started.some((entry) => entry.session.spawned) ? firstLaunch.evidence : null;
586
+ return finish(buildMcpToolCatalogReport(result.discovery, status, result.catalog, result.enumeration, findings), result.protocol, execution, result.tools, result.cache);
587
+ }
588
+ function finish(report, protocol, execution, tools, cache) {
589
+ const { schemaVersion, scope, status, discovery, catalog, coverage, findings } = report;
590
+ return {
591
+ report: { schemaVersion, scope, transport: "stdio", status, protocol, execution, discovery, catalog, coverage, findings },
592
+ tools,
593
+ cache
594
+ };
595
+ }
@@ -1,9 +1,13 @@
1
- import { type McpDiscoveryReport } from "./mcp-discovery.js";
1
+ 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
- type CatalogStatus = "pass" | "warn" | "fail" | "unsupported" | "not-applicable" | "incomplete" | "blocked";
6
- type EnumerationCoverage = "complete" | "incomplete" | "not-tested";
5
+ import type { RuntimeExecutionEvidence } from "../domain/types.js";
6
+ export declare const MAX_CATALOG_PAGES = 5;
7
+ export declare const MAX_CATALOG_RESPONSE_BYTES: number;
8
+ export declare const MAX_CATALOG_TOTAL_RESPONSE_BYTES: number;
9
+ export type CatalogStatus = "pass" | "warn" | "fail" | "unsupported" | "not-applicable" | "incomplete" | "blocked";
10
+ export type EnumerationCoverage = "complete" | "incomplete" | "not-tested";
7
11
  export interface McpToolCatalogOptions {
8
12
  allowNetwork?: boolean;
9
13
  allowLocalNetwork?: boolean;
@@ -12,10 +16,17 @@ export interface McpToolCatalogOptions {
12
16
  now?: () => number;
13
17
  bearerToken?: string;
14
18
  }
19
+ export interface McpToolCatalogProtocol {
20
+ era: "modern" | "legacy" | null;
21
+ version: string | null;
22
+ }
15
23
  export interface McpToolCatalogReport {
16
24
  schemaVersion: 1;
17
25
  scope: "tool-catalog-structure";
26
+ transport?: "stdio";
18
27
  status: CatalogStatus;
28
+ protocol?: McpToolCatalogProtocol;
29
+ execution?: RuntimeExecutionEvidence | null;
19
30
  discovery: McpDiscoveryReport;
20
31
  catalog: {
21
32
  complete: boolean;
@@ -42,6 +53,27 @@ export interface McpToolCatalogSnapshot {
42
53
  tools: unknown[] | null;
43
54
  cache: McpToolCatalogCache | null;
44
55
  }
56
+ export declare function catalogFinding(id: string, severity: CatalogFinding["severity"], message: string, impact: string, suggestedFix: string): CatalogFinding;
57
+ export declare function validJsonRpcError(value: unknown): value is McpJsonObject & {
58
+ code: number;
59
+ message: string;
60
+ };
61
+ export declare function hasMatchingEnvelope(message: McpJsonObject, requestId: string): boolean;
62
+ export declare function parseToolsResult(message: McpJsonObject, requestId: string): {
63
+ tools: unknown[];
64
+ cursor: string | undefined;
65
+ cache: McpToolCatalogCache;
66
+ } | null;
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";
69
+ export interface McpToolCatalogInspector {
70
+ readonly findings: CatalogFinding[];
71
+ readonly tools: unknown[];
72
+ readonly toolsChecked: number;
73
+ addFindings(items: CatalogFinding[]): boolean;
74
+ inspectPage(pageTools: unknown[], page: number, timeExpired: () => boolean): "tool-limit" | "finding-limit" | "total-time-limit" | null;
75
+ }
76
+ export declare function createMcpToolCatalogInspector(): McpToolCatalogInspector;
77
+ export declare function serializeToolsListRequest(id: string, cursor: string | undefined): string;
45
78
  export declare function inspectMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogReport>;
46
79
  export declare function captureMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
47
- export {};