codex-plugin-doctor 1.64.0 → 1.65.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Furkan Tasci
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Furkan Tasci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -118,8 +118,11 @@ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-
118
118
  ```
119
119
 
120
120
  This reports added, removed, and structurally changed definitions using numeric
121
- locations. Incomplete inputs, invalid definitions, or duplicate names prevent a
122
- comparison. It does not determine whether a schema change breaks compatibility.
121
+ locations, and labels each change `breaking`, `compatible`, or `unclassified`
122
+ with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
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.
123
126
  See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
124
127
  privacy, limits, and exit codes.
125
128
 
@@ -555,9 +558,9 @@ jobs:
555
558
  runs-on: ubuntu-latest
556
559
  steps:
557
560
  - uses: actions/checkout@v5
558
- - uses: Esquetta/CodexPluginDoctor@v1.64.0
561
+ - uses: Esquetta/CodexPluginDoctor@v1.65.0
559
562
  with:
560
- version: "1.64.0"
563
+ version: "1.65.0"
561
564
  path: .
562
565
  runtime: "true"
563
566
  policy: codex-publish
@@ -1,3 +1,4 @@
1
+ import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
1
2
  import { type McpToolFileReport } from "./mcp-tool-file.js";
2
3
  type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
3
4
  export interface McpToolDiffReport {
@@ -13,6 +14,8 @@ export interface McpToolDiffReport {
13
14
  removed: number | null;
14
15
  changed: number | null;
15
16
  unchanged: number | null;
17
+ breaking: number | null;
18
+ unclassified: number | null;
16
19
  };
17
20
  coverage: {
18
21
  comparison: "structural-only";
@@ -20,12 +23,15 @@ export interface McpToolDiffReport {
20
23
  compatibility: "not-tested";
21
24
  serverCatalog: "not-tested";
22
25
  toolExecution: "not-tested";
26
+ impactClassification: "heuristic";
23
27
  };
24
28
  changes: Array<{
25
29
  kind: "added" | "removed" | "changed";
26
30
  beforeToolIndex: number | null;
27
31
  afterToolIndex: number | null;
28
32
  fields: DiffField[];
33
+ impact: ToolChangeImpact;
34
+ reasons: ToolChangeReason[];
29
35
  }>;
30
36
  }
31
37
  export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
@@ -1,3 +1,4 @@
1
+ import { classifyAddedTool, classifyChangedTool, classifyRemovedTool } from "./mcp-tool-impact.js";
1
2
  import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
2
3
  const FIXED_FIELDS = [
3
4
  "inputSchema",
@@ -72,13 +73,14 @@ function incompleteReport(before, after, status, reason) {
72
73
  status,
73
74
  before,
74
75
  after,
75
- comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null },
76
+ comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
76
77
  coverage: {
77
78
  comparison: "structural-only",
78
79
  schema: "root-shape-only",
79
80
  compatibility: "not-tested",
80
81
  serverCatalog: "not-tested",
81
- toolExecution: "not-tested"
82
+ toolExecution: "not-tested",
83
+ impactClassification: "heuristic"
82
84
  },
83
85
  changes: []
84
86
  };
@@ -113,7 +115,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
113
115
  const afterDefinition = afterDefinitions.get(name);
114
116
  if (afterDefinition === undefined) {
115
117
  removed += 1;
116
- changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [] });
118
+ changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
117
119
  continue;
118
120
  }
119
121
  const fields = changedFields(beforeDefinition.value, afterDefinition.value);
@@ -122,13 +124,19 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
122
124
  }
123
125
  else {
124
126
  changed += 1;
125
- changes.push({ kind: "changed", beforeToolIndex: beforeDefinition.index, afterToolIndex: afterDefinition.index, fields });
127
+ changes.push({
128
+ kind: "changed",
129
+ beforeToolIndex: beforeDefinition.index,
130
+ afterToolIndex: afterDefinition.index,
131
+ fields,
132
+ ...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
133
+ });
126
134
  }
127
135
  }
128
136
  for (const [name, afterDefinition] of afterDefinitions) {
129
137
  if (!beforeDefinitions.has(name)) {
130
138
  added += 1;
131
- changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [] });
139
+ changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
132
140
  }
133
141
  }
134
142
  return {
@@ -137,13 +145,23 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
137
145
  status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
138
146
  before,
139
147
  after,
140
- comparison: { complete: true, reason: null, added, removed, changed, unchanged },
148
+ comparison: {
149
+ complete: true,
150
+ reason: null,
151
+ added,
152
+ removed,
153
+ changed,
154
+ unchanged,
155
+ breaking: changes.filter((change) => change.impact === "breaking").length,
156
+ unclassified: changes.filter((change) => change.impact === "unclassified").length
157
+ },
141
158
  coverage: {
142
159
  comparison: "structural-only",
143
160
  schema: "root-shape-only",
144
161
  compatibility: "not-tested",
145
162
  serverCatalog: "not-tested",
146
- toolExecution: "not-tested"
163
+ toolExecution: "not-tested",
164
+ impactClassification: "heuristic"
147
165
  },
148
166
  changes
149
167
  };
@@ -0,0 +1,13 @@
1
+ type JsonObject = Record<string, unknown>;
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"];
4
+ export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
5
+ export interface ToolChangeClassification {
6
+ impact: ToolChangeImpact;
7
+ reasons: ToolChangeReason[];
8
+ }
9
+ type Equal = (left: unknown, right: unknown) => boolean;
10
+ export declare function classifyRemovedTool(): ToolChangeClassification;
11
+ export declare function classifyAddedTool(): ToolChangeClassification;
12
+ export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal): ToolChangeClassification;
13
+ export {};
@@ -0,0 +1,230 @@
1
+ export const TOOL_CHANGE_REASONS = [
2
+ "tool-removed",
3
+ "input-type-narrowed",
4
+ "input-required-added",
5
+ "input-property-removed",
6
+ "input-property-type-narrowed",
7
+ "input-property-enum-narrowed",
8
+ "input-additional-properties-closed",
9
+ "input-schema-unclassified",
10
+ "output-schema-removed",
11
+ "output-type-widened",
12
+ "output-property-removed",
13
+ "output-guarantee-removed",
14
+ "output-property-type-widened",
15
+ "output-property-enum-widened",
16
+ "output-schema-unclassified",
17
+ "annotation-safety-reduced",
18
+ "other-fields-unclassified"
19
+ ];
20
+ const UNCLASSIFIED_REASONS = new Set([
21
+ "input-schema-unclassified",
22
+ "output-schema-unclassified",
23
+ "other-fields-unclassified"
24
+ ]);
25
+ // Keywords that document a schema without constraining accepted or produced values.
26
+ const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
27
+ const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
28
+ const PROPERTY_STRUCTURAL_KEYWORDS = new Set(["type", "enum"]);
29
+ // MCP tool annotation defaults, oriented so `true` is the safer reading.
30
+ const SAFETY_HINTS = [
31
+ { key: "readOnlyHint", absent: false, safe: true },
32
+ { key: "destructiveHint", absent: true, safe: false },
33
+ { key: "idempotentHint", absent: false, safe: true },
34
+ { key: "openWorldHint", absent: true, safe: false }
35
+ ];
36
+ function isJsonObject(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+ /** Returns the set of declared JSON types, or null when the schema does not restrict type. */
40
+ function typeSet(schema) {
41
+ const declared = schema.type;
42
+ if (typeof declared === "string")
43
+ return new Set([declared]);
44
+ if (Array.isArray(declared) && declared.every((entry) => typeof entry === "string"))
45
+ return new Set(declared);
46
+ return null;
47
+ }
48
+ /** True when every value admitted by `inner` is also admitted by `outer`. */
49
+ function typesCovered(inner, outer) {
50
+ if (outer === null)
51
+ return true;
52
+ if (inner === null)
53
+ return false;
54
+ for (const type of inner) {
55
+ if (outer.has(type))
56
+ continue;
57
+ if (type === "integer" && outer.has("number"))
58
+ continue;
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ function enumCovered(inner, outer, equal) {
64
+ if (outer === undefined)
65
+ return true;
66
+ if (!Array.isArray(outer))
67
+ return null;
68
+ if (inner === undefined)
69
+ return false;
70
+ if (!Array.isArray(inner))
71
+ return null;
72
+ return inner.every((value) => outer.some((candidate) => equal(value, candidate)));
73
+ }
74
+ function stringSet(value) {
75
+ if (value === undefined)
76
+ return new Set();
77
+ if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))
78
+ return null;
79
+ return new Set(value);
80
+ }
81
+ function objectMap(value) {
82
+ if (value === undefined)
83
+ return {};
84
+ return isJsonObject(value) ? value : null;
85
+ }
86
+ function keywordsOutside(schema, allowed) {
87
+ const rest = Object.create(null);
88
+ for (const key of Object.keys(schema)) {
89
+ if (!allowed.has(key) && !DOCUMENTATION_KEYWORDS.has(key))
90
+ rest[key] = schema[key];
91
+ }
92
+ return rest;
93
+ }
94
+ /**
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).
98
+ */
99
+ function compareProperty(before, after, direction, equal, reasons) {
100
+ if (equal(before, after))
101
+ 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);
106
+ return;
107
+ }
108
+ 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");
111
+ }
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);
129
+ const beforeProperties = objectMap(before.properties);
130
+ const afterProperties = objectMap(after.properties);
131
+ if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
132
+ reasons.add("input-schema-unclassified");
133
+ return;
134
+ }
135
+ for (const key of afterRequired) {
136
+ if (!beforeRequired.has(key))
137
+ reasons.add("input-required-added");
138
+ }
139
+ for (const key of Object.keys(beforeProperties)) {
140
+ if (!Object.hasOwn(afterProperties, key))
141
+ reasons.add("input-property-removed");
142
+ else
143
+ compareProperty(beforeProperties[key], afterProperties[key], "input", equal, reasons);
144
+ }
145
+ if (!equal(before.additionalProperties, after.additionalProperties)) {
146
+ if (after.additionalProperties === false)
147
+ reasons.add("input-additional-properties-closed");
148
+ else if (!(after.additionalProperties === undefined || after.additionalProperties === true))
149
+ reasons.add("input-schema-unclassified");
150
+ }
151
+ }
152
+ function classifyOutputSchema(before, after, equal, reasons) {
153
+ if (before === undefined)
154
+ return;
155
+ if (after === undefined) {
156
+ reasons.add("output-schema-removed");
157
+ return;
158
+ }
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");
187
+ }
188
+ function hint(annotations, key, absent) {
189
+ if (annotations === undefined)
190
+ return absent;
191
+ if (!isJsonObject(annotations))
192
+ return null;
193
+ const value = annotations[key];
194
+ if (value === undefined)
195
+ return absent;
196
+ return typeof value === "boolean" ? value : null;
197
+ }
198
+ function classifyAnnotations(before, after, reasons) {
199
+ for (const { key, absent, safe } of SAFETY_HINTS) {
200
+ const beforeValue = hint(before, key, absent);
201
+ const afterValue = hint(after, key, absent);
202
+ if (beforeValue === safe && afterValue !== safe)
203
+ reasons.add("annotation-safety-reduced");
204
+ }
205
+ }
206
+ function result(reasons) {
207
+ const ordered = TOOL_CHANGE_REASONS.filter((reason) => reasons.has(reason));
208
+ const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
209
+ ? "breaking"
210
+ : ordered.length > 0 ? "unclassified" : "compatible";
211
+ return { impact, reasons: ordered };
212
+ }
213
+ export function classifyRemovedTool() {
214
+ return { impact: "breaking", reasons: ["tool-removed"] };
215
+ }
216
+ export function classifyAddedTool() {
217
+ return { impact: "compatible", reasons: [] };
218
+ }
219
+ export function classifyChangedTool(before, after, fields, equal) {
220
+ const reasons = new Set();
221
+ if (fields.includes("inputSchema"))
222
+ classifyInputSchema(before.inputSchema, after.inputSchema, equal, reasons);
223
+ if (fields.includes("outputSchema"))
224
+ classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
225
+ if (fields.includes("annotations"))
226
+ classifyAnnotations(before.annotations, after.annotations, reasons);
227
+ if (fields.includes("other"))
228
+ reasons.add("other-fields-unclassified");
229
+ return result(reasons);
230
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { packageVersion } from "../version.js";
3
+ import { TOOL_CHANGE_REASONS } from "./mcp-tool-impact.js";
3
4
  import { ruleCatalog } from "../rules/rule-catalog.js";
4
5
  const runtimeCapabilityStatusSchema = {
5
6
  type: "string",
@@ -785,33 +786,37 @@ publicSchemaDefinitions.push({
785
786
  before: embeddedToolFileReportSchema,
786
787
  after: embeddedToolFileReportSchema,
787
788
  comparison: {
788
- type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged"],
789
+ type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged", "breaking", "unclassified"],
789
790
  properties: {
790
791
  complete: { type: "boolean" },
791
792
  reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
792
793
  added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
793
794
  removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
794
795
  changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
795
- unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 }
796
+ unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 },
797
+ breaking: { type: ["integer", "null"], minimum: 0, maximum: 1000 },
798
+ unclassified: { type: ["integer", "null"], minimum: 0, maximum: 1000 }
796
799
  }, additionalProperties: false
797
800
  },
798
801
  coverage: {
799
- type: "object", required: ["comparison", "schema", "compatibility", "serverCatalog", "toolExecution"],
802
+ type: "object", required: ["comparison", "schema", "compatibility", "serverCatalog", "toolExecution", "impactClassification"],
800
803
  properties: {
801
804
  comparison: { const: "structural-only" }, schema: { const: "root-shape-only" },
802
805
  compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
803
- toolExecution: { const: "not-tested" }
806
+ toolExecution: { const: "not-tested" }, impactClassification: { const: "heuristic" }
804
807
  }, additionalProperties: false
805
808
  },
806
809
  changes: {
807
810
  type: "array", maxItems: 1000,
808
811
  items: {
809
- type: "object", required: ["kind", "beforeToolIndex", "afterToolIndex", "fields"],
812
+ type: "object", required: ["kind", "beforeToolIndex", "afterToolIndex", "fields", "impact", "reasons"],
810
813
  properties: {
811
814
  kind: { enum: ["added", "removed", "changed"] },
812
815
  beforeToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
813
816
  afterToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
814
- fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } }
817
+ fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } },
818
+ impact: { enum: ["breaking", "compatible", "unclassified"] },
819
+ reasons: { type: "array", maxItems: TOOL_CHANGE_REASONS.length, uniqueItems: true, items: { enum: [...TOOL_CHANGE_REASONS] } }
815
820
  }, additionalProperties: false
816
821
  }
817
822
  }
@@ -11,12 +11,15 @@ export function renderMcpToolDiffReport(report) {
11
11
  `Removed: ${comparison.removed ?? "not compared"}`,
12
12
  `Changed: ${comparison.changed ?? "not compared"}`,
13
13
  `Unchanged: ${comparison.unchanged ?? "not compared"}`,
14
+ `Breaking: ${comparison.breaking ?? "not compared"}`,
15
+ `Unclassified: ${comparison.unclassified ?? "not compared"}`,
14
16
  `Comparison coverage: ${report.coverage.comparison}`,
15
17
  `Schema coverage: ${report.coverage.schema}`,
16
18
  `Compatibility: ${report.coverage.compatibility}`,
17
19
  `Server catalog: ${report.coverage.serverCatalog}`,
18
20
  `Tool execution: ${report.coverage.toolExecution}`,
19
- "Only the supplied responses are compared. A change does not establish incompatibility; an unchanged result does not establish compatibility.",
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.",
20
23
  ...["before", "after"].flatMap((side) => {
21
24
  const source = report[side];
22
25
  const label = side === "before" ? "Before" : "After";
@@ -35,7 +38,8 @@ export function renderMcpToolDiffReport(report) {
35
38
  ...(change.beforeToolIndex === null ? [] : [`before tool ${change.beforeToolIndex}`]),
36
39
  ...(change.afterToolIndex === null ? [] : [`after tool ${change.afterToolIndex}`])
37
40
  ];
38
- return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}`;
41
+ const impact = `${change.impact}${change.reasons.length ? ` (${change.reasons.join(", ")})` : ""}`;
42
+ return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}\n Impact: ${impact}`;
39
43
  })
40
44
  ].join("\n");
41
45
  }
package/dist/run-cli.js CHANGED
@@ -107,7 +107,7 @@ function writeExactStdout(io, message) {
107
107
  }
108
108
  class CliUsageError extends Error {
109
109
  }
110
- const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json]";
110
+ const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json] [--fail-on any|breaking]";
111
111
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
112
112
  const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
113
113
  const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
@@ -1209,18 +1209,28 @@ export async function runCli(args, io = defaultIo, options = {}) {
1209
1209
  if (command === "doctor") {
1210
1210
  if (maybePath === "tools-diff") {
1211
1211
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1212
- io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Structural changes only; compatibility is not tested.`);
1212
+ io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Changes are classified heuristically as breaking, compatible, or unclassified; compatibility is not tested.\n--fail-on breaking exits 1 only for breaking or unclassified changes (default: any change or input warning).`);
1213
1213
  return 0;
1214
1214
  }
1215
1215
  let beforePath;
1216
1216
  let afterPath;
1217
1217
  let json = false;
1218
+ let failOn;
1218
1219
  let invalid = false;
1219
1220
  for (let index = 0; index < remainingArgs.length; index += 1) {
1220
1221
  const flag = remainingArgs[index];
1221
1222
  if (flag === "--json" && !json) {
1222
1223
  json = true;
1223
1224
  }
1225
+ else if (flag === "--fail-on" && failOn === undefined) {
1226
+ const value = remainingArgs[index + 1];
1227
+ if (value !== "any" && value !== "breaking") {
1228
+ invalid = true;
1229
+ break;
1230
+ }
1231
+ failOn = value;
1232
+ index += 1;
1233
+ }
1224
1234
  else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
1225
1235
  const value = remainingArgs[index + 1];
1226
1236
  if (!value || value.startsWith("-")) {
@@ -1244,7 +1254,12 @@ export async function runCli(args, io = defaultIo, options = {}) {
1244
1254
  }
1245
1255
  const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
1246
1256
  io.writeStdout(json ? JSON.stringify(report, null, 2) : renderMcpToolDiffReport(report));
1247
- return report.status === "pass" ? 0 : report.status === "warn" ? 1 : 2;
1257
+ if (report.status !== "pass" && report.status !== "warn")
1258
+ return 2;
1259
+ if (failOn === "breaking") {
1260
+ return (report.comparison.breaking ?? 0) + (report.comparison.unclassified ?? 0) > 0 ? 1 : 0;
1261
+ }
1262
+ return report.status === "pass" ? 0 : 1;
1248
1263
  }
1249
1264
  if (maybePath === "tools-file") {
1250
1265
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {