codex-plugin-doctor 1.63.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
@@ -109,6 +109,23 @@ tool, and finding limits. It does not establish server inventory completeness or
109
109
  execute tools. See [Offline MCP Tool Definitions](docs/guides/offline-tool-definitions.md)
110
110
  for the accepted input format, coverage, and exit codes.
111
111
 
112
+ ### Offline MCP Tool Diff
113
+
114
+ Compare two saved tool-list responses before updating a server or client:
115
+
116
+ ```bash
117
+ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-new.json --json
118
+ ```
119
+
120
+ This reports added, removed, and structurally changed definitions using numeric
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.
126
+ See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
127
+ privacy, limits, and exit codes.
128
+
112
129
  ### HTTP MCP Tool Catalog
113
130
 
114
131
  Inspect a modern HTTP MCP server's tool definitions without invoking them:
@@ -541,9 +558,9 @@ jobs:
541
558
  runs-on: ubuntu-latest
542
559
  steps:
543
560
  - uses: actions/checkout@v5
544
- - uses: Esquetta/CodexPluginDoctor@v1.63.0
561
+ - uses: Esquetta/CodexPluginDoctor@v1.65.0
545
562
  with:
546
- version: "1.63.0"
563
+ version: "1.65.0"
547
564
  path: .
548
565
  runtime: "true"
549
566
  policy: codex-publish
@@ -0,0 +1,38 @@
1
+ import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
2
+ import { type McpToolFileReport } from "./mcp-tool-file.js";
3
+ type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
4
+ export interface McpToolDiffReport {
5
+ schemaVersion: 1;
6
+ scope: "tool-definitions-diff";
7
+ status: "pass" | "warn" | "incomplete" | "blocked";
8
+ before: McpToolFileReport;
9
+ after: McpToolFileReport;
10
+ comparison: {
11
+ complete: boolean;
12
+ reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
13
+ added: number | null;
14
+ removed: number | null;
15
+ changed: number | null;
16
+ unchanged: number | null;
17
+ breaking: number | null;
18
+ unclassified: number | null;
19
+ };
20
+ coverage: {
21
+ comparison: "structural-only";
22
+ schema: "root-shape-only";
23
+ compatibility: "not-tested";
24
+ serverCatalog: "not-tested";
25
+ toolExecution: "not-tested";
26
+ impactClassification: "heuristic";
27
+ };
28
+ changes: Array<{
29
+ kind: "added" | "removed" | "changed";
30
+ beforeToolIndex: number | null;
31
+ afterToolIndex: number | null;
32
+ fields: DiffField[];
33
+ impact: ToolChangeImpact;
34
+ reasons: ToolChangeReason[];
35
+ }>;
36
+ }
37
+ export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
38
+ export {};
@@ -0,0 +1,168 @@
1
+ import { classifyAddedTool, classifyChangedTool, classifyRemovedTool } from "./mcp-tool-impact.js";
2
+ import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
3
+ const FIXED_FIELDS = [
4
+ "inputSchema",
5
+ "outputSchema",
6
+ "description",
7
+ "title",
8
+ "annotations"
9
+ ];
10
+ const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
11
+ function isJsonObject(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function structurallyEqual(left, right) {
15
+ const pending = [[left, right]];
16
+ while (pending.length > 0) {
17
+ const [currentLeft, currentRight] = pending.pop();
18
+ if (Object.is(currentLeft, currentRight))
19
+ continue;
20
+ if (Array.isArray(currentLeft) || Array.isArray(currentRight)) {
21
+ if (!Array.isArray(currentLeft) || !Array.isArray(currentRight) || currentLeft.length !== currentRight.length)
22
+ return false;
23
+ for (let index = 0; index < currentLeft.length; index += 1)
24
+ pending.push([currentLeft[index], currentRight[index]]);
25
+ continue;
26
+ }
27
+ if (!isJsonObject(currentLeft) || !isJsonObject(currentRight))
28
+ return false;
29
+ const leftKeys = Object.keys(currentLeft);
30
+ const rightKeys = Object.keys(currentRight);
31
+ if (leftKeys.length !== rightKeys.length)
32
+ return false;
33
+ for (const key of leftKeys) {
34
+ if (!Object.hasOwn(currentRight, key))
35
+ return false;
36
+ pending.push([currentLeft[key], currentRight[key]]);
37
+ }
38
+ }
39
+ return true;
40
+ }
41
+ function filteredOther(definition) {
42
+ const other = Object.create(null);
43
+ for (const key of Object.keys(definition)) {
44
+ if (!EXCLUDED_OTHER_FIELDS.has(key))
45
+ other[key] = definition[key];
46
+ }
47
+ return other;
48
+ }
49
+ function changedFields(before, after) {
50
+ const fields = [];
51
+ for (const field of FIXED_FIELDS) {
52
+ if (!structurallyEqual(before[field], after[field]))
53
+ fields.push(field);
54
+ }
55
+ if (!structurallyEqual(filteredOther(before), filteredOther(after)))
56
+ fields.push("other");
57
+ return fields;
58
+ }
59
+ function toolDefinitions(tools) {
60
+ const definitions = new Map();
61
+ for (let offset = 0; offset < tools.length; offset += 1) {
62
+ const value = tools[offset];
63
+ if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
64
+ return null;
65
+ definitions.set(value.name, { index: offset + 1, value });
66
+ }
67
+ return definitions;
68
+ }
69
+ function incompleteReport(before, after, status, reason) {
70
+ return {
71
+ schemaVersion: 1,
72
+ scope: "tool-definitions-diff",
73
+ status,
74
+ before,
75
+ after,
76
+ comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
77
+ coverage: {
78
+ comparison: "structural-only",
79
+ schema: "root-shape-only",
80
+ compatibility: "not-tested",
81
+ serverCatalog: "not-tested",
82
+ toolExecution: "not-tested",
83
+ impactClassification: "heuristic"
84
+ },
85
+ changes: []
86
+ };
87
+ }
88
+ export async function compareMcpToolFiles(beforePath, afterPath) {
89
+ const [beforeSnapshot, afterSnapshot] = await Promise.all([
90
+ loadMcpToolFileSnapshot(beforePath),
91
+ loadMcpToolFileSnapshot(afterPath)
92
+ ]);
93
+ const { report: before } = beforeSnapshot;
94
+ const { report: after } = afterSnapshot;
95
+ if (before.status === "blocked" || after.status === "blocked") {
96
+ return incompleteReport(before, after, "blocked", "input-blocked");
97
+ }
98
+ if (!before.inspection.complete || !after.inspection.complete) {
99
+ return incompleteReport(before, after, "incomplete", "input-incomplete");
100
+ }
101
+ if (before.status === "fail" || after.status === "fail" || beforeSnapshot.tools === null || afterSnapshot.tools === null) {
102
+ return incompleteReport(before, after, "incomplete", "invalid-definitions");
103
+ }
104
+ const beforeDefinitions = toolDefinitions(beforeSnapshot.tools);
105
+ const afterDefinitions = toolDefinitions(afterSnapshot.tools);
106
+ if (beforeDefinitions === null || afterDefinitions === null) {
107
+ return incompleteReport(before, after, "incomplete", "ambiguous-names");
108
+ }
109
+ const changes = [];
110
+ let added = 0;
111
+ let removed = 0;
112
+ let changed = 0;
113
+ let unchanged = 0;
114
+ for (const [name, beforeDefinition] of beforeDefinitions) {
115
+ const afterDefinition = afterDefinitions.get(name);
116
+ if (afterDefinition === undefined) {
117
+ removed += 1;
118
+ changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
119
+ continue;
120
+ }
121
+ const fields = changedFields(beforeDefinition.value, afterDefinition.value);
122
+ if (fields.length === 0) {
123
+ unchanged += 1;
124
+ }
125
+ else {
126
+ changed += 1;
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
+ });
134
+ }
135
+ }
136
+ for (const [name, afterDefinition] of afterDefinitions) {
137
+ if (!beforeDefinitions.has(name)) {
138
+ added += 1;
139
+ changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
140
+ }
141
+ }
142
+ return {
143
+ schemaVersion: 1,
144
+ scope: "tool-definitions-diff",
145
+ status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
146
+ before,
147
+ after,
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
+ },
158
+ coverage: {
159
+ comparison: "structural-only",
160
+ schema: "root-shape-only",
161
+ compatibility: "not-tested",
162
+ serverCatalog: "not-tested",
163
+ toolExecution: "not-tested",
164
+ impactClassification: "heuristic"
165
+ },
166
+ changes
167
+ };
168
+ }
@@ -24,4 +24,9 @@ export interface McpToolFileReport {
24
24
  };
25
25
  findings: CatalogFinding[];
26
26
  }
27
+ export interface McpToolFileSnapshot {
28
+ report: McpToolFileReport;
29
+ tools: unknown[] | null;
30
+ }
31
+ export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
27
32
  export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
@@ -4,6 +4,9 @@ import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
4
4
  const MAX_FILE_BYTES = 1024 * 1024;
5
5
  const MAX_TOOLS = 500;
6
6
  const MAX_FINDINGS = 100;
7
+ function snapshot(report, tools = null) {
8
+ return { report, tools };
9
+ }
7
10
  function isJsonObject(value) {
8
11
  return typeof value === "object" && value !== null && !Array.isArray(value);
9
12
  }
@@ -143,43 +146,43 @@ async function readBoundedRegularFile(filePath) {
143
146
  await fileHandle?.close().catch(() => undefined);
144
147
  }
145
148
  }
146
- export async function inspectMcpToolFile(filePath) {
149
+ export async function loadMcpToolFileSnapshot(filePath) {
147
150
  if (isBlockedOfflinePath(filePath)) {
148
- return blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path."));
151
+ return snapshot(blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path.")));
149
152
  }
150
153
  const file = await readBoundedRegularFile(filePath);
151
154
  if (file.kind === "unavailable") {
152
- return incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file."));
155
+ return snapshot(incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file.")));
153
156
  }
154
157
  if (file.kind === "too-large") {
155
- return incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB."));
158
+ return snapshot(incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB.")));
156
159
  }
157
160
  if (file.kind === "changed") {
158
- return incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable."));
161
+ return snapshot(incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable.")));
159
162
  }
160
163
  if (file.kind === "symlink") {
161
- return blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly."));
164
+ return snapshot(blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly.")));
162
165
  }
163
166
  if (file.kind === "not-regular") {
164
- return blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly."));
167
+ return snapshot(blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly.")));
165
168
  }
166
169
  let decoded;
167
170
  try {
168
171
  decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
169
172
  }
170
173
  catch {
171
- return incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON."));
174
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON.")));
172
175
  }
173
176
  let value;
174
177
  try {
175
178
  value = JSON.parse(decoded);
176
179
  }
177
180
  catch {
178
- return incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response."));
181
+ return snapshot(incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response.")));
179
182
  }
180
183
  const parsed = parseToolsResult(value);
181
184
  if (parsed === null) {
182
- return incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata."));
185
+ return snapshot(incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata.")));
183
186
  }
184
187
  const findings = [];
185
188
  const names = new Map();
@@ -225,17 +228,20 @@ export async function inspectMcpToolFile(filePath) {
225
228
  if (reason === null && parsed.hasNextCursor)
226
229
  reason = "more-pages-present";
227
230
  if (reason !== null) {
228
- return report(file.bytesRead, "incomplete", {
231
+ return snapshot(report(file.bytesRead, "incomplete", {
229
232
  complete: false,
230
233
  toolsChecked,
231
234
  hasNextCursor: parsed.hasNextCursor,
232
235
  reason
233
- }, findings);
236
+ }, findings), parsed.tools);
234
237
  }
235
- return report(file.bytesRead, finalStatus(findings), {
238
+ return snapshot(report(file.bytesRead, finalStatus(findings), {
236
239
  complete: true,
237
240
  toolsChecked,
238
241
  hasNextCursor: false,
239
242
  reason: null
240
- }, findings);
243
+ }, findings), parsed.tools);
244
+ }
245
+ export async function inspectMcpToolFile(filePath) {
246
+ return (await loadMcpToolFileSnapshot(filePath)).report;
241
247
  }
@@ -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
+ }