codex-plugin-doctor 1.63.0 → 1.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +19 -2
- package/dist/core/mcp-tool-diff.d.ts +38 -0
- package/dist/core/mcp-tool-diff.js +168 -0
- package/dist/core/mcp-tool-file.d.ts +5 -0
- package/dist/core/mcp-tool-file.js +20 -14
- package/dist/core/mcp-tool-impact.d.ts +13 -0
- package/dist/core/mcp-tool-impact.js +230 -0
- package/dist/core/output-contract.js +55 -0
- package/dist/reporting/render-mcp-tool-diff-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-diff-report.js +45 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +58 -0
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
package/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Furkan Tasci
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Furkan Tasci
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -109,6 +109,23 @@ tool, and finding limits. It does not establish server inventory completeness or
|
|
|
109
109
|
execute tools. See [Offline MCP Tool Definitions](docs/guides/offline-tool-definitions.md)
|
|
110
110
|
for the accepted input format, coverage, and exit codes.
|
|
111
111
|
|
|
112
|
+
### Offline MCP Tool Diff
|
|
113
|
+
|
|
114
|
+
Compare two saved tool-list responses before updating a server or client:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-new.json --json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This reports added, removed, and structurally changed definitions using numeric
|
|
121
|
+
locations, and labels each change `breaking`, `compatible`, or `unclassified`
|
|
122
|
+
with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
|
|
123
|
+
unclassified changes. Incomplete inputs, invalid definitions, or duplicate names
|
|
124
|
+
prevent a comparison. Impact labels are root-level schema heuristics, not proof
|
|
125
|
+
of compatibility.
|
|
126
|
+
See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
|
|
127
|
+
privacy, limits, and exit codes.
|
|
128
|
+
|
|
112
129
|
### HTTP MCP Tool Catalog
|
|
113
130
|
|
|
114
131
|
Inspect a modern HTTP MCP server's tool definitions without invoking them:
|
|
@@ -541,9 +558,9 @@ jobs:
|
|
|
541
558
|
runs-on: ubuntu-latest
|
|
542
559
|
steps:
|
|
543
560
|
- uses: actions/checkout@v5
|
|
544
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
561
|
+
- uses: Esquetta/CodexPluginDoctor@v1.65.0
|
|
545
562
|
with:
|
|
546
|
-
version: "1.
|
|
563
|
+
version: "1.65.0"
|
|
547
564
|
path: .
|
|
548
565
|
runtime: "true"
|
|
549
566
|
policy: codex-publish
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
2
|
+
import { type McpToolFileReport } from "./mcp-tool-file.js";
|
|
3
|
+
type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
|
|
4
|
+
export interface McpToolDiffReport {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
scope: "tool-definitions-diff";
|
|
7
|
+
status: "pass" | "warn" | "incomplete" | "blocked";
|
|
8
|
+
before: McpToolFileReport;
|
|
9
|
+
after: McpToolFileReport;
|
|
10
|
+
comparison: {
|
|
11
|
+
complete: boolean;
|
|
12
|
+
reason: "input-blocked" | "input-incomplete" | "invalid-definitions" | "ambiguous-names" | null;
|
|
13
|
+
added: number | null;
|
|
14
|
+
removed: number | null;
|
|
15
|
+
changed: number | null;
|
|
16
|
+
unchanged: number | null;
|
|
17
|
+
breaking: number | null;
|
|
18
|
+
unclassified: number | null;
|
|
19
|
+
};
|
|
20
|
+
coverage: {
|
|
21
|
+
comparison: "structural-only";
|
|
22
|
+
schema: "root-shape-only";
|
|
23
|
+
compatibility: "not-tested";
|
|
24
|
+
serverCatalog: "not-tested";
|
|
25
|
+
toolExecution: "not-tested";
|
|
26
|
+
impactClassification: "heuristic";
|
|
27
|
+
};
|
|
28
|
+
changes: Array<{
|
|
29
|
+
kind: "added" | "removed" | "changed";
|
|
30
|
+
beforeToolIndex: number | null;
|
|
31
|
+
afterToolIndex: number | null;
|
|
32
|
+
fields: DiffField[];
|
|
33
|
+
impact: ToolChangeImpact;
|
|
34
|
+
reasons: ToolChangeReason[];
|
|
35
|
+
}>;
|
|
36
|
+
}
|
|
37
|
+
export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { classifyAddedTool, classifyChangedTool, classifyRemovedTool } from "./mcp-tool-impact.js";
|
|
2
|
+
import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
|
|
3
|
+
const FIXED_FIELDS = [
|
|
4
|
+
"inputSchema",
|
|
5
|
+
"outputSchema",
|
|
6
|
+
"description",
|
|
7
|
+
"title",
|
|
8
|
+
"annotations"
|
|
9
|
+
];
|
|
10
|
+
const EXCLUDED_OTHER_FIELDS = new Set(["name", ...FIXED_FIELDS]);
|
|
11
|
+
function isJsonObject(value) {
|
|
12
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
13
|
+
}
|
|
14
|
+
function structurallyEqual(left, right) {
|
|
15
|
+
const pending = [[left, right]];
|
|
16
|
+
while (pending.length > 0) {
|
|
17
|
+
const [currentLeft, currentRight] = pending.pop();
|
|
18
|
+
if (Object.is(currentLeft, currentRight))
|
|
19
|
+
continue;
|
|
20
|
+
if (Array.isArray(currentLeft) || Array.isArray(currentRight)) {
|
|
21
|
+
if (!Array.isArray(currentLeft) || !Array.isArray(currentRight) || currentLeft.length !== currentRight.length)
|
|
22
|
+
return false;
|
|
23
|
+
for (let index = 0; index < currentLeft.length; index += 1)
|
|
24
|
+
pending.push([currentLeft[index], currentRight[index]]);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!isJsonObject(currentLeft) || !isJsonObject(currentRight))
|
|
28
|
+
return false;
|
|
29
|
+
const leftKeys = Object.keys(currentLeft);
|
|
30
|
+
const rightKeys = Object.keys(currentRight);
|
|
31
|
+
if (leftKeys.length !== rightKeys.length)
|
|
32
|
+
return false;
|
|
33
|
+
for (const key of leftKeys) {
|
|
34
|
+
if (!Object.hasOwn(currentRight, key))
|
|
35
|
+
return false;
|
|
36
|
+
pending.push([currentLeft[key], currentRight[key]]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
function filteredOther(definition) {
|
|
42
|
+
const other = Object.create(null);
|
|
43
|
+
for (const key of Object.keys(definition)) {
|
|
44
|
+
if (!EXCLUDED_OTHER_FIELDS.has(key))
|
|
45
|
+
other[key] = definition[key];
|
|
46
|
+
}
|
|
47
|
+
return other;
|
|
48
|
+
}
|
|
49
|
+
function changedFields(before, after) {
|
|
50
|
+
const fields = [];
|
|
51
|
+
for (const field of FIXED_FIELDS) {
|
|
52
|
+
if (!structurallyEqual(before[field], after[field]))
|
|
53
|
+
fields.push(field);
|
|
54
|
+
}
|
|
55
|
+
if (!structurallyEqual(filteredOther(before), filteredOther(after)))
|
|
56
|
+
fields.push("other");
|
|
57
|
+
return fields;
|
|
58
|
+
}
|
|
59
|
+
function toolDefinitions(tools) {
|
|
60
|
+
const definitions = new Map();
|
|
61
|
+
for (let offset = 0; offset < tools.length; offset += 1) {
|
|
62
|
+
const value = tools[offset];
|
|
63
|
+
if (!isJsonObject(value) || typeof value.name !== "string" || definitions.has(value.name))
|
|
64
|
+
return null;
|
|
65
|
+
definitions.set(value.name, { index: offset + 1, value });
|
|
66
|
+
}
|
|
67
|
+
return definitions;
|
|
68
|
+
}
|
|
69
|
+
function incompleteReport(before, after, status, reason) {
|
|
70
|
+
return {
|
|
71
|
+
schemaVersion: 1,
|
|
72
|
+
scope: "tool-definitions-diff",
|
|
73
|
+
status,
|
|
74
|
+
before,
|
|
75
|
+
after,
|
|
76
|
+
comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
|
|
77
|
+
coverage: {
|
|
78
|
+
comparison: "structural-only",
|
|
79
|
+
schema: "root-shape-only",
|
|
80
|
+
compatibility: "not-tested",
|
|
81
|
+
serverCatalog: "not-tested",
|
|
82
|
+
toolExecution: "not-tested",
|
|
83
|
+
impactClassification: "heuristic"
|
|
84
|
+
},
|
|
85
|
+
changes: []
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
89
|
+
const [beforeSnapshot, afterSnapshot] = await Promise.all([
|
|
90
|
+
loadMcpToolFileSnapshot(beforePath),
|
|
91
|
+
loadMcpToolFileSnapshot(afterPath)
|
|
92
|
+
]);
|
|
93
|
+
const { report: before } = beforeSnapshot;
|
|
94
|
+
const { report: after } = afterSnapshot;
|
|
95
|
+
if (before.status === "blocked" || after.status === "blocked") {
|
|
96
|
+
return incompleteReport(before, after, "blocked", "input-blocked");
|
|
97
|
+
}
|
|
98
|
+
if (!before.inspection.complete || !after.inspection.complete) {
|
|
99
|
+
return incompleteReport(before, after, "incomplete", "input-incomplete");
|
|
100
|
+
}
|
|
101
|
+
if (before.status === "fail" || after.status === "fail" || beforeSnapshot.tools === null || afterSnapshot.tools === null) {
|
|
102
|
+
return incompleteReport(before, after, "incomplete", "invalid-definitions");
|
|
103
|
+
}
|
|
104
|
+
const beforeDefinitions = toolDefinitions(beforeSnapshot.tools);
|
|
105
|
+
const afterDefinitions = toolDefinitions(afterSnapshot.tools);
|
|
106
|
+
if (beforeDefinitions === null || afterDefinitions === null) {
|
|
107
|
+
return incompleteReport(before, after, "incomplete", "ambiguous-names");
|
|
108
|
+
}
|
|
109
|
+
const changes = [];
|
|
110
|
+
let added = 0;
|
|
111
|
+
let removed = 0;
|
|
112
|
+
let changed = 0;
|
|
113
|
+
let unchanged = 0;
|
|
114
|
+
for (const [name, beforeDefinition] of beforeDefinitions) {
|
|
115
|
+
const afterDefinition = afterDefinitions.get(name);
|
|
116
|
+
if (afterDefinition === undefined) {
|
|
117
|
+
removed += 1;
|
|
118
|
+
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
122
|
+
if (fields.length === 0) {
|
|
123
|
+
unchanged += 1;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
changed += 1;
|
|
127
|
+
changes.push({
|
|
128
|
+
kind: "changed",
|
|
129
|
+
beforeToolIndex: beforeDefinition.index,
|
|
130
|
+
afterToolIndex: afterDefinition.index,
|
|
131
|
+
fields,
|
|
132
|
+
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const [name, afterDefinition] of afterDefinitions) {
|
|
137
|
+
if (!beforeDefinitions.has(name)) {
|
|
138
|
+
added += 1;
|
|
139
|
+
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
scope: "tool-definitions-diff",
|
|
145
|
+
status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
|
|
146
|
+
before,
|
|
147
|
+
after,
|
|
148
|
+
comparison: {
|
|
149
|
+
complete: true,
|
|
150
|
+
reason: null,
|
|
151
|
+
added,
|
|
152
|
+
removed,
|
|
153
|
+
changed,
|
|
154
|
+
unchanged,
|
|
155
|
+
breaking: changes.filter((change) => change.impact === "breaking").length,
|
|
156
|
+
unclassified: changes.filter((change) => change.impact === "unclassified").length
|
|
157
|
+
},
|
|
158
|
+
coverage: {
|
|
159
|
+
comparison: "structural-only",
|
|
160
|
+
schema: "root-shape-only",
|
|
161
|
+
compatibility: "not-tested",
|
|
162
|
+
serverCatalog: "not-tested",
|
|
163
|
+
toolExecution: "not-tested",
|
|
164
|
+
impactClassification: "heuristic"
|
|
165
|
+
},
|
|
166
|
+
changes
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -24,4 +24,9 @@ export interface McpToolFileReport {
|
|
|
24
24
|
};
|
|
25
25
|
findings: CatalogFinding[];
|
|
26
26
|
}
|
|
27
|
+
export interface McpToolFileSnapshot {
|
|
28
|
+
report: McpToolFileReport;
|
|
29
|
+
tools: unknown[] | null;
|
|
30
|
+
}
|
|
31
|
+
export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
|
|
27
32
|
export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
|
|
@@ -4,6 +4,9 @@ import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
|
4
4
|
const MAX_FILE_BYTES = 1024 * 1024;
|
|
5
5
|
const MAX_TOOLS = 500;
|
|
6
6
|
const MAX_FINDINGS = 100;
|
|
7
|
+
function snapshot(report, tools = null) {
|
|
8
|
+
return { report, tools };
|
|
9
|
+
}
|
|
7
10
|
function isJsonObject(value) {
|
|
8
11
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
12
|
}
|
|
@@ -143,43 +146,43 @@ async function readBoundedRegularFile(filePath) {
|
|
|
143
146
|
await fileHandle?.close().catch(() => undefined);
|
|
144
147
|
}
|
|
145
148
|
}
|
|
146
|
-
export async function
|
|
149
|
+
export async function loadMcpToolFileSnapshot(filePath) {
|
|
147
150
|
if (isBlockedOfflinePath(filePath)) {
|
|
148
|
-
return blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path."));
|
|
151
|
+
return snapshot(blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path.")));
|
|
149
152
|
}
|
|
150
153
|
const file = await readBoundedRegularFile(filePath);
|
|
151
154
|
if (file.kind === "unavailable") {
|
|
152
|
-
return incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file."));
|
|
155
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file.")));
|
|
153
156
|
}
|
|
154
157
|
if (file.kind === "too-large") {
|
|
155
|
-
return incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB."));
|
|
158
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB.")));
|
|
156
159
|
}
|
|
157
160
|
if (file.kind === "changed") {
|
|
158
|
-
return incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable."));
|
|
161
|
+
return snapshot(incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable.")));
|
|
159
162
|
}
|
|
160
163
|
if (file.kind === "symlink") {
|
|
161
|
-
return blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly."));
|
|
164
|
+
return snapshot(blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly.")));
|
|
162
165
|
}
|
|
163
166
|
if (file.kind === "not-regular") {
|
|
164
|
-
return blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly."));
|
|
167
|
+
return snapshot(blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly.")));
|
|
165
168
|
}
|
|
166
169
|
let decoded;
|
|
167
170
|
try {
|
|
168
171
|
decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
|
|
169
172
|
}
|
|
170
173
|
catch {
|
|
171
|
-
return incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON."));
|
|
174
|
+
return snapshot(incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON.")));
|
|
172
175
|
}
|
|
173
176
|
let value;
|
|
174
177
|
try {
|
|
175
178
|
value = JSON.parse(decoded);
|
|
176
179
|
}
|
|
177
180
|
catch {
|
|
178
|
-
return incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response."));
|
|
181
|
+
return snapshot(incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response.")));
|
|
179
182
|
}
|
|
180
183
|
const parsed = parseToolsResult(value);
|
|
181
184
|
if (parsed === null) {
|
|
182
|
-
return incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata."));
|
|
185
|
+
return snapshot(incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata.")));
|
|
183
186
|
}
|
|
184
187
|
const findings = [];
|
|
185
188
|
const names = new Map();
|
|
@@ -225,17 +228,20 @@ export async function inspectMcpToolFile(filePath) {
|
|
|
225
228
|
if (reason === null && parsed.hasNextCursor)
|
|
226
229
|
reason = "more-pages-present";
|
|
227
230
|
if (reason !== null) {
|
|
228
|
-
return report(file.bytesRead, "incomplete", {
|
|
231
|
+
return snapshot(report(file.bytesRead, "incomplete", {
|
|
229
232
|
complete: false,
|
|
230
233
|
toolsChecked,
|
|
231
234
|
hasNextCursor: parsed.hasNextCursor,
|
|
232
235
|
reason
|
|
233
|
-
}, findings);
|
|
236
|
+
}, findings), parsed.tools);
|
|
234
237
|
}
|
|
235
|
-
return report(file.bytesRead, finalStatus(findings), {
|
|
238
|
+
return snapshot(report(file.bytesRead, finalStatus(findings), {
|
|
236
239
|
complete: true,
|
|
237
240
|
toolsChecked,
|
|
238
241
|
hasNextCursor: false,
|
|
239
242
|
reason: null
|
|
240
|
-
}, findings);
|
|
243
|
+
}, findings), parsed.tools);
|
|
244
|
+
}
|
|
245
|
+
export async function inspectMcpToolFile(filePath) {
|
|
246
|
+
return (await loadMcpToolFileSnapshot(filePath)).report;
|
|
241
247
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
|
|
3
|
+
export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
|
|
4
|
+
export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
|
|
5
|
+
export interface ToolChangeClassification {
|
|
6
|
+
impact: ToolChangeImpact;
|
|
7
|
+
reasons: ToolChangeReason[];
|
|
8
|
+
}
|
|
9
|
+
type Equal = (left: unknown, right: unknown) => boolean;
|
|
10
|
+
export declare function classifyRemovedTool(): ToolChangeClassification;
|
|
11
|
+
export declare function classifyAddedTool(): ToolChangeClassification;
|
|
12
|
+
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal): ToolChangeClassification;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const TOOL_CHANGE_REASONS = [
|
|
2
|
+
"tool-removed",
|
|
3
|
+
"input-type-narrowed",
|
|
4
|
+
"input-required-added",
|
|
5
|
+
"input-property-removed",
|
|
6
|
+
"input-property-type-narrowed",
|
|
7
|
+
"input-property-enum-narrowed",
|
|
8
|
+
"input-additional-properties-closed",
|
|
9
|
+
"input-schema-unclassified",
|
|
10
|
+
"output-schema-removed",
|
|
11
|
+
"output-type-widened",
|
|
12
|
+
"output-property-removed",
|
|
13
|
+
"output-guarantee-removed",
|
|
14
|
+
"output-property-type-widened",
|
|
15
|
+
"output-property-enum-widened",
|
|
16
|
+
"output-schema-unclassified",
|
|
17
|
+
"annotation-safety-reduced",
|
|
18
|
+
"other-fields-unclassified"
|
|
19
|
+
];
|
|
20
|
+
const UNCLASSIFIED_REASONS = new Set([
|
|
21
|
+
"input-schema-unclassified",
|
|
22
|
+
"output-schema-unclassified",
|
|
23
|
+
"other-fields-unclassified"
|
|
24
|
+
]);
|
|
25
|
+
// Keywords that document a schema without constraining accepted or produced values.
|
|
26
|
+
const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
|
|
27
|
+
const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
|
|
28
|
+
const PROPERTY_STRUCTURAL_KEYWORDS = new Set(["type", "enum"]);
|
|
29
|
+
// MCP tool annotation defaults, oriented so `true` is the safer reading.
|
|
30
|
+
const SAFETY_HINTS = [
|
|
31
|
+
{ key: "readOnlyHint", absent: false, safe: true },
|
|
32
|
+
{ key: "destructiveHint", absent: true, safe: false },
|
|
33
|
+
{ key: "idempotentHint", absent: false, safe: true },
|
|
34
|
+
{ key: "openWorldHint", absent: true, safe: false }
|
|
35
|
+
];
|
|
36
|
+
function isJsonObject(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
/** Returns the set of declared JSON types, or null when the schema does not restrict type. */
|
|
40
|
+
function typeSet(schema) {
|
|
41
|
+
const declared = schema.type;
|
|
42
|
+
if (typeof declared === "string")
|
|
43
|
+
return new Set([declared]);
|
|
44
|
+
if (Array.isArray(declared) && declared.every((entry) => typeof entry === "string"))
|
|
45
|
+
return new Set(declared);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** True when every value admitted by `inner` is also admitted by `outer`. */
|
|
49
|
+
function typesCovered(inner, outer) {
|
|
50
|
+
if (outer === null)
|
|
51
|
+
return true;
|
|
52
|
+
if (inner === null)
|
|
53
|
+
return false;
|
|
54
|
+
for (const type of inner) {
|
|
55
|
+
if (outer.has(type))
|
|
56
|
+
continue;
|
|
57
|
+
if (type === "integer" && outer.has("number"))
|
|
58
|
+
continue;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function enumCovered(inner, outer, equal) {
|
|
64
|
+
if (outer === undefined)
|
|
65
|
+
return true;
|
|
66
|
+
if (!Array.isArray(outer))
|
|
67
|
+
return null;
|
|
68
|
+
if (inner === undefined)
|
|
69
|
+
return false;
|
|
70
|
+
if (!Array.isArray(inner))
|
|
71
|
+
return null;
|
|
72
|
+
return inner.every((value) => outer.some((candidate) => equal(value, candidate)));
|
|
73
|
+
}
|
|
74
|
+
function stringSet(value) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return new Set();
|
|
77
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))
|
|
78
|
+
return null;
|
|
79
|
+
return new Set(value);
|
|
80
|
+
}
|
|
81
|
+
function objectMap(value) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return {};
|
|
84
|
+
return isJsonObject(value) ? value : null;
|
|
85
|
+
}
|
|
86
|
+
function keywordsOutside(schema, allowed) {
|
|
87
|
+
const rest = Object.create(null);
|
|
88
|
+
for (const key of Object.keys(schema)) {
|
|
89
|
+
if (!allowed.has(key) && !DOCUMENTATION_KEYWORDS.has(key))
|
|
90
|
+
rest[key] = schema[key];
|
|
91
|
+
}
|
|
92
|
+
return rest;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compares one property subschema. `direction` is "input" when callers must keep being
|
|
96
|
+
* accepted (after must cover before) and "output" when consumers must keep understanding
|
|
97
|
+
* results (before must cover after).
|
|
98
|
+
*/
|
|
99
|
+
function compareProperty(before, after, direction, equal, reasons) {
|
|
100
|
+
if (equal(before, after))
|
|
101
|
+
return;
|
|
102
|
+
const unclassified = direction === "input" ? "input-schema-unclassified" : "output-schema-unclassified";
|
|
103
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
104
|
+
|| !equal(keywordsOutside(before, PROPERTY_STRUCTURAL_KEYWORDS), keywordsOutside(after, PROPERTY_STRUCTURAL_KEYWORDS))) {
|
|
105
|
+
reasons.add(unclassified);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const [inner, outer] = direction === "input" ? [before, after] : [after, before];
|
|
109
|
+
if (!typesCovered(typeSet(inner), typeSet(outer))) {
|
|
110
|
+
reasons.add(direction === "input" ? "input-property-type-narrowed" : "output-property-type-widened");
|
|
111
|
+
}
|
|
112
|
+
const enumResult = enumCovered(inner.enum, outer.enum, equal);
|
|
113
|
+
if (enumResult === null)
|
|
114
|
+
reasons.add(unclassified);
|
|
115
|
+
else if (!enumResult)
|
|
116
|
+
reasons.add(direction === "input" ? "input-property-enum-narrowed" : "output-property-enum-widened");
|
|
117
|
+
}
|
|
118
|
+
function classifyInputSchema(before, after, equal, reasons) {
|
|
119
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
120
|
+
|| !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
|
|
121
|
+
|| !equal(before.$schema, after.$schema)) {
|
|
122
|
+
reasons.add("input-schema-unclassified");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!typesCovered(typeSet(before), typeSet(after)))
|
|
126
|
+
reasons.add("input-type-narrowed");
|
|
127
|
+
const beforeRequired = stringSet(before.required);
|
|
128
|
+
const afterRequired = stringSet(after.required);
|
|
129
|
+
const beforeProperties = objectMap(before.properties);
|
|
130
|
+
const afterProperties = objectMap(after.properties);
|
|
131
|
+
if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
|
|
132
|
+
reasons.add("input-schema-unclassified");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const key of afterRequired) {
|
|
136
|
+
if (!beforeRequired.has(key))
|
|
137
|
+
reasons.add("input-required-added");
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(beforeProperties)) {
|
|
140
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
141
|
+
reasons.add("input-property-removed");
|
|
142
|
+
else
|
|
143
|
+
compareProperty(beforeProperties[key], afterProperties[key], "input", equal, reasons);
|
|
144
|
+
}
|
|
145
|
+
if (!equal(before.additionalProperties, after.additionalProperties)) {
|
|
146
|
+
if (after.additionalProperties === false)
|
|
147
|
+
reasons.add("input-additional-properties-closed");
|
|
148
|
+
else if (!(after.additionalProperties === undefined || after.additionalProperties === true))
|
|
149
|
+
reasons.add("input-schema-unclassified");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function classifyOutputSchema(before, after, equal, reasons) {
|
|
153
|
+
if (before === undefined)
|
|
154
|
+
return;
|
|
155
|
+
if (after === undefined) {
|
|
156
|
+
reasons.add("output-schema-removed");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
160
|
+
|| !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
|
|
161
|
+
|| !equal(before.$schema, after.$schema)) {
|
|
162
|
+
reasons.add("output-schema-unclassified");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!typesCovered(typeSet(after), typeSet(before)))
|
|
166
|
+
reasons.add("output-type-widened");
|
|
167
|
+
const beforeRequired = stringSet(before.required);
|
|
168
|
+
const afterRequired = stringSet(after.required);
|
|
169
|
+
const beforeProperties = objectMap(before.properties);
|
|
170
|
+
const afterProperties = objectMap(after.properties);
|
|
171
|
+
if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
|
|
172
|
+
reasons.add("output-schema-unclassified");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const key of beforeRequired) {
|
|
176
|
+
if (!afterRequired.has(key))
|
|
177
|
+
reasons.add("output-guarantee-removed");
|
|
178
|
+
}
|
|
179
|
+
for (const key of Object.keys(beforeProperties)) {
|
|
180
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
181
|
+
reasons.add("output-property-removed");
|
|
182
|
+
else
|
|
183
|
+
compareProperty(beforeProperties[key], afterProperties[key], "output", equal, reasons);
|
|
184
|
+
}
|
|
185
|
+
if (!equal(before.additionalProperties, after.additionalProperties))
|
|
186
|
+
reasons.add("output-schema-unclassified");
|
|
187
|
+
}
|
|
188
|
+
function hint(annotations, key, absent) {
|
|
189
|
+
if (annotations === undefined)
|
|
190
|
+
return absent;
|
|
191
|
+
if (!isJsonObject(annotations))
|
|
192
|
+
return null;
|
|
193
|
+
const value = annotations[key];
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
return absent;
|
|
196
|
+
return typeof value === "boolean" ? value : null;
|
|
197
|
+
}
|
|
198
|
+
function classifyAnnotations(before, after, reasons) {
|
|
199
|
+
for (const { key, absent, safe } of SAFETY_HINTS) {
|
|
200
|
+
const beforeValue = hint(before, key, absent);
|
|
201
|
+
const afterValue = hint(after, key, absent);
|
|
202
|
+
if (beforeValue === safe && afterValue !== safe)
|
|
203
|
+
reasons.add("annotation-safety-reduced");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function result(reasons) {
|
|
207
|
+
const ordered = TOOL_CHANGE_REASONS.filter((reason) => reasons.has(reason));
|
|
208
|
+
const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
|
|
209
|
+
? "breaking"
|
|
210
|
+
: ordered.length > 0 ? "unclassified" : "compatible";
|
|
211
|
+
return { impact, reasons: ordered };
|
|
212
|
+
}
|
|
213
|
+
export function classifyRemovedTool() {
|
|
214
|
+
return { impact: "breaking", reasons: ["tool-removed"] };
|
|
215
|
+
}
|
|
216
|
+
export function classifyAddedTool() {
|
|
217
|
+
return { impact: "compatible", reasons: [] };
|
|
218
|
+
}
|
|
219
|
+
export function classifyChangedTool(before, after, fields, equal) {
|
|
220
|
+
const reasons = new Set();
|
|
221
|
+
if (fields.includes("inputSchema"))
|
|
222
|
+
classifyInputSchema(before.inputSchema, after.inputSchema, equal, reasons);
|
|
223
|
+
if (fields.includes("outputSchema"))
|
|
224
|
+
classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
|
|
225
|
+
if (fields.includes("annotations"))
|
|
226
|
+
classifyAnnotations(before.annotations, after.annotations, reasons);
|
|
227
|
+
if (fields.includes("other"))
|
|
228
|
+
reasons.add("other-fields-unclassified");
|
|
229
|
+
return result(reasons);
|
|
230
|
+
}
|