codex-plugin-doctor 1.68.0 → 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")
@@ -71,9 +71,60 @@ function finalStatus(findings) {
71
71
  : "pass";
72
72
  }
73
73
  function isAuthorizationFinding(discovery) {
74
- return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required");
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;
@@ -128,6 +179,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
128
179
  allowNetwork: options.allowNetwork,
129
180
  allowLocalNetwork: options.allowLocalNetwork,
130
181
  lookup: options.lookup,
182
+ bearerToken: options.bearerToken,
131
183
  request: async (url, requestOptions) => {
132
184
  const response = await requestWithinBudget(url, requestOptions);
133
185
  discoveryResponse = response;
@@ -137,45 +189,43 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
137
189
  const partial = (report) => ({ report, tools: null, cache: null });
138
190
  const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
139
191
  if (discovery.status === "blocked") {
140
- return partial(catalogReport(discovery, "blocked", { ...emptyCatalog, reason: "network-blocked" }, "not-tested", []));
192
+ const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
193
+ ? "credentials-insecure-transport"
194
+ : "network-blocked";
195
+ return partial(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
141
196
  }
142
197
  if (isAuthorizationFinding(discovery)) {
143
- return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
198
+ return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
144
199
  }
145
200
  if (discovery.status === "unsupported") {
146
- return partial(catalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
201
+ return partial(buildMcpToolCatalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
147
202
  }
148
203
  if (discovery.status !== "discovered") {
149
- 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", []));
150
205
  }
151
206
  const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
152
207
  if (capabilities === null) {
153
- 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", []));
154
209
  }
155
210
  if (!Object.hasOwn(capabilities, "tools")) {
156
- return partial(catalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
211
+ return partial(buildMcpToolCatalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
157
212
  }
158
- const findings = [];
159
- const names = new Map();
213
+ const inspector = createMcpToolCatalogInspector();
214
+ const findings = inspector.findings;
160
215
  const seenCursors = new Set();
161
216
  let pagesRead = 0;
162
- let toolsChecked = 0;
163
217
  let cursor;
164
218
  let incompleteReason = null;
165
219
  let blocked = false;
166
- const tools = [];
167
220
  let cache = null;
168
221
  const addFindings = (items) => {
169
- if (findings.length + items.length > MAX_FINDINGS) {
170
- findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
171
- incompleteReason = "finding-limit";
172
- return false;
173
- }
174
- findings.push(...items);
175
- return true;
222
+ if (inspector.addFindings(items))
223
+ return true;
224
+ incompleteReason = "finding-limit";
225
+ return false;
176
226
  };
177
227
  while (incompleteReason === null && !blocked) {
178
- if (pagesRead >= MAX_PAGES) {
228
+ if (pagesRead >= MAX_CATALOG_PAGES) {
179
229
  incompleteReason = "page-limit";
180
230
  break;
181
231
  }
@@ -189,8 +239,9 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
189
239
  response = await requestWithinBudget(rawUrl, {
190
240
  allowLocalNetwork: options.allowLocalNetwork,
191
241
  lookup: options.lookup,
242
+ bearerToken: options.bearerToken,
192
243
  method: "POST",
193
- body: requestBody(requestId, cursor),
244
+ body: serializeToolsListRequest(requestId, cursor),
194
245
  headers: {
195
246
  Accept: "application/json, text/event-stream",
196
247
  "Content-Type": "application/json",
@@ -205,11 +256,13 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
205
256
  blocked = true;
206
257
  }
207
258
  else {
208
- incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
209
- ? "request-timeout"
210
- : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
211
- ? "response-byte-limit"
212
- : "request-failed");
259
+ incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_INSECURE_CREDENTIALS"
260
+ ? "credentials-insecure-transport"
261
+ : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
262
+ ? "request-timeout"
263
+ : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
264
+ ? "response-byte-limit"
265
+ : "request-failed");
213
266
  }
214
267
  break;
215
268
  }
@@ -219,19 +272,19 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
219
272
  }
220
273
  const message = parseMcpResponse(response, requestId);
221
274
  if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
222
- && validError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
223
- 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.")]))
224
277
  incompleteReason = "tools-list-method-unsupported";
225
278
  break;
226
279
  }
227
280
  if (response.statusCode !== 200) {
228
- 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.")]))
229
282
  incompleteReason = "http-status-invalid";
230
283
  break;
231
284
  }
232
285
  const page = message === null ? null : parseToolsResult(message, requestId);
233
286
  if (page === null) {
234
- 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.")]))
235
288
  incompleteReason = "response-invalid";
236
289
  break;
237
290
  }
@@ -240,57 +293,32 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
240
293
  ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
241
294
  cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
242
295
  };
243
- for (const tool of page.tools) {
244
- if (toolsChecked >= MAX_TOOLS) {
245
- incompleteReason = "tool-limit";
246
- break;
247
- }
248
- toolsChecked += 1;
249
- tools.push(tool);
250
- const location = { page: pagesRead, toolIndex: toolsChecked };
251
- if (!addFindings(inspectMcpToolDefinition(tool, location))) {
252
- break;
253
- }
254
- if (isPlainObject(tool) && typeof tool.name === "string") {
255
- const previous = names.get(tool.name);
256
- if (previous !== undefined && !addFindings([{
257
- ...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."),
258
- location: { ...location, relatedToolIndex: previous }
259
- }])) {
260
- break;
261
- }
262
- if (previous === undefined)
263
- names.set(tool.name, toolsChecked);
264
- }
265
- if (deadline - now() <= 0) {
266
- incompleteReason = "total-time-limit";
267
- break;
268
- }
269
- }
296
+ incompleteReason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
270
297
  if (incompleteReason !== null)
271
298
  break;
272
299
  if (page.cursor === undefined)
273
300
  break;
274
301
  if (seenCursors.has(page.cursor)) {
275
- 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.")]))
276
303
  incompleteReason = "cursor-loop";
277
304
  break;
278
305
  }
279
306
  seenCursors.add(page.cursor);
280
307
  cursor = page.cursor;
281
308
  }
309
+ const toolsChecked = inspector.toolsChecked;
282
310
  if (blocked) {
283
- 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));
284
312
  }
285
313
  if (incompleteReason !== null) {
286
- 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));
287
315
  }
288
316
  if (deadline - now() <= 0) {
289
- 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));
290
318
  }
291
319
  return {
292
- report: catalogReport(discovery, finalStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
293
- tools,
320
+ report: buildMcpToolCatalogReport(discovery, finalCatalogStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
321
+ tools: inspector.tools,
294
322
  cache
295
323
  };
296
324
  }
@@ -34,6 +34,9 @@ const NESTED_STRUCTURAL_KEYWORDS = new Set([
34
34
  ]);
35
35
  // Subschemas nested deeper than this below the root are not compared and stay unclassified.
36
36
  const MAX_NESTED_DEPTH = 8;
37
+ // Keywords that only hold or name subschemas and never validate the object's own keys, so a
38
+ // new property beside them stays compatible. Any other unclassified keyword may govern keys.
39
+ const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
37
40
  const CODES = {
38
41
  input: {
39
42
  unclassified: "input-schema-unclassified",
@@ -197,13 +200,14 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
197
200
  else
198
201
  compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
199
202
  }
200
- // In a nested object whose undeclared keys were open or governed by other keywords, a newly
201
- // declared property can reject values callers send (input) or produce values consumers did
202
- // not expect (output).
203
+ // In an object whose undeclared keys were governed by other keywords, or a nested input object
204
+ // that left them open, a newly declared property can reject values callers send (input) or
205
+ // produce values consumers did not expect (output).
203
206
  const addedKeys = Object.keys(afterProperties).filter((key) => !Object.hasOwn(beforeProperties, key));
204
- if (!root && addedKeys.length > 0) {
205
- const governed = isJsonObject(before.additionalProperties) || Object.keys(keywordsOutside(before, structural)).length > 0;
206
- const open = Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
207
+ if (addedKeys.length > 0) {
208
+ const governed = isJsonObject(before.additionalProperties)
209
+ || Object.keys(keywordsOutside(before, structural)).some((key) => !DEFINITION_KEYWORDS.has(key));
210
+ const open = !root && Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
207
211
  if (governed || (direction === "input" && open))
208
212
  reasons.add(codes.unclassified);
209
213
  }
@@ -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}`,
@@ -0,0 +1,2 @@
1
+ import type { McpToolDiffReport } from "../core/mcp-tool-diff.js";
2
+ export declare function renderMcpToolDiffMarkdown(report: McpToolDiffReport): string;
@@ -0,0 +1,33 @@
1
+ function count(value) {
2
+ return value === null || value === undefined ? "not compared" : String(value);
3
+ }
4
+ function toolIndex(value) {
5
+ return value === null ? "-" : String(value);
6
+ }
7
+ export function renderMcpToolDiffMarkdown(report) {
8
+ const comparison = report.comparison;
9
+ const lines = [
10
+ "## MCP Tool Definition Diff",
11
+ "",
12
+ `**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
13
+ "",
14
+ "| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
15
+ "| --- | --- | --- | --- | --- | --- |",
16
+ `| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
17
+ ];
18
+ if (report.changes.length > 0) {
19
+ lines.push("", "### Changes", "", "| Change | Before tool | After tool | Fields | Impact | Reasons |", "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
20
+ }
21
+ const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
22
+ const location = finding.location;
23
+ const locator = location
24
+ ? `${side} tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}`
25
+ : side;
26
+ return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
27
+ }));
28
+ if (findings.length > 0) {
29
+ lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
30
+ }
31
+ lines.push("", `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
32
+ return lines.join("\n");
33
+ }
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;