codex-plugin-doctor 1.63.0 → 1.64.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 +16 -2
- package/dist/core/mcp-tool-diff.d.ts +32 -0
- package/dist/core/mcp-tool-diff.js +150 -0
- package/dist/core/mcp-tool-file.d.ts +5 -0
- package/dist/core/mcp-tool-file.js +20 -14
- package/dist/core/output-contract.js +50 -0
- package/dist/reporting/render-mcp-tool-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-diff-report.js +41 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +43 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,6 +109,20 @@ 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. Incomplete inputs, invalid definitions, or duplicate names prevent a
|
|
122
|
+
comparison. It does not determine whether a schema change breaks compatibility.
|
|
123
|
+
See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
|
|
124
|
+
privacy, limits, and exit codes.
|
|
125
|
+
|
|
112
126
|
### HTTP MCP Tool Catalog
|
|
113
127
|
|
|
114
128
|
Inspect a modern HTTP MCP server's tool definitions without invoking them:
|
|
@@ -541,9 +555,9 @@ jobs:
|
|
|
541
555
|
runs-on: ubuntu-latest
|
|
542
556
|
steps:
|
|
543
557
|
- uses: actions/checkout@v5
|
|
544
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
558
|
+
- uses: Esquetta/CodexPluginDoctor@v1.64.0
|
|
545
559
|
with:
|
|
546
|
-
version: "1.
|
|
560
|
+
version: "1.64.0"
|
|
547
561
|
path: .
|
|
548
562
|
runtime: "true"
|
|
549
563
|
policy: codex-publish
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type McpToolFileReport } from "./mcp-tool-file.js";
|
|
2
|
+
type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
|
|
3
|
+
export interface McpToolDiffReport {
|
|
4
|
+
schemaVersion: 1;
|
|
5
|
+
scope: "tool-definitions-diff";
|
|
6
|
+
status: "pass" | "warn" | "incomplete" | "blocked";
|
|
7
|
+
before: McpToolFileReport;
|
|
8
|
+
after: McpToolFileReport;
|
|
9
|
+
comparison: {
|
|
10
|
+
complete: boolean;
|
|
11
|
+
reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
|
|
12
|
+
added: number | null;
|
|
13
|
+
removed: number | null;
|
|
14
|
+
changed: number | null;
|
|
15
|
+
unchanged: number | null;
|
|
16
|
+
};
|
|
17
|
+
coverage: {
|
|
18
|
+
comparison: "structural-only";
|
|
19
|
+
schema: "root-shape-only";
|
|
20
|
+
compatibility: "not-tested";
|
|
21
|
+
serverCatalog: "not-tested";
|
|
22
|
+
toolExecution: "not-tested";
|
|
23
|
+
};
|
|
24
|
+
changes: Array<{
|
|
25
|
+
kind: "added" | "removed" | "changed";
|
|
26
|
+
beforeToolIndex: number | null;
|
|
27
|
+
afterToolIndex: number | null;
|
|
28
|
+
fields: DiffField[];
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
|
|
2
|
+
const FIXED_FIELDS = [
|
|
3
|
+
"inputSchema",
|
|
4
|
+
"outputSchema",
|
|
5
|
+
"description",
|
|
6
|
+
"title",
|
|
7
|
+
"annotations"
|
|
8
|
+
];
|
|
9
|
+
const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
|
|
10
|
+
function isJsonObject(value) {
|
|
11
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
function structurallyEqual(left, right) {
|
|
14
|
+
const pending = [[left, right]];
|
|
15
|
+
while (pending.length > 0) {
|
|
16
|
+
const [currentLeft, currentRight] = pending.pop();
|
|
17
|
+
if (Object.is(currentLeft, currentRight))
|
|
18
|
+
continue;
|
|
19
|
+
if (Array.isArray(currentLeft) || Array.isArray(currentRight)) {
|
|
20
|
+
if (!Array.isArray(currentLeft) || !Array.isArray(currentRight) || currentLeft.length !== currentRight.length)
|
|
21
|
+
return false;
|
|
22
|
+
for (let index = 0; index < currentLeft.length; index += 1)
|
|
23
|
+
pending.push([currentLeft[index], currentRight[index]]);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (!isJsonObject(currentLeft) || !isJsonObject(currentRight))
|
|
27
|
+
return false;
|
|
28
|
+
const leftKeys = Object.keys(currentLeft);
|
|
29
|
+
const rightKeys = Object.keys(currentRight);
|
|
30
|
+
if (leftKeys.length !== rightKeys.length)
|
|
31
|
+
return false;
|
|
32
|
+
for (const key of leftKeys) {
|
|
33
|
+
if (!Object.hasOwn(currentRight, key))
|
|
34
|
+
return false;
|
|
35
|
+
pending.push([currentLeft[key], currentRight[key]]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function filteredOther(definition) {
|
|
41
|
+
const other = Object.create(null);
|
|
42
|
+
for (const key of Object.keys(definition)) {
|
|
43
|
+
if (!EXCLUDED_OTHER_FIELDS.has(key))
|
|
44
|
+
other[key] = definition[key];
|
|
45
|
+
}
|
|
46
|
+
return other;
|
|
47
|
+
}
|
|
48
|
+
function changedFields(before, after) {
|
|
49
|
+
const fields = [];
|
|
50
|
+
for (const field of FIXED_FIELDS) {
|
|
51
|
+
if (!structurallyEqual(before[field], after[field]))
|
|
52
|
+
fields.push(field);
|
|
53
|
+
}
|
|
54
|
+
if (!structurallyEqual(filteredOther(before), filteredOther(after)))
|
|
55
|
+
fields.push("other");
|
|
56
|
+
return fields;
|
|
57
|
+
}
|
|
58
|
+
function toolDefinitions(tools) {
|
|
59
|
+
const definitions = new Map();
|
|
60
|
+
for (let offset = 0; offset < tools.length; offset += 1) {
|
|
61
|
+
const value = tools[offset];
|
|
62
|
+
if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
|
|
63
|
+
return null;
|
|
64
|
+
definitions.set(value.name, { index: offset + 1, value });
|
|
65
|
+
}
|
|
66
|
+
return definitions;
|
|
67
|
+
}
|
|
68
|
+
function incompleteReport(before, after, status, reason) {
|
|
69
|
+
return {
|
|
70
|
+
schemaVersion: 1,
|
|
71
|
+
scope: "tool-definitions-diff",
|
|
72
|
+
status,
|
|
73
|
+
before,
|
|
74
|
+
after,
|
|
75
|
+
comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null },
|
|
76
|
+
coverage: {
|
|
77
|
+
comparison: "structural-only",
|
|
78
|
+
schema: "root-shape-only",
|
|
79
|
+
compatibility: "not-tested",
|
|
80
|
+
serverCatalog: "not-tested",
|
|
81
|
+
toolExecution: "not-tested"
|
|
82
|
+
},
|
|
83
|
+
changes: []
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
87
|
+
const [beforeSnapshot, afterSnapshot] = await Promise.all([
|
|
88
|
+
loadMcpToolFileSnapshot(beforePath),
|
|
89
|
+
loadMcpToolFileSnapshot(afterPath)
|
|
90
|
+
]);
|
|
91
|
+
const { report: before } = beforeSnapshot;
|
|
92
|
+
const { report: after } = afterSnapshot;
|
|
93
|
+
if (before.status === "blocked" || after.status === "blocked") {
|
|
94
|
+
return incompleteReport(before, after, "blocked", "input-blocked");
|
|
95
|
+
}
|
|
96
|
+
if (!before.inspection.complete || !after.inspection.complete) {
|
|
97
|
+
return incompleteReport(before, after, "incomplete", "input-incomplete");
|
|
98
|
+
}
|
|
99
|
+
if (before.status === "fail" || after.status === "fail" || beforeSnapshot.tools === null || afterSnapshot.tools === null) {
|
|
100
|
+
return incompleteReport(before, after, "incomplete", "invalid-definitions");
|
|
101
|
+
}
|
|
102
|
+
const beforeDefinitions = toolDefinitions(beforeSnapshot.tools);
|
|
103
|
+
const afterDefinitions = toolDefinitions(afterSnapshot.tools);
|
|
104
|
+
if (beforeDefinitions === null || afterDefinitions === null) {
|
|
105
|
+
return incompleteReport(before, after, "incomplete", "ambiguous-names");
|
|
106
|
+
}
|
|
107
|
+
const changes = [];
|
|
108
|
+
let added = 0;
|
|
109
|
+
let removed = 0;
|
|
110
|
+
let changed = 0;
|
|
111
|
+
let unchanged = 0;
|
|
112
|
+
for (const [name, beforeDefinition] of beforeDefinitions) {
|
|
113
|
+
const afterDefinition = afterDefinitions.get(name);
|
|
114
|
+
if (afterDefinition === undefined) {
|
|
115
|
+
removed += 1;
|
|
116
|
+
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [] });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
120
|
+
if (fields.length === 0) {
|
|
121
|
+
unchanged += 1;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
changed += 1;
|
|
125
|
+
changes.push({ kind: "changed", beforeToolIndex: beforeDefinition.index, afterToolIndex: afterDefinition.index, fields });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const [name, afterDefinition] of afterDefinitions) {
|
|
129
|
+
if (!beforeDefinitions.has(name)) {
|
|
130
|
+
added += 1;
|
|
131
|
+
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [] });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
scope: "tool-definitions-diff",
|
|
137
|
+
status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
|
|
138
|
+
before,
|
|
139
|
+
after,
|
|
140
|
+
comparison: { complete: true, reason: null, added, removed, changed, unchanged },
|
|
141
|
+
coverage: {
|
|
142
|
+
comparison: "structural-only",
|
|
143
|
+
schema: "root-shape-only",
|
|
144
|
+
compatibility: "not-tested",
|
|
145
|
+
serverCatalog: "not-tested",
|
|
146
|
+
toolExecution: "not-tested"
|
|
147
|
+
},
|
|
148
|
+
changes
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -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
|
|
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
|
}
|
|
@@ -767,6 +767,56 @@ const publicSchemaDefinitions = [
|
|
|
767
767
|
}
|
|
768
768
|
}
|
|
769
769
|
];
|
|
770
|
+
const toolFileReportDefinition = publicSchemaDefinitions.find((definition) => definition.id === "doctor.tools.file.json");
|
|
771
|
+
const embeddedToolFileReportSchema = {
|
|
772
|
+
type: "object",
|
|
773
|
+
required: toolFileReportDefinition.required,
|
|
774
|
+
properties: toolFileReportDefinition.properties,
|
|
775
|
+
additionalProperties: true
|
|
776
|
+
};
|
|
777
|
+
publicSchemaDefinitions.push({
|
|
778
|
+
id: "doctor.tools.diff.json",
|
|
779
|
+
command: "codex-plugin-doctor doctor tools-diff --before <path> --after <path> --json",
|
|
780
|
+
required: ["schemaVersion", "scope", "status", "before", "after", "comparison", "coverage", "changes"],
|
|
781
|
+
properties: {
|
|
782
|
+
schemaVersion: { const: 1 },
|
|
783
|
+
scope: { const: "tool-definitions-diff" },
|
|
784
|
+
status: { enum: ["pass", "warn", "incomplete", "blocked"] },
|
|
785
|
+
before: embeddedToolFileReportSchema,
|
|
786
|
+
after: embeddedToolFileReportSchema,
|
|
787
|
+
comparison: {
|
|
788
|
+
type: "object", required: ["complete", "reason", "added", "removed", "changed", "unchanged"],
|
|
789
|
+
properties: {
|
|
790
|
+
complete: { type: "boolean" },
|
|
791
|
+
reason: { enum: [null, "input-blocked", "input-incomplete", "invalid-definitions", "ambiguous-names"] },
|
|
792
|
+
added: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
793
|
+
removed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
794
|
+
changed: { type: ["integer", "null"], minimum: 0, maximum: 500 },
|
|
795
|
+
unchanged: { type: ["integer", "null"], minimum: 0, maximum: 500 }
|
|
796
|
+
}, additionalProperties: false
|
|
797
|
+
},
|
|
798
|
+
coverage: {
|
|
799
|
+
type: "object", required: ["comparison", "schema", "compatibility", "serverCatalog", "toolExecution"],
|
|
800
|
+
properties: {
|
|
801
|
+
comparison: { const: "structural-only" }, schema: { const: "root-shape-only" },
|
|
802
|
+
compatibility: { const: "not-tested" }, serverCatalog: { const: "not-tested" },
|
|
803
|
+
toolExecution: { const: "not-tested" }
|
|
804
|
+
}, additionalProperties: false
|
|
805
|
+
},
|
|
806
|
+
changes: {
|
|
807
|
+
type: "array", maxItems: 1000,
|
|
808
|
+
items: {
|
|
809
|
+
type: "object", required: ["kind", "beforeToolIndex", "afterToolIndex", "fields"],
|
|
810
|
+
properties: {
|
|
811
|
+
kind: { enum: ["added", "removed", "changed"] },
|
|
812
|
+
beforeToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
|
|
813
|
+
afterToolIndex: { type: ["integer", "null"], minimum: 1, maximum: 500 },
|
|
814
|
+
fields: { type: "array", maxItems: 6, uniqueItems: true, items: { enum: ["inputSchema", "outputSchema", "description", "title", "annotations", "other"] } }
|
|
815
|
+
}, additionalProperties: false
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
});
|
|
770
820
|
function sha256(value) {
|
|
771
821
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
772
822
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export function renderMcpToolDiffReport(report) {
|
|
2
|
+
const comparison = report.comparison;
|
|
3
|
+
return [
|
|
4
|
+
"Offline MCP Tool Diff",
|
|
5
|
+
"=====================",
|
|
6
|
+
`Status: ${report.status}`,
|
|
7
|
+
`Scope: ${report.scope}`,
|
|
8
|
+
`Comparison complete: ${comparison.complete}`,
|
|
9
|
+
...(comparison.reason === null ? [] : [`Reason: ${comparison.reason}`]),
|
|
10
|
+
`Added: ${comparison.added ?? "not compared"}`,
|
|
11
|
+
`Removed: ${comparison.removed ?? "not compared"}`,
|
|
12
|
+
`Changed: ${comparison.changed ?? "not compared"}`,
|
|
13
|
+
`Unchanged: ${comparison.unchanged ?? "not compared"}`,
|
|
14
|
+
`Comparison coverage: ${report.coverage.comparison}`,
|
|
15
|
+
`Schema coverage: ${report.coverage.schema}`,
|
|
16
|
+
`Compatibility: ${report.coverage.compatibility}`,
|
|
17
|
+
`Server catalog: ${report.coverage.serverCatalog}`,
|
|
18
|
+
`Tool execution: ${report.coverage.toolExecution}`,
|
|
19
|
+
"Only the supplied responses are compared. A change does not establish incompatibility; an unchanged result does not establish compatibility.",
|
|
20
|
+
...["before", "after"].flatMap((side) => {
|
|
21
|
+
const source = report[side];
|
|
22
|
+
const label = side === "before" ? "Before" : "After";
|
|
23
|
+
return [
|
|
24
|
+
`${label} input: ${source.status}; tools checked: ${source.inspection.toolsChecked}; bytes read: ${source.source.bytesRead}`,
|
|
25
|
+
...(source.inspection.reason === null ? [] : [`${label} reason: ${source.inspection.reason}`]),
|
|
26
|
+
...source.findings.map((finding) => {
|
|
27
|
+
const location = finding.location;
|
|
28
|
+
const locator = location ? ` [${side} tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}]` : "";
|
|
29
|
+
return `${label} ${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
30
|
+
})
|
|
31
|
+
];
|
|
32
|
+
}),
|
|
33
|
+
...report.changes.map((change) => {
|
|
34
|
+
const locations = [
|
|
35
|
+
...(change.beforeToolIndex === null ? [] : [`before tool ${change.beforeToolIndex}`]),
|
|
36
|
+
...(change.afterToolIndex === null ? [] : [`after tool ${change.afterToolIndex}`])
|
|
37
|
+
];
|
|
38
|
+
return `${change.kind.toUpperCase()} [${locations.join(" -> ")}]${change.fields.length ? `: ${change.fields.join(", ")}` : ""}`;
|
|
39
|
+
})
|
|
40
|
+
].join("\n");
|
|
41
|
+
}
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
2
2
|
import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
3
3
|
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
4
|
+
import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
|
|
4
5
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
|
|
5
6
|
import { runCheck } from "./index.js";
|
|
6
7
|
import { writeRawDoctorConfig } from "./index.js";
|
|
@@ -16,6 +17,7 @@ export interface CliTerminalContext {
|
|
|
16
17
|
platform?: NodeJS.Platform;
|
|
17
18
|
}
|
|
18
19
|
export interface RunCliOptions {
|
|
20
|
+
compareMcpToolFilesImpl?: typeof compareMcpToolFiles;
|
|
19
21
|
inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
|
|
20
22
|
inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
|
|
21
23
|
discoverMcpServerImpl?: typeof discoverMcpServer;
|
package/dist/run-cli.js
CHANGED
|
@@ -24,6 +24,8 @@ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
|
|
|
24
24
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
25
25
|
import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
26
26
|
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
27
|
+
import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
|
|
28
|
+
import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
|
|
27
29
|
import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
|
|
28
30
|
import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
|
|
29
31
|
import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
|
|
@@ -105,6 +107,7 @@ function writeExactStdout(io, message) {
|
|
|
105
107
|
}
|
|
106
108
|
class CliUsageError extends Error {
|
|
107
109
|
}
|
|
110
|
+
const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json]";
|
|
108
111
|
const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
|
|
109
112
|
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
|
|
110
113
|
const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
|
|
@@ -193,6 +196,7 @@ function printUsage(io) {
|
|
|
193
196
|
io.writeStderr(discoveryUsage);
|
|
194
197
|
io.writeStderr(toolsUsage);
|
|
195
198
|
io.writeStderr(toolsFileUsage);
|
|
199
|
+
io.writeStderr(toolsDiffUsage);
|
|
196
200
|
io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
197
201
|
+ " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
198
202
|
+ "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
|
|
@@ -1203,6 +1207,45 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1203
1207
|
return 0;
|
|
1204
1208
|
}
|
|
1205
1209
|
if (command === "doctor") {
|
|
1210
|
+
if (maybePath === "tools-diff") {
|
|
1211
|
+
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1212
|
+
io.writeStdout(`${toolsDiffUsage}\nCompares two saved MCP tools/list responses offline. Structural changes only; compatibility is not tested.`);
|
|
1213
|
+
return 0;
|
|
1214
|
+
}
|
|
1215
|
+
let beforePath;
|
|
1216
|
+
let afterPath;
|
|
1217
|
+
let json = false;
|
|
1218
|
+
let invalid = false;
|
|
1219
|
+
for (let index = 0; index < remainingArgs.length; index += 1) {
|
|
1220
|
+
const flag = remainingArgs[index];
|
|
1221
|
+
if (flag === "--json" && !json) {
|
|
1222
|
+
json = true;
|
|
1223
|
+
}
|
|
1224
|
+
else if ((flag === "--before" && beforePath === undefined) || (flag === "--after" && afterPath === undefined)) {
|
|
1225
|
+
const value = remainingArgs[index + 1];
|
|
1226
|
+
if (!value || value.startsWith("-")) {
|
|
1227
|
+
invalid = true;
|
|
1228
|
+
break;
|
|
1229
|
+
}
|
|
1230
|
+
if (flag === "--before")
|
|
1231
|
+
beforePath = value;
|
|
1232
|
+
else
|
|
1233
|
+
afterPath = value;
|
|
1234
|
+
index += 1;
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
invalid = true;
|
|
1238
|
+
break;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (invalid || !beforePath || !afterPath) {
|
|
1242
|
+
io.writeStderr(`Invalid comparison arguments. ${toolsDiffUsage}`);
|
|
1243
|
+
return 2;
|
|
1244
|
+
}
|
|
1245
|
+
const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
|
|
1246
|
+
io.writeStdout(json ? JSON.stringify(report, null, 2) : renderMcpToolDiffReport(report));
|
|
1247
|
+
return report.status === "pass" ? 0 : report.status === "warn" ? 1 : 2;
|
|
1248
|
+
}
|
|
1206
1249
|
if (maybePath === "tools-file") {
|
|
1207
1250
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1208
1251
|
io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
|
package/package.json
CHANGED