codex-plugin-doctor 1.65.0 → 1.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,8 +121,9 @@ This reports added, removed, and structurally changed definitions using numeric
121
121
  locations, and labels each change `breaking`, `compatible`, or `unclassified`
122
122
  with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
123
123
  unclassified changes. Incomplete inputs, invalid definitions, or duplicate names
124
- prevent a comparison. Impact labels are root-level schema heuristics, not proof
125
- of compatibility.
124
+ prevent a comparison. Impact labels follow nested object properties, array
125
+ items, and common length and range bounds, but remain heuristics, not proof of
126
+ compatibility.
126
127
  See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
127
128
  privacy, limits, and exit codes.
128
129
 
@@ -138,6 +139,10 @@ This opt-in command performs discovery and bounded `tools/list` pagination. It
138
139
  reports tool-definition and root-schema shape findings, explicit completion state,
139
140
  and partial inspection limits. It does not claim full schema validation, tool
140
141
  execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
142
+ Add `--save-response <path>` to record a completely enumerated catalog as a
143
+ `tools/list` response for `tools-file` and `tools-diff`; nothing is written when
144
+ enumeration is incomplete. The GitHub Action can compare saved responses with
145
+ `tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on`.
141
146
  See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
142
147
 
143
148
  ### Remote MCP Readiness
@@ -558,9 +563,9 @@ jobs:
558
563
  runs-on: ubuntu-latest
559
564
  steps:
560
565
  - uses: actions/checkout@v5
561
- - uses: Esquetta/CodexPluginDoctor@v1.65.0
566
+ - uses: Esquetta/CodexPluginDoctor@v1.67.0
562
567
  with:
563
- version: "1.65.0"
568
+ version: "1.67.0"
564
569
  path: .
565
570
  runtime: "true"
566
571
  policy: codex-publish
@@ -32,5 +32,15 @@ export interface McpToolCatalogReport {
32
32
  };
33
33
  findings: CatalogFinding[];
34
34
  }
35
+ export interface McpToolCatalogCache {
36
+ ttlMs: number;
37
+ cacheScope: "public" | "private";
38
+ }
39
+ export interface McpToolCatalogSnapshot {
40
+ report: McpToolCatalogReport;
41
+ tools: unknown[] | null;
42
+ cache: McpToolCatalogCache | null;
43
+ }
35
44
  export declare function inspectMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogReport>;
45
+ export declare function captureMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
36
46
  export {};
@@ -39,7 +39,11 @@ function parseToolsResult(message, requestId) {
39
39
  if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string") {
40
40
  return null;
41
41
  }
42
- return { tools: message.result.tools, cursor: message.result.nextCursor };
42
+ return {
43
+ tools: message.result.tools,
44
+ cursor: message.result.nextCursor,
45
+ cache: { ttlMs: message.result.ttlMs, cacheScope: message.result.cacheScope }
46
+ };
43
47
  }
44
48
  function catalogReport(discovery, status, catalog, catalogEnumeration, findings) {
45
49
  return {
@@ -85,6 +89,10 @@ function requestBody(id, cursor) {
85
89
  });
86
90
  }
87
91
  export async function inspectMcpToolCatalog(rawUrl, options = {}) {
92
+ return (await captureMcpToolCatalog(rawUrl, options)).report;
93
+ }
94
+ // Tools and cache metadata are returned only for a complete enumeration.
95
+ export async function captureMcpToolCatalog(rawUrl, options = {}) {
88
96
  const request = options.request ?? requestBoundedHttp;
89
97
  const now = options.now ?? (() => performance.now());
90
98
  const deadline = now() + TOTAL_TIMEOUT_MS;
@@ -126,25 +134,26 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
126
134
  return response;
127
135
  }
128
136
  });
137
+ const partial = (report) => ({ report, tools: null, cache: null });
129
138
  const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
130
139
  if (discovery.status === "blocked") {
131
- return catalogReport(discovery, "blocked", { ...emptyCatalog, reason: "network-blocked" }, "not-tested", []);
140
+ return partial(catalogReport(discovery, "blocked", { ...emptyCatalog, reason: "network-blocked" }, "not-tested", []));
132
141
  }
133
142
  if (isAuthorizationFinding(discovery)) {
134
- return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []);
143
+ return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
135
144
  }
136
145
  if (discovery.status === "unsupported") {
137
- return catalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []);
146
+ return partial(catalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
138
147
  }
139
148
  if (discovery.status !== "discovered") {
140
- return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []);
149
+ return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
141
150
  }
142
151
  const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
143
152
  if (capabilities === null) {
144
- return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []);
153
+ return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
145
154
  }
146
155
  if (!Object.hasOwn(capabilities, "tools")) {
147
- return catalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []);
156
+ return partial(catalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
148
157
  }
149
158
  const findings = [];
150
159
  const names = new Map();
@@ -154,6 +163,8 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
154
163
  let cursor;
155
164
  let incompleteReason = null;
156
165
  let blocked = false;
166
+ const tools = [];
167
+ let cache = null;
157
168
  const addFindings = (items) => {
158
169
  if (findings.length + items.length > MAX_FINDINGS) {
159
170
  findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
@@ -225,12 +236,17 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
225
236
  break;
226
237
  }
227
238
  pagesRead += 1;
239
+ cache = cache === null ? page.cache : {
240
+ ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
241
+ cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
242
+ };
228
243
  for (const tool of page.tools) {
229
244
  if (toolsChecked >= MAX_TOOLS) {
230
245
  incompleteReason = "tool-limit";
231
246
  break;
232
247
  }
233
248
  toolsChecked += 1;
249
+ tools.push(tool);
234
250
  const location = { page: pagesRead, toolIndex: toolsChecked };
235
251
  if (!addFindings(inspectMcpToolDefinition(tool, location))) {
236
252
  break;
@@ -264,13 +280,17 @@ export async function inspectMcpToolCatalog(rawUrl, options = {}) {
264
280
  cursor = page.cursor;
265
281
  }
266
282
  if (blocked) {
267
- return catalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings);
283
+ return partial(catalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
268
284
  }
269
285
  if (incompleteReason !== null) {
270
- return catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings);
286
+ return partial(catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
271
287
  }
272
288
  if (deadline - now() <= 0) {
273
- return catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings);
289
+ return partial(catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
274
290
  }
275
- return catalogReport(discovery, finalStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings);
291
+ return {
292
+ report: catalogReport(discovery, finalStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
293
+ tools,
294
+ cache
295
+ };
276
296
  }
@@ -1,4 +1,5 @@
1
1
  import type { CatalogFinding } from "./mcp-tool-definition.js";
2
+ export declare const MAX_TOOL_FILE_BYTES: number;
2
3
  export interface McpToolFileReport {
3
4
  schemaVersion: 1;
4
5
  scope: "tool-definitions-file";
@@ -28,5 +29,6 @@ export interface McpToolFileSnapshot {
28
29
  report: McpToolFileReport;
29
30
  tools: unknown[] | null;
30
31
  }
32
+ export declare function isBlockedOfflinePath(filePath: string): boolean;
31
33
  export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
32
34
  export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
@@ -1,7 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, open } from "node:fs/promises";
3
3
  import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
4
- const MAX_FILE_BYTES = 1024 * 1024;
4
+ export const MAX_TOOL_FILE_BYTES = 1024 * 1024;
5
5
  const MAX_TOOLS = 500;
6
6
  const MAX_FINDINGS = 100;
7
7
  function snapshot(report, tools = null) {
@@ -22,7 +22,7 @@ function sameSnapshot(left, right) {
22
22
  && left.mtimeMs === right.mtimeMs
23
23
  && left.ctimeMs === right.ctimeMs;
24
24
  }
25
- function isBlockedOfflinePath(filePath) {
25
+ export function isBlockedOfflinePath(filePath) {
26
26
  return /^[A-Za-z][A-Za-z0-9+.-]+:/u.test(filePath)
27
27
  || /^[A-Za-z]:\/\//u.test(filePath)
28
28
  || /^[\\/]{2}/u.test(filePath);
@@ -102,7 +102,7 @@ async function readBoundedRegularFile(filePath) {
102
102
  return { kind: "symlink", bytesRead: 0 };
103
103
  if (!initial.isFile())
104
104
  return { kind: "not-regular", bytesRead: 0 };
105
- if (!Number.isSafeInteger(initial.size) || initial.size < 0 || initial.size > MAX_FILE_BYTES) {
105
+ if (!Number.isSafeInteger(initial.size) || initial.size < 0 || initial.size > MAX_TOOL_FILE_BYTES) {
106
106
  return { kind: "too-large", bytesRead: 0 };
107
107
  }
108
108
  const flags = process.platform === "win32"
@@ -117,7 +117,7 @@ async function readBoundedRegularFile(filePath) {
117
117
  if (!opened.isFile() || current.isSymbolicLink() || !current.isFile() || !sameSnapshot(initial, opened) || !sameSnapshot(opened, current)) {
118
118
  return { kind: "changed", bytesRead };
119
119
  }
120
- if (!Number.isSafeInteger(opened.size) || opened.size < 0 || opened.size > MAX_FILE_BYTES) {
120
+ if (!Number.isSafeInteger(opened.size) || opened.size < 0 || opened.size > MAX_TOOL_FILE_BYTES) {
121
121
  return { kind: "too-large", bytesRead };
122
122
  }
123
123
  const content = Buffer.alloc(opened.size);
@@ -1,6 +1,6 @@
1
1
  type JsonObject = Record<string, unknown>;
2
2
  export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
3
- export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
3
+ export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-constraint-tightened", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-constraint-loosened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
4
4
  export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
5
5
  export interface ToolChangeClassification {
6
6
  impact: ToolChangeImpact;
@@ -5,6 +5,7 @@ export const TOOL_CHANGE_REASONS = [
5
5
  "input-property-removed",
6
6
  "input-property-type-narrowed",
7
7
  "input-property-enum-narrowed",
8
+ "input-constraint-tightened",
8
9
  "input-additional-properties-closed",
9
10
  "input-schema-unclassified",
10
11
  "output-schema-removed",
@@ -13,6 +14,7 @@ export const TOOL_CHANGE_REASONS = [
13
14
  "output-guarantee-removed",
14
15
  "output-property-type-widened",
15
16
  "output-property-enum-widened",
17
+ "output-constraint-loosened",
16
18
  "output-schema-unclassified",
17
19
  "annotation-safety-reduced",
18
20
  "other-fields-unclassified"
@@ -25,7 +27,33 @@ const UNCLASSIFIED_REASONS = new Set([
25
27
  // Keywords that document a schema without constraining accepted or produced values.
26
28
  const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
27
29
  const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
28
- const PROPERTY_STRUCTURAL_KEYWORDS = new Set(["type", "enum"]);
30
+ const LOWER_BOUND_KEYWORDS = ["minimum", "minLength", "minItems"];
31
+ const UPPER_BOUND_KEYWORDS = ["maximum", "maxLength", "maxItems"];
32
+ const NESTED_STRUCTURAL_KEYWORDS = new Set([
33
+ "type", "enum", "properties", "required", "additionalProperties", "items", ...LOWER_BOUND_KEYWORDS, ...UPPER_BOUND_KEYWORDS
34
+ ]);
35
+ // Subschemas nested deeper than this below the root are not compared and stay unclassified.
36
+ const MAX_NESTED_DEPTH = 8;
37
+ const CODES = {
38
+ input: {
39
+ unclassified: "input-schema-unclassified",
40
+ rootType: "input-type-narrowed",
41
+ propertyType: "input-property-type-narrowed",
42
+ enumChanged: "input-property-enum-narrowed",
43
+ constraint: "input-constraint-tightened",
44
+ required: "input-required-added",
45
+ propertyRemoved: "input-property-removed"
46
+ },
47
+ output: {
48
+ unclassified: "output-schema-unclassified",
49
+ rootType: "output-type-widened",
50
+ propertyType: "output-property-type-widened",
51
+ enumChanged: "output-property-enum-widened",
52
+ constraint: "output-constraint-loosened",
53
+ required: "output-guarantee-removed",
54
+ propertyRemoved: "output-property-removed"
55
+ }
56
+ };
29
57
  // MCP tool annotation defaults, oriented so `true` is the safer reading.
30
58
  const SAFETY_HINTS = [
31
59
  { key: "readOnlyHint", absent: false, safe: true },
@@ -91,62 +119,104 @@ function keywordsOutside(schema, allowed) {
91
119
  }
92
120
  return rest;
93
121
  }
122
+ function isFiniteNumber(value) {
123
+ return typeof value === "number" && Number.isFinite(value);
124
+ }
125
+ /** Flags bounds of `outer` that `inner` does not satisfy, so `inner` values may fall outside `outer`. */
126
+ function compareBounds(inner, outer, codes, reasons) {
127
+ for (const [keywords, covered] of [
128
+ [LOWER_BOUND_KEYWORDS, (innerBound, outerBound) => innerBound >= outerBound],
129
+ [UPPER_BOUND_KEYWORDS, (innerBound, outerBound) => innerBound <= outerBound]
130
+ ]) {
131
+ for (const keyword of keywords) {
132
+ const outerBound = outer[keyword];
133
+ const innerBound = inner[keyword];
134
+ if ((outerBound !== undefined && !isFiniteNumber(outerBound)) || (innerBound !== undefined && !isFiniteNumber(innerBound))) {
135
+ reasons.add(codes.unclassified);
136
+ }
137
+ else if (outerBound !== undefined && (innerBound === undefined || !covered(innerBound, outerBound))) {
138
+ reasons.add(codes.constraint);
139
+ }
140
+ }
141
+ }
142
+ }
94
143
  /**
95
- * Compares one property subschema. `direction` is "input" when callers must keep being
96
- * accepted (after must cover before) and "output" when consumers must keep understanding
97
- * results (before must cover after).
144
+ * Compares one schema and its object and array subschemas. `direction` is "input" when callers
145
+ * must keep being accepted (after must cover before) and "output" when consumers must keep
146
+ * understanding results (before must cover after). Depth 0 is the tool's root schema.
98
147
  */
99
- function compareProperty(before, after, direction, equal, reasons) {
148
+ function compareSchema(before, after, direction, depth, equal, reasons) {
100
149
  if (equal(before, after))
101
150
  return;
102
- const unclassified = direction === "input" ? "input-schema-unclassified" : "output-schema-unclassified";
103
- if (!isJsonObject(before) || !isJsonObject(after)
104
- || !equal(keywordsOutside(before, PROPERTY_STRUCTURAL_KEYWORDS), keywordsOutside(after, PROPERTY_STRUCTURAL_KEYWORDS))) {
105
- reasons.add(unclassified);
151
+ const codes = CODES[direction];
152
+ const root = depth === 0;
153
+ const structural = root ? ROOT_STRUCTURAL_KEYWORDS : NESTED_STRUCTURAL_KEYWORDS;
154
+ if (depth > MAX_NESTED_DEPTH || !isJsonObject(before) || !isJsonObject(after)
155
+ || !equal(keywordsOutside(before, structural), keywordsOutside(after, structural))
156
+ || !equal(before.$schema, after.$schema)) {
157
+ reasons.add(codes.unclassified);
106
158
  return;
107
159
  }
108
160
  const [inner, outer] = direction === "input" ? [before, after] : [after, before];
109
- if (!typesCovered(typeSet(inner), typeSet(outer))) {
110
- reasons.add(direction === "input" ? "input-property-type-narrowed" : "output-property-type-widened");
161
+ // unevaluatedItems/unevaluatedProperties apply to whatever items/additionalProperties leave
162
+ // uncovered, so a change in either cannot be judged on its own.
163
+ const unevaluated = ["unevaluatedItems", "unevaluatedProperties"].some((key) => before[key] !== undefined || after[key] !== undefined);
164
+ if (!typesCovered(typeSet(inner), typeSet(outer)))
165
+ reasons.add(root ? codes.rootType : codes.propertyType);
166
+ if (!root) {
167
+ const enumResult = enumCovered(inner.enum, outer.enum, equal);
168
+ if (enumResult === null)
169
+ reasons.add(codes.unclassified);
170
+ else if (!enumResult)
171
+ reasons.add(codes.enumChanged);
172
+ compareBounds(inner, outer, codes, reasons);
173
+ if (!equal(before.items, after.items)) {
174
+ if (unevaluated)
175
+ reasons.add(codes.unclassified);
176
+ else if (outer.items !== undefined && inner.items === undefined)
177
+ reasons.add(codes.constraint);
178
+ else if (outer.items !== undefined)
179
+ compareSchema(before.items, after.items, direction, depth + 1, equal, reasons);
180
+ }
111
181
  }
112
- const enumResult = enumCovered(inner.enum, outer.enum, equal);
113
- if (enumResult === null)
114
- reasons.add(unclassified);
115
- else if (!enumResult)
116
- reasons.add(direction === "input" ? "input-property-enum-narrowed" : "output-property-enum-widened");
117
- }
118
- function classifyInputSchema(before, after, equal, reasons) {
119
- if (!isJsonObject(before) || !isJsonObject(after)
120
- || !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
121
- || !equal(before.$schema, after.$schema)) {
122
- reasons.add("input-schema-unclassified");
123
- return;
124
- }
125
- if (!typesCovered(typeSet(before), typeSet(after)))
126
- reasons.add("input-type-narrowed");
127
- const beforeRequired = stringSet(before.required);
128
- const afterRequired = stringSet(after.required);
182
+ const innerRequired = stringSet(inner.required);
183
+ const outerRequired = stringSet(outer.required);
129
184
  const beforeProperties = objectMap(before.properties);
130
185
  const afterProperties = objectMap(after.properties);
131
- if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
132
- reasons.add("input-schema-unclassified");
186
+ if (innerRequired === null || outerRequired === null || beforeProperties === null || afterProperties === null) {
187
+ reasons.add(codes.unclassified);
133
188
  return;
134
189
  }
135
- for (const key of afterRequired) {
136
- if (!beforeRequired.has(key))
137
- reasons.add("input-required-added");
190
+ for (const key of outerRequired) {
191
+ if (!innerRequired.has(key))
192
+ reasons.add(codes.required);
138
193
  }
139
194
  for (const key of Object.keys(beforeProperties)) {
140
195
  if (!Object.hasOwn(afterProperties, key))
141
- reasons.add("input-property-removed");
196
+ reasons.add(codes.propertyRemoved);
142
197
  else
143
- compareProperty(beforeProperties[key], afterProperties[key], "input", equal, reasons);
198
+ compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
144
199
  }
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
+ 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 (governed || (direction === "input" && open))
208
+ reasons.add(codes.unclassified);
209
+ }
210
+ // Callers already send a key that was required before it was declared, with any value.
211
+ if (direction === "input" && addedKeys.some((key) => innerRequired.has(key)))
212
+ reasons.add(codes.unclassified);
145
213
  if (!equal(before.additionalProperties, after.additionalProperties)) {
146
- if (after.additionalProperties === false)
214
+ if (unevaluated)
215
+ reasons.add(codes.unclassified);
216
+ else if (direction === "input" && after.additionalProperties === false)
147
217
  reasons.add("input-additional-properties-closed");
148
- else if (!(after.additionalProperties === undefined || after.additionalProperties === true))
149
- reasons.add("input-schema-unclassified");
218
+ else if (direction === "output" || !(after.additionalProperties === undefined || after.additionalProperties === true))
219
+ reasons.add(codes.unclassified);
150
220
  }
151
221
  }
152
222
  function classifyOutputSchema(before, after, equal, reasons) {
@@ -156,34 +226,7 @@ function classifyOutputSchema(before, after, equal, reasons) {
156
226
  reasons.add("output-schema-removed");
157
227
  return;
158
228
  }
159
- if (!isJsonObject(before) || !isJsonObject(after)
160
- || !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
161
- || !equal(before.$schema, after.$schema)) {
162
- reasons.add("output-schema-unclassified");
163
- return;
164
- }
165
- if (!typesCovered(typeSet(after), typeSet(before)))
166
- reasons.add("output-type-widened");
167
- const beforeRequired = stringSet(before.required);
168
- const afterRequired = stringSet(after.required);
169
- const beforeProperties = objectMap(before.properties);
170
- const afterProperties = objectMap(after.properties);
171
- if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
172
- reasons.add("output-schema-unclassified");
173
- return;
174
- }
175
- for (const key of beforeRequired) {
176
- if (!afterRequired.has(key))
177
- reasons.add("output-guarantee-removed");
178
- }
179
- for (const key of Object.keys(beforeProperties)) {
180
- if (!Object.hasOwn(afterProperties, key))
181
- reasons.add("output-property-removed");
182
- else
183
- compareProperty(beforeProperties[key], afterProperties[key], "output", equal, reasons);
184
- }
185
- if (!equal(before.additionalProperties, after.additionalProperties))
186
- reasons.add("output-schema-unclassified");
229
+ compareSchema(before, after, "output", 0, equal, reasons);
187
230
  }
188
231
  function hint(annotations, key, absent) {
189
232
  if (annotations === undefined)
@@ -219,7 +262,7 @@ export function classifyAddedTool() {
219
262
  export function classifyChangedTool(before, after, fields, equal) {
220
263
  const reasons = new Set();
221
264
  if (fields.includes("inputSchema"))
222
- classifyInputSchema(before.inputSchema, after.inputSchema, equal, reasons);
265
+ compareSchema(before.inputSchema, after.inputSchema, "input", 0, equal, reasons);
223
266
  if (fields.includes("outputSchema"))
224
267
  classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
225
268
  if (fields.includes("annotations"))
@@ -0,0 +1,10 @@
1
+ import type { McpToolCatalogCache } from "./mcp-tool-catalog.js";
2
+ export type McpToolResponseSaveResult = {
3
+ kind: "saved";
4
+ bytes: number;
5
+ tools: number;
6
+ } | {
7
+ kind: "path-blocked" | "not-regular" | "too-large" | "write-failed";
8
+ };
9
+ export declare function serializeMcpToolsListResponse(tools: unknown[], cache: McpToolCatalogCache): string;
10
+ export declare function saveMcpToolsListResponse(filePath: string, tools: unknown[], cache: McpToolCatalogCache): Promise<McpToolResponseSaveResult>;
@@ -0,0 +1,52 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, lstat, rename, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { isBlockedOfflinePath, MAX_TOOL_FILE_BYTES } from "./mcp-tool-file.js";
5
+ // Serializes an enumerated catalog as one complete tools/list response that doctor tools-file and tools-diff accept.
6
+ export function serializeMcpToolsListResponse(tools, cache) {
7
+ return `${JSON.stringify({
8
+ jsonrpc: "2.0",
9
+ id: "tools-list",
10
+ result: { resultType: "complete", tools, ttlMs: cache.ttlMs, cacheScope: cache.cacheScope }
11
+ }, null, 2)}\n`;
12
+ }
13
+ export async function saveMcpToolsListResponse(filePath, tools, cache) {
14
+ if (isBlockedOfflinePath(filePath))
15
+ return { kind: "path-blocked" };
16
+ let content;
17
+ let bytes;
18
+ try {
19
+ content = serializeMcpToolsListResponse(tools, cache);
20
+ bytes = Buffer.byteLength(content);
21
+ }
22
+ catch {
23
+ return { kind: "write-failed" };
24
+ }
25
+ if (bytes > MAX_TOOL_FILE_BYTES)
26
+ return { kind: "too-large" };
27
+ let existingMode = null;
28
+ try {
29
+ const existing = await lstat(filePath);
30
+ if (existing.isSymbolicLink() || !existing.isFile())
31
+ return { kind: "not-regular" };
32
+ existingMode = existing.mode & 0o777;
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "ENOENT")
36
+ return { kind: "write-failed" };
37
+ }
38
+ // Write beside the target and rename so a failed write never leaves a partial response file.
39
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
40
+ try {
41
+ // Create with the replaced baseline's permission bits, which may protect private tool definitions.
42
+ await writeFile(tempPath, content, { encoding: "utf8", flag: "wx", mode: existingMode ?? 0o666 });
43
+ if (existingMode !== null)
44
+ await chmod(tempPath, existingMode);
45
+ await rename(tempPath, filePath);
46
+ }
47
+ catch {
48
+ await rm(tempPath, { force: true }).catch(() => undefined);
49
+ return { kind: "write-failed" };
50
+ }
51
+ return { kind: "saved", bytes, tools: tools.length };
52
+ }
@@ -19,7 +19,7 @@ export function renderMcpToolDiffReport(report) {
19
19
  `Server catalog: ${report.coverage.serverCatalog}`,
20
20
  `Tool execution: ${report.coverage.toolExecution}`,
21
21
  `Impact classification: ${report.coverage.impactClassification}`,
22
- "Only the supplied responses are compared. Impact labels are root-level schema heuristics: a compatible label or an unchanged result does not establish compatibility.",
22
+ "Only the supplied responses are compared. Impact labels are schema heuristics: a compatible label or an unchanged result does not establish compatibility.",
23
23
  ...["before", "after"].flatMap((side) => {
24
24
  const source = report[side];
25
25
  const label = side === "before" ? "Before" : "After";
package/dist/run-cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { discoverMcpServer } from "./core/mcp-discovery.js";
2
- import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
2
+ import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
3
3
  import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
4
4
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
5
5
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
@@ -20,6 +20,7 @@ export interface RunCliOptions {
20
20
  compareMcpToolFilesImpl?: typeof compareMcpToolFiles;
21
21
  inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
22
22
  inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
23
+ captureMcpToolCatalogImpl?: typeof captureMcpToolCatalog;
23
24
  discoverMcpServerImpl?: typeof discoverMcpServer;
24
25
  terminalContext?: CliTerminalContext;
25
26
  runCheckImpl?: typeof runCheck;
package/dist/run-cli.js CHANGED
@@ -22,8 +22,9 @@ import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutp
22
22
  import { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
23
23
  import { buildSubmissionPreflight } from "./core/submission-preflight.js";
24
24
  import { discoverMcpServer } from "./core/mcp-discovery.js";
25
- import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
26
- import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
25
+ import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
26
+ import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
27
+ import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
27
28
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
28
29
  import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
29
30
  import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
@@ -109,7 +110,7 @@ class CliUsageError extends Error {
109
110
  }
110
111
  const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json] [--fail-on any|breaking]";
111
112
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
112
- const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
113
+ const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>]";
113
114
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
114
115
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
115
116
  const index = flags.indexOf("--sandbox");
@@ -1280,7 +1281,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1280
1281
  }
1281
1282
  if (maybePath === "tools") {
1282
1283
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1283
- io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.`);
1284
+ 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.`);
1284
1285
  return 0;
1285
1286
  }
1286
1287
  const [url, ...flags] = remainingArgs;
@@ -1289,23 +1290,68 @@ export async function runCli(args, io = defaultIo, options = {}) {
1289
1290
  return 2;
1290
1291
  }
1291
1292
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1292
- if (flags.some((flag) => !allowedFlags.has(flag)) || new Set(flags).size !== flags.length) {
1293
+ const switches = [];
1294
+ let savePath;
1295
+ let invalid = false;
1296
+ for (let index = 0; index < flags.length; index += 1) {
1297
+ const flag = flags[index];
1298
+ if (flag === "--save-response" && savePath === undefined) {
1299
+ const value = flags[index + 1];
1300
+ if (!value || value.startsWith("-")) {
1301
+ invalid = true;
1302
+ break;
1303
+ }
1304
+ savePath = value;
1305
+ index += 1;
1306
+ }
1307
+ else if (allowedFlags.has(flag) && !switches.includes(flag)) {
1308
+ switches.push(flag);
1309
+ }
1310
+ else {
1311
+ invalid = true;
1312
+ break;
1313
+ }
1314
+ }
1315
+ if (invalid) {
1293
1316
  io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
1294
1317
  return 2;
1295
1318
  }
1296
- if (!flags.includes("--allow-network")) {
1319
+ if (!switches.includes("--allow-network")) {
1297
1320
  io.writeStderr("doctor tools requires explicit --allow-network consent.");
1298
1321
  return 2;
1299
1322
  }
1300
- const report = await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, {
1301
- allowNetwork: true,
1302
- allowLocalNetwork: flags.includes("--allow-local-network")
1303
- });
1304
- io.writeStdout(flags.includes("--json")
1323
+ if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
1324
+ io.writeStderr("doctor tools --save-response requires a local file path.");
1325
+ return 2;
1326
+ }
1327
+ const catalogOptions = { allowNetwork: true, allowLocalNetwork: switches.includes("--allow-local-network") };
1328
+ const snapshot = savePath === undefined
1329
+ ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
1330
+ : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
1331
+ const report = snapshot.report;
1332
+ io.writeStdout(switches.includes("--json")
1305
1333
  ? JSON.stringify(report, null, 2)
1306
1334
  : renderMcpToolCatalogReport(report));
1307
- return report.status === "pass" ? 0
1335
+ const exitCode = report.status === "pass" ? 0
1308
1336
  : report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
1337
+ if (savePath === undefined)
1338
+ return exitCode;
1339
+ if (snapshot.tools === null || snapshot.cache === null) {
1340
+ io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
1341
+ return 2;
1342
+ }
1343
+ const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
1344
+ if (saved.kind !== "saved") {
1345
+ io.writeStderr({
1346
+ "path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
1347
+ "not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
1348
+ "too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
1349
+ "write-failed": "Tool catalog response could not be written."
1350
+ }[saved.kind]);
1351
+ return 2;
1352
+ }
1353
+ io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
1354
+ return exitCode;
1309
1355
  }
1310
1356
  if (maybePath === "discover") {
1311
1357
  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.65.0",
3
+ "version": "1.67.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",