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.
@@ -4,20 +4,20 @@ import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
4
4
  import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
5
5
  import { packageVersion } from "../version.js";
6
6
  const REQUESTED_VERSION = "2026-07-28";
7
- const MAX_PAGES = 5;
7
+ export const MAX_CATALOG_PAGES = 5;
8
8
  const MAX_TOOLS = 500;
9
9
  const MAX_FINDINGS = 100;
10
- const MAX_RESPONSE_BYTES = 1024 * 1024;
11
- const MAX_TOTAL_RESPONSE_BYTES = 4 * 1024 * 1024;
10
+ export const MAX_CATALOG_RESPONSE_BYTES = 1024 * 1024;
11
+ export const MAX_CATALOG_TOTAL_RESPONSE_BYTES = 4 * 1024 * 1024;
12
12
  const REQUEST_TIMEOUT_MS = 3_000;
13
13
  const TOTAL_TIMEOUT_MS = 15_000;
14
14
  function isPlainObject(value) {
15
15
  return typeof value === "object" && value !== null && !Array.isArray(value);
16
16
  }
17
- function finding(id, severity, message, impact, suggestedFix) {
17
+ export function catalogFinding(id, severity, message, impact, suggestedFix) {
18
18
  return { id, severity, message, impact, suggestedFix };
19
19
  }
20
- function validError(value) {
20
+ export function validJsonRpcError(value) {
21
21
  return isPlainObject(value) && typeof value.code === "number" && Number.isFinite(value.code) && typeof value.message === "string";
22
22
  }
23
23
  function validCacheMetadata(value) {
@@ -25,11 +25,11 @@ function validCacheMetadata(value) {
25
25
  && typeof value.ttlMs === "number" && Number.isFinite(value.ttlMs) && value.ttlMs >= 0
26
26
  && (value.cacheScope === "public" || value.cacheScope === "private");
27
27
  }
28
- function hasMatchingEnvelope(message, requestId) {
28
+ export function hasMatchingEnvelope(message, requestId) {
29
29
  return message.jsonrpc === "2.0" && message.id === requestId
30
30
  && Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
31
31
  }
32
- function parseToolsResult(message, requestId) {
32
+ export function parseToolsResult(message, requestId) {
33
33
  if (!hasMatchingEnvelope(message, requestId)) {
34
34
  return null;
35
35
  }
@@ -45,7 +45,7 @@ function parseToolsResult(message, requestId) {
45
45
  cache: { ttlMs: message.result.ttlMs, cacheScope: message.result.cacheScope }
46
46
  };
47
47
  }
48
- function catalogReport(discovery, status, catalog, catalogEnumeration, findings) {
48
+ export function buildMcpToolCatalogReport(discovery, status, catalog, catalogEnumeration, findings) {
49
49
  return {
50
50
  schemaVersion: 1,
51
51
  scope: "tool-catalog-structure",
@@ -63,7 +63,7 @@ function catalogReport(discovery, status, catalog, catalogEnumeration, findings)
63
63
  findings
64
64
  };
65
65
  }
66
- function finalStatus(findings) {
66
+ export function finalCatalogStatus(findings) {
67
67
  return findings.some((item) => item.severity === "fail")
68
68
  ? "fail"
69
69
  : findings.some((item) => item.severity === "warn")
@@ -73,7 +73,58 @@ function finalStatus(findings) {
73
73
  function isAuthorizationFinding(discovery) {
74
74
  return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required" || item.id === "plugin.discovery.authorization.rejected");
75
75
  }
76
- function requestBody(id, cursor) {
76
+ // Shared by the HTTP and stdio catalogs so both apply the same tool, finding, and duplicate-name rules.
77
+ export function createMcpToolCatalogInspector() {
78
+ const findings = [];
79
+ const tools = [];
80
+ const names = new Map();
81
+ let toolsChecked = 0;
82
+ const addFindings = (items) => {
83
+ if (findings.length + items.length > MAX_FINDINGS) {
84
+ findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
85
+ return false;
86
+ }
87
+ findings.push(...items);
88
+ return true;
89
+ };
90
+ return {
91
+ findings,
92
+ tools,
93
+ get toolsChecked() {
94
+ return toolsChecked;
95
+ },
96
+ addFindings,
97
+ inspectPage(pageTools, page, timeExpired) {
98
+ for (const tool of pageTools) {
99
+ if (toolsChecked >= MAX_TOOLS) {
100
+ return "tool-limit";
101
+ }
102
+ toolsChecked += 1;
103
+ tools.push(tool);
104
+ const location = { page, toolIndex: toolsChecked };
105
+ if (!addFindings(inspectMcpToolDefinition(tool, location))) {
106
+ return "finding-limit";
107
+ }
108
+ if (isPlainObject(tool) && typeof tool.name === "string") {
109
+ const previous = names.get(tool.name);
110
+ if (previous !== undefined && !addFindings([{
111
+ ...catalogFinding("plugin.catalog.tool.name.duplicate", "warn", "Two enumerated tools use the same name.", "Duplicate tool names can make client selection ambiguous.", "Use a unique MCP tool name for each definition."),
112
+ location: { ...location, relatedToolIndex: previous }
113
+ }])) {
114
+ return "finding-limit";
115
+ }
116
+ if (previous === undefined)
117
+ names.set(tool.name, toolsChecked);
118
+ }
119
+ if (timeExpired()) {
120
+ return "total-time-limit";
121
+ }
122
+ }
123
+ return null;
124
+ }
125
+ };
126
+ }
127
+ export function serializeToolsListRequest(id, cursor) {
77
128
  return JSON.stringify({
78
129
  jsonrpc: "2.0",
79
130
  id,
@@ -100,7 +151,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
100
151
  let budgetReason = null;
101
152
  const requestWithinBudget = async (url, requestOptions = {}) => {
102
153
  const remainingTime = deadline - now();
103
- const remainingBytes = MAX_TOTAL_RESPONSE_BYTES - receivedBytes;
154
+ const remainingBytes = MAX_CATALOG_TOTAL_RESPONSE_BYTES - receivedBytes;
104
155
  const timeoutMs = Math.floor(Math.min(REQUEST_TIMEOUT_MS, remainingTime));
105
156
  if (timeoutMs < 1) {
106
157
  budgetReason = "total-time-limit";
@@ -110,7 +161,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
110
161
  budgetReason = "total-response-byte-limit";
111
162
  throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response byte budget exhausted.");
112
163
  }
113
- const maxResponseBytes = Math.min(MAX_RESPONSE_BYTES, remainingBytes);
164
+ const maxResponseBytes = Math.min(MAX_CATALOG_RESPONSE_BYTES, remainingBytes);
114
165
  const response = await request(url, {
115
166
  ...requestOptions,
116
167
  timeoutMs,
@@ -118,7 +169,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
118
169
  });
119
170
  receivedBytes += response.body.length;
120
171
  if (response.body.length > maxResponseBytes) {
121
- budgetReason = receivedBytes > MAX_TOTAL_RESPONSE_BYTES ? "total-response-byte-limit" : "response-byte-limit";
172
+ budgetReason = receivedBytes > MAX_CATALOG_TOTAL_RESPONSE_BYTES ? "total-response-byte-limit" : "response-byte-limit";
122
173
  throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response exceeded the byte budget.");
123
174
  }
124
175
  return response;
@@ -141,45 +192,40 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
141
192
  const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
142
193
  ? "credentials-insecure-transport"
143
194
  : "network-blocked";
144
- return partial(catalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
195
+ return partial(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
145
196
  }
146
197
  if (isAuthorizationFinding(discovery)) {
147
- return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
198
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
148
199
  }
149
200
  if (discovery.status === "unsupported") {
150
- return partial(catalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
201
+ return partial(buildMcpToolCatalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
151
202
  }
152
203
  if (discovery.status !== "discovered") {
153
- return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
204
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
154
205
  }
155
206
  const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
156
207
  if (capabilities === null) {
157
- return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
208
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
158
209
  }
159
210
  if (!Object.hasOwn(capabilities, "tools")) {
160
- return partial(catalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
211
+ return partial(buildMcpToolCatalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
161
212
  }
162
- const findings = [];
163
- const names = new Map();
213
+ const inspector = createMcpToolCatalogInspector();
214
+ const findings = inspector.findings;
164
215
  const seenCursors = new Set();
165
216
  let pagesRead = 0;
166
- let toolsChecked = 0;
167
217
  let cursor;
168
218
  let incompleteReason = null;
169
219
  let blocked = false;
170
- const tools = [];
171
220
  let cache = null;
172
221
  const addFindings = (items) => {
173
- if (findings.length + items.length > MAX_FINDINGS) {
174
- findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
175
- incompleteReason = "finding-limit";
176
- return false;
177
- }
178
- findings.push(...items);
179
- return true;
222
+ if (inspector.addFindings(items))
223
+ return true;
224
+ incompleteReason = "finding-limit";
225
+ return false;
180
226
  };
181
227
  while (incompleteReason === null && !blocked) {
182
- if (pagesRead >= MAX_PAGES) {
228
+ if (pagesRead >= MAX_CATALOG_PAGES) {
183
229
  incompleteReason = "page-limit";
184
230
  break;
185
231
  }
@@ -195,7 +241,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
195
241
  lookup: options.lookup,
196
242
  bearerToken: options.bearerToken,
197
243
  method: "POST",
198
- body: requestBody(requestId, cursor),
244
+ body: serializeToolsListRequest(requestId, cursor),
199
245
  headers: {
200
246
  Accept: "application/json, text/event-stream",
201
247
  "Content-Type": "application/json",
@@ -226,19 +272,19 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
226
272
  }
227
273
  const message = parseMcpResponse(response, requestId);
228
274
  if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
229
- && validError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
230
- if (addFindings([finding("plugin.catalog.tools_list.method_unsupported", "warn", "The endpoint advertised tools but does not implement tools/list.", "Tool definitions cannot be enumerated from the advertised capability.", "Implement tools/list for the advertised modern MCP tools capability.")]))
275
+ && validJsonRpcError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
276
+ if (addFindings([catalogFinding("plugin.catalog.tools_list.method_unsupported", "warn", "The endpoint advertised tools but does not implement tools/list.", "Tool definitions cannot be enumerated from the advertised capability.", "Implement tools/list for the advertised modern MCP tools capability.")]))
231
277
  incompleteReason = "tools-list-method-unsupported";
232
278
  break;
233
279
  }
234
280
  if (response.statusCode !== 200) {
235
- if (addFindings([finding("plugin.catalog.http_status.invalid", "fail", "The endpoint returned an unexpected tools/list HTTP status.", "The tool catalog cannot be safely enumerated from this response.", "Return HTTP 200 for a tools/list response after authorization succeeds.")]))
281
+ if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail", "The endpoint returned an unexpected tools/list HTTP status.", "The tool catalog cannot be safely enumerated from this response.", "Return HTTP 200 for a tools/list response after authorization succeeds.")]))
236
282
  incompleteReason = "http-status-invalid";
237
283
  break;
238
284
  }
239
285
  const page = message === null ? null : parseToolsResult(message, requestId);
240
286
  if (page === null) {
241
- if (addFindings([finding("plugin.catalog.response.invalid", "fail", "The endpoint returned an invalid tools/list response.", "The tool catalog cannot be safely enumerated from this response.", "Return a matching JSON-RPC complete result with valid tools and cache metadata.")]))
287
+ if (addFindings([catalogFinding("plugin.catalog.response.invalid", "fail", "The endpoint returned an invalid tools/list response.", "The tool catalog cannot be safely enumerated from this response.", "Return a matching JSON-RPC complete result with valid tools and cache metadata.")]))
242
288
  incompleteReason = "response-invalid";
243
289
  break;
244
290
  }
@@ -247,57 +293,32 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
247
293
  ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
248
294
  cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
249
295
  };
250
- for (const tool of page.tools) {
251
- if (toolsChecked >= MAX_TOOLS) {
252
- incompleteReason = "tool-limit";
253
- break;
254
- }
255
- toolsChecked += 1;
256
- tools.push(tool);
257
- const location = { page: pagesRead, toolIndex: toolsChecked };
258
- if (!addFindings(inspectMcpToolDefinition(tool, location))) {
259
- break;
260
- }
261
- if (isPlainObject(tool) && typeof tool.name === "string") {
262
- const previous = names.get(tool.name);
263
- if (previous !== undefined && !addFindings([{
264
- ...finding("plugin.catalog.tool.name.duplicate", "warn", "Two enumerated tools use the same name.", "Duplicate tool names can make client selection ambiguous.", "Use a unique MCP tool name for each definition."),
265
- location: { ...location, relatedToolIndex: previous }
266
- }])) {
267
- break;
268
- }
269
- if (previous === undefined)
270
- names.set(tool.name, toolsChecked);
271
- }
272
- if (deadline - now() <= 0) {
273
- incompleteReason = "total-time-limit";
274
- break;
275
- }
276
- }
296
+ incompleteReason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
277
297
  if (incompleteReason !== null)
278
298
  break;
279
299
  if (page.cursor === undefined)
280
300
  break;
281
301
  if (seenCursors.has(page.cursor)) {
282
- if (addFindings([finding("plugin.catalog.cursor.loop", "warn", "The endpoint 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.")]))
302
+ if (addFindings([catalogFinding("plugin.catalog.cursor.loop", "warn", "The endpoint 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.")]))
283
303
  incompleteReason = "cursor-loop";
284
304
  break;
285
305
  }
286
306
  seenCursors.add(page.cursor);
287
307
  cursor = page.cursor;
288
308
  }
309
+ const toolsChecked = inspector.toolsChecked;
289
310
  if (blocked) {
290
- return partial(catalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
311
+ return partial(buildMcpToolCatalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
291
312
  }
292
313
  if (incompleteReason !== null) {
293
- return partial(catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
314
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
294
315
  }
295
316
  if (deadline - now() <= 0) {
296
- return partial(catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
317
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
297
318
  }
298
319
  return {
299
- report: catalogReport(discovery, finalStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
300
- tools,
320
+ report: buildMcpToolCatalogReport(discovery, finalCatalogStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
321
+ tools: inspector.tools,
301
322
  cache
302
323
  };
303
324
  }
@@ -650,7 +650,27 @@ const publicSchemaDefinitions = [
650
650
  properties: {
651
651
  schemaVersion: { const: 1 },
652
652
  scope: { const: "tool-catalog-structure" },
653
+ // transport, protocol, and execution appear only on reports from `doctor tools <path> --server <name> --runtime`.
654
+ transport: { const: "stdio" },
653
655
  status: { enum: ["pass", "warn", "fail", "unsupported", "not-applicable", "incomplete", "blocked"] },
656
+ protocol: {
657
+ type: "object", required: ["era", "version"],
658
+ properties: {
659
+ era: { enum: ["modern", "legacy", null] },
660
+ version: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
661
+ },
662
+ additionalProperties: false
663
+ },
664
+ execution: {
665
+ type: ["object", "null"], required: ["backend", "image", "network", "packageMount"],
666
+ properties: {
667
+ backend: { enum: ["native", "docker"] },
668
+ image: { type: ["string", "null"] },
669
+ network: { enum: ["host", "none"] },
670
+ packageMount: { enum: ["host", "read_only"] }
671
+ },
672
+ additionalProperties: false
673
+ },
654
674
  discovery: {
655
675
  type: "object",
656
676
  required: ["schemaVersion", "requestedVersion", "scope", "status", "supportedVersions", "findings", "coverage"],
@@ -1,6 +1,8 @@
1
- import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
1
+ import type { DiscoveredPackage, Finding, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
2
2
  import { type RemoteMcpRequest } from "./remote-mcp-probe.js";
3
3
  import type { RemoteLookup } from "./remote-network-policy.js";
4
+ export declare function cleanupDockerContainer(containerName: string): Promise<Finding | null>;
5
+ export declare function resolveRuntimeCwd(rootPath: string, configuredCwd: unknown): Promise<string | null>;
4
6
  export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
5
7
  export interface RuntimeProbeOptions {
6
8
  startupTimeoutMs?: number;
@@ -110,7 +110,7 @@ function withRuntimeEvidence(finding, serverName) {
110
110
  }
111
111
  };
112
112
  }
113
- function cleanupDockerContainer(containerName) {
113
+ export function cleanupDockerContainer(containerName) {
114
114
  return new Promise((resolve) => {
115
115
  execFile("docker", ["rm", "-f", containerName], {
116
116
  encoding: "utf8",
@@ -588,7 +588,7 @@ function findCallableTool(tools) {
588
588
  function hasValidToolInputSchemas(tools) {
589
589
  return tools.every((tool) => isPlainObject(tool.inputSchema) && tool.inputSchema.type === "object");
590
590
  }
591
- async function resolveRuntimeCwd(rootPath, configuredCwd) {
591
+ export async function resolveRuntimeCwd(rootPath, configuredCwd) {
592
592
  const resolvedCwd = typeof configuredCwd === "string"
593
593
  ? path.resolve(rootPath, configuredCwd)
594
594
  : rootPath;
@@ -4,6 +4,13 @@ export function renderMcpToolCatalogReport(report) {
4
4
  "================",
5
5
  `Status: ${report.status}`,
6
6
  `Scope: ${report.scope}`,
7
+ ...(report.transport === "stdio"
8
+ ? [
9
+ "Transport: stdio",
10
+ `Protocol: ${report.protocol?.era ?? "not established"}${report.protocol?.version ? ` (${report.protocol.version})` : ""}`,
11
+ `Execution: ${report.execution?.backend ?? "not started"}`
12
+ ]
13
+ : []),
7
14
  `Discovery: ${report.discovery.status}`,
8
15
  `Catalog complete: ${report.catalog.complete}`,
9
16
  `Pages read: ${report.catalog.pagesRead}`,
package/dist/run-cli.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { discoverMcpServer } from "./core/mcp-discovery.js";
2
2
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
3
+ import { captureStdioMcpToolCatalog } from "./core/mcp-stdio-tool-catalog.js";
3
4
  import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
4
5
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
5
6
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
@@ -21,6 +22,7 @@ export interface RunCliOptions {
21
22
  inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
22
23
  inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
23
24
  captureMcpToolCatalogImpl?: typeof captureMcpToolCatalog;
25
+ captureStdioMcpToolCatalogImpl?: typeof captureStdioMcpToolCatalog;
24
26
  discoverMcpServerImpl?: typeof discoverMcpServer;
25
27
  terminalContext?: CliTerminalContext;
26
28
  runCheckImpl?: typeof runCheck;
package/dist/run-cli.js CHANGED
@@ -24,6 +24,7 @@ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
24
24
  import { isValidBearerToken } from "./core/bounded-http-client.js";
25
25
  import { discoverMcpServer } from "./core/mcp-discovery.js";
26
26
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
27
+ import { captureStdioMcpToolCatalog, resolveMcpStdioServer } from "./core/mcp-stdio-tool-catalog.js";
27
28
  import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
28
29
  import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
29
30
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
@@ -112,7 +113,7 @@ class CliUsageError extends Error {
112
113
  }
113
114
  const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
114
115
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
115
- const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]";
116
+ const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
116
117
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
117
118
  const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
118
119
  const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
@@ -128,6 +129,105 @@ function resolveBearerToken(name, env) {
128
129
  return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
129
130
  return { token };
130
131
  }
132
+ async function finishToolCatalogCommand(io, snapshot, json, savePath) {
133
+ const report = snapshot.report;
134
+ io.writeStdout(json
135
+ ? JSON.stringify(report, null, 2)
136
+ : renderMcpToolCatalogReport(report));
137
+ const exitCode = report.status === "pass" ? 0
138
+ : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
139
+ if (savePath === undefined)
140
+ return exitCode;
141
+ if (snapshot.tools === null || snapshot.cache === null) {
142
+ io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
143
+ return 2;
144
+ }
145
+ const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
146
+ if (saved.kind !== "saved") {
147
+ io.writeStderr({
148
+ "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
149
+ "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
150
+ "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
151
+ "write-failed": "Tool catalog response could not be written."
152
+ }[saved.kind]);
153
+ return 2;
154
+ }
155
+ io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
156
+ return exitCode;
157
+ }
158
+ async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
159
+ const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
160
+ const switches = [];
161
+ const values = new Map();
162
+ let invalid = false;
163
+ for (let index = 0; index < flags.length; index += 1) {
164
+ const flag = flags[index];
165
+ if (["--server", "--save-response", "--sandbox", "--runtime-approval-digest"].includes(flag) && !values.has(flag)) {
166
+ const value = flags[index + 1];
167
+ if (!value || value.startsWith("-")) {
168
+ invalid = true;
169
+ break;
170
+ }
171
+ values.set(flag, value);
172
+ index += 1;
173
+ }
174
+ else if (allowedSwitches.has(flag) && !switches.includes(flag)) {
175
+ switches.push(flag);
176
+ }
177
+ else {
178
+ invalid = true;
179
+ break;
180
+ }
181
+ }
182
+ const serverName = values.get("--server");
183
+ const savePath = values.get("--save-response");
184
+ const sandbox = values.get("--sandbox");
185
+ const approvalDigest = values.get("--runtime-approval-digest");
186
+ const requireApproval = switches.includes("--require-runtime-approval");
187
+ if (invalid || serverName === undefined || (sandbox !== undefined && sandbox !== "docker")) {
188
+ io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
189
+ return 2;
190
+ }
191
+ if (!switches.includes("--runtime")) {
192
+ io.writeStderr("doctor tools --server starts a local MCP server and requires explicit --runtime consent.");
193
+ return 2;
194
+ }
195
+ if (approvalDigest !== undefined && !requireApproval) {
196
+ io.writeStderr("--runtime-approval-digest requires --require-runtime-approval.");
197
+ return 2;
198
+ }
199
+ if (isBlockedOfflinePath(targetPath)) {
200
+ io.writeStderr("doctor tools --server requires a local package path.");
201
+ return 2;
202
+ }
203
+ if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
204
+ io.writeStderr("doctor tools --save-response requires a local file path.");
205
+ return 2;
206
+ }
207
+ const resolved = await resolveMcpStdioServer(targetPath, serverName);
208
+ if (!resolved.ok) {
209
+ io.writeStderr(resolved.message);
210
+ return 2;
211
+ }
212
+ if (requireApproval) {
213
+ // Runtime plan digests cover only the manifest mcpServers file, so .mcp.json servers cannot be approved.
214
+ if (resolved.server.source !== "manifest") {
215
+ io.writeStderr("Runtime approval covers servers from the plugin manifest mcpServers file; this server comes from .mcp.json.");
216
+ return 2;
217
+ }
218
+ const runtimePlan = await buildDoctorRuntimePlan(targetPath, new Date().toISOString(), sandbox === "docker" ? { sandbox: "docker" } : {});
219
+ const approval = evaluateRuntimeApproval(runtimePlan, {
220
+ required: true,
221
+ approvedDigest: approvalDigest ?? null
222
+ });
223
+ if (!runtimeApprovalPassed(approval)) {
224
+ io.writeStderr(`${approval.message}\nCurrent runtime plan digest: ${runtimePlan.digest}`);
225
+ return 1;
226
+ }
227
+ }
228
+ const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox === "docker" ? { sandbox: "docker" } : {});
229
+ return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
230
+ }
131
231
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
132
232
  const index = flags.indexOf("--sandbox");
133
233
  if (index === -1) {
@@ -1176,9 +1276,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
1176
1276
  (command === "release" && maybePath === "check") ||
1177
1277
  (command === "doctor" &&
1178
1278
  (["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
1179
- (maybePath === "release-evidence" && remainingArgs[0] !== "verify")));
1279
+ (maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
1280
+ (maybePath === "tools" && remainingArgs.includes("--server"))));
1180
1281
  if (args.includes("--sandbox") && !runtimeSandboxSupported) {
1181
- io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, and release-evidence.");
1282
+ io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, and doctor tools --server.");
1182
1283
  return 2;
1183
1284
  }
1184
1285
  if (command === "--version" || command === "-v" || command === "version") {
@@ -1299,7 +1400,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1299
1400
  }
1300
1401
  if (maybePath === "tools") {
1301
1402
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1302
- io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
1403
+ io.writeStdout(`${toolsUsage}\nTool catalog structure only; tools are not executed.\n--server starts the named stdio server from the package MCP configuration after explicit --runtime consent and stops it after reading the tool list.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
1303
1404
  return 0;
1304
1405
  }
1305
1406
  const [url, ...flags] = remainingArgs;
@@ -1307,6 +1408,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
1307
1408
  io.writeStderr(toolsUsage);
1308
1409
  return 2;
1309
1410
  }
1411
+ if (flags.includes("--server")) {
1412
+ return runStdioToolCatalogCommand(url, flags, io, options);
1413
+ }
1310
1414
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1311
1415
  const switches = [];
1312
1416
  let savePath;
@@ -1359,30 +1463,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1359
1463
  const snapshot = savePath === undefined
1360
1464
  ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
1361
1465
  : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
1362
- const report = snapshot.report;
1363
- io.writeStdout(switches.includes("--json")
1364
- ? JSON.stringify(report, null, 2)
1365
- : renderMcpToolCatalogReport(report));
1366
- const exitCode = report.status === "pass" ? 0
1367
- : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
1368
- if (savePath === undefined)
1369
- return exitCode;
1370
- if (snapshot.tools === null || snapshot.cache === null) {
1371
- io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
1372
- return 2;
1373
- }
1374
- const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
1375
- if (saved.kind !== "saved") {
1376
- io.writeStderr({
1377
- "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
1378
- "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
1379
- "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
1380
- "write-failed": "Tool catalog response could not be written."
1381
- }[saved.kind]);
1382
- return 2;
1383
- }
1384
- io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
1385
- return exitCode;
1466
+ return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
1386
1467
  }
1387
1468
  if (maybePath === "discover") {
1388
1469
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.69.1",
3
+ "version": "1.70.0",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",