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 +21 -21
- package/README.md +7 -4
- package/dist/core/mcp-tool-diff.d.ts +6 -0
- package/dist/core/mcp-tool-diff.js +25 -7
- package/dist/core/mcp-tool-impact.d.ts +13 -0
- package/dist/core/mcp-tool-impact.js +230 -0
- package/dist/core/output-contract.js +11 -6
- package/dist/reporting/render-mcp-tool-diff-report.js +6 -2
- package/dist/run-cli.js +18 -3
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
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
|
|
122
|
-
|
|
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.
|
|
561
|
+
- uses: Esquetta/CodexPluginDoctor@v1.65.0
|
|
559
562
|
with:
|
|
560
|
-
version: "1.
|
|
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({
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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") {
|