codex-plugin-doctor 1.64.0 → 1.66.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
 
@@ -135,6 +138,10 @@ This opt-in command performs discovery and bounded `tools/list` pagination. It
135
138
  reports tool-definition and root-schema shape findings, explicit completion state,
136
139
  and partial inspection limits. It does not claim full schema validation, tool
137
140
  execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
141
+ Add `--save-response <path>` to record a completely enumerated catalog as a
142
+ `tools/list` response for `tools-file` and `tools-diff`; nothing is written when
143
+ enumeration is incomplete. The GitHub Action can compare saved responses with
144
+ `tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on`.
138
145
  See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
139
146
 
140
147
  ### Remote MCP Readiness
@@ -555,9 +562,9 @@ jobs:
555
562
  runs-on: ubuntu-latest
556
563
  steps:
557
564
  - uses: actions/checkout@v5
558
- - uses: Esquetta/CodexPluginDoctor@v1.64.0
565
+ - uses: Esquetta/CodexPluginDoctor@v1.66.0
559
566
  with:
560
- version: "1.64.0"
567
+ version: "1.66.0"
561
568
  path: .
562
569
  runtime: "true"
563
570
  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,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
  };
@@ -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);
@@ -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
+ }
@@ -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>;