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