codex-plugin-doctor 1.64.0 → 1.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +11 -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-diff.d.ts +6 -0
- package/dist/core/mcp-tool-diff.js +25 -7
- 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 +13 -0
- package/dist/core/mcp-tool-impact.js +230 -0
- package/dist/core/mcp-tool-response-file.d.ts +10 -0
- package/dist/core/mcp-tool-response-file.js +52 -0
- package/dist/core/output-contract.js +11 -6
- package/dist/reporting/render-mcp-tool-diff-report.js +6 -2
- package/dist/run-cli.d.ts +2 -1
- package/dist/run-cli.js +76 -15
- 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
|
@@ -118,8 +118,11 @@ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-
|
|
|
118
118
|
```
|
|
119
119
|
|
|
120
120
|
This reports added, removed, and structurally changed definitions using numeric
|
|
121
|
-
locations
|
|
122
|
-
|
|
121
|
+
locations, and labels each change `breaking`, `compatible`, or `unclassified`
|
|
122
|
+
with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
|
|
123
|
+
unclassified changes. Incomplete inputs, invalid definitions, or duplicate names
|
|
124
|
+
prevent a comparison. Impact labels are root-level schema heuristics, not proof
|
|
125
|
+
of compatibility.
|
|
123
126
|
See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
|
|
124
127
|
privacy, limits, and exit codes.
|
|
125
128
|
|
|
@@ -135,6 +138,10 @@ This opt-in command performs discovery and bounded `tools/list` pagination. It
|
|
|
135
138
|
reports tool-definition and root-schema shape findings, explicit completion state,
|
|
136
139
|
and partial inspection limits. It does not claim full schema validation, tool
|
|
137
140
|
execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
|
|
141
|
+
Add `--save-response <path>` to record a completely enumerated catalog as a
|
|
142
|
+
`tools/list` response for `tools-file` and `tools-diff`; nothing is written when
|
|
143
|
+
enumeration is incomplete. The GitHub Action can compare saved responses with
|
|
144
|
+
`tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on`.
|
|
138
145
|
See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
139
146
|
|
|
140
147
|
### Remote MCP Readiness
|
|
@@ -555,9 +562,9 @@ jobs:
|
|
|
555
562
|
runs-on: ubuntu-latest
|
|
556
563
|
steps:
|
|
557
564
|
- uses: actions/checkout@v5
|
|
558
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
565
|
+
- uses: Esquetta/CodexPluginDoctor@v1.66.0
|
|
559
566
|
with:
|
|
560
|
-
version: "1.
|
|
567
|
+
version: "1.66.0"
|
|
561
568
|
path: .
|
|
562
569
|
runtime: "true"
|
|
563
570
|
policy: codex-publish
|
|
@@ -32,5 +32,15 @@ export interface McpToolCatalogReport {
|
|
|
32
32
|
};
|
|
33
33
|
findings: CatalogFinding[];
|
|
34
34
|
}
|
|
35
|
+
export interface McpToolCatalogCache {
|
|
36
|
+
ttlMs: number;
|
|
37
|
+
cacheScope: "public" | "private";
|
|
38
|
+
}
|
|
39
|
+
export interface McpToolCatalogSnapshot {
|
|
40
|
+
report: McpToolCatalogReport;
|
|
41
|
+
tools: unknown[] | null;
|
|
42
|
+
cache: McpToolCatalogCache | null;
|
|
43
|
+
}
|
|
35
44
|
export declare function inspectMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogReport>;
|
|
45
|
+
export declare function captureMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
|
|
36
46
|
export {};
|
|
@@ -39,7 +39,11 @@ function parseToolsResult(message, requestId) {
|
|
|
39
39
|
if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string") {
|
|
40
40
|
return null;
|
|
41
41
|
}
|
|
42
|
-
return {
|
|
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,3 +1,4 @@
|
|
|
1
|
+
import { type ToolChangeImpact, type ToolChangeReason } from "./mcp-tool-impact.js";
|
|
1
2
|
import { type McpToolFileReport } from "./mcp-tool-file.js";
|
|
2
3
|
type DiffField = "inputSchema" | "outputSchema" | "description" | "title" | "annotations" | "other";
|
|
3
4
|
export interface McpToolDiffReport {
|
|
@@ -13,6 +14,8 @@ export interface McpToolDiffReport {
|
|
|
13
14
|
removed: number | null;
|
|
14
15
|
changed: number | null;
|
|
15
16
|
unchanged: number | null;
|
|
17
|
+
breaking: number | null;
|
|
18
|
+
unclassified: number | null;
|
|
16
19
|
};
|
|
17
20
|
coverage: {
|
|
18
21
|
comparison: "structural-only";
|
|
@@ -20,12 +23,15 @@ export interface McpToolDiffReport {
|
|
|
20
23
|
compatibility: "not-tested";
|
|
21
24
|
serverCatalog: "not-tested";
|
|
22
25
|
toolExecution: "not-tested";
|
|
26
|
+
impactClassification: "heuristic";
|
|
23
27
|
};
|
|
24
28
|
changes: Array<{
|
|
25
29
|
kind: "added" | "removed" | "changed";
|
|
26
30
|
beforeToolIndex: number | null;
|
|
27
31
|
afterToolIndex: number | null;
|
|
28
32
|
fields: DiffField[];
|
|
33
|
+
impact: ToolChangeImpact;
|
|
34
|
+
reasons: ToolChangeReason[];
|
|
29
35
|
}>;
|
|
30
36
|
}
|
|
31
37
|
export declare function compareMcpToolFiles(beforePath: string, afterPath: string): Promise<McpToolDiffReport>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classifyAddedTool, classifyChangedTool, classifyRemovedTool } from "./mcp-tool-impact.js";
|
|
1
2
|
import { loadMcpToolFileSnapshot } from "./mcp-tool-file.js";
|
|
2
3
|
const FIXED_FIELDS = [
|
|
3
4
|
"inputSchema",
|
|
@@ -72,13 +73,14 @@ function incompleteReport(before, after, status, reason) {
|
|
|
72
73
|
status,
|
|
73
74
|
before,
|
|
74
75
|
after,
|
|
75
|
-
comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null },
|
|
76
|
+
comparison: { complete: false, reason, added: null, removed: null, changed: null, unchanged: null, breaking: null, unclassified: null },
|
|
76
77
|
coverage: {
|
|
77
78
|
comparison: "structural-only",
|
|
78
79
|
schema: "root-shape-only",
|
|
79
80
|
compatibility: "not-tested",
|
|
80
81
|
serverCatalog: "not-tested",
|
|
81
|
-
toolExecution: "not-tested"
|
|
82
|
+
toolExecution: "not-tested",
|
|
83
|
+
impactClassification: "heuristic"
|
|
82
84
|
},
|
|
83
85
|
changes: []
|
|
84
86
|
};
|
|
@@ -113,7 +115,7 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
113
115
|
const afterDefinition = afterDefinitions.get(name);
|
|
114
116
|
if (afterDefinition === undefined) {
|
|
115
117
|
removed += 1;
|
|
116
|
-
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [] });
|
|
118
|
+
changes.push({ kind: "removed", beforeToolIndex: beforeDefinition.index, afterToolIndex: null, fields: [], ...classifyRemovedTool() });
|
|
117
119
|
continue;
|
|
118
120
|
}
|
|
119
121
|
const fields = changedFields(beforeDefinition.value, afterDefinition.value);
|
|
@@ -122,13 +124,19 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
122
124
|
}
|
|
123
125
|
else {
|
|
124
126
|
changed += 1;
|
|
125
|
-
changes.push({
|
|
127
|
+
changes.push({
|
|
128
|
+
kind: "changed",
|
|
129
|
+
beforeToolIndex: beforeDefinition.index,
|
|
130
|
+
afterToolIndex: afterDefinition.index,
|
|
131
|
+
fields,
|
|
132
|
+
...classifyChangedTool(beforeDefinition.value, afterDefinition.value, fields, structurallyEqual)
|
|
133
|
+
});
|
|
126
134
|
}
|
|
127
135
|
}
|
|
128
136
|
for (const [name, afterDefinition] of afterDefinitions) {
|
|
129
137
|
if (!beforeDefinitions.has(name)) {
|
|
130
138
|
added += 1;
|
|
131
|
-
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [] });
|
|
139
|
+
changes.push({ kind: "added", beforeToolIndex: null, afterToolIndex: afterDefinition.index, fields: [], ...classifyAddedTool() });
|
|
132
140
|
}
|
|
133
141
|
}
|
|
134
142
|
return {
|
|
@@ -137,13 +145,23 @@ export async function compareMcpToolFiles(beforePath, afterPath) {
|
|
|
137
145
|
status: changes.length === 0 && before.status === "pass" && after.status === "pass" ? "pass" : "warn",
|
|
138
146
|
before,
|
|
139
147
|
after,
|
|
140
|
-
comparison: {
|
|
148
|
+
comparison: {
|
|
149
|
+
complete: true,
|
|
150
|
+
reason: null,
|
|
151
|
+
added,
|
|
152
|
+
removed,
|
|
153
|
+
changed,
|
|
154
|
+
unchanged,
|
|
155
|
+
breaking: changes.filter((change) => change.impact === "breaking").length,
|
|
156
|
+
unclassified: changes.filter((change) => change.impact === "unclassified").length
|
|
157
|
+
},
|
|
141
158
|
coverage: {
|
|
142
159
|
comparison: "structural-only",
|
|
143
160
|
schema: "root-shape-only",
|
|
144
161
|
compatibility: "not-tested",
|
|
145
162
|
serverCatalog: "not-tested",
|
|
146
|
-
toolExecution: "not-tested"
|
|
163
|
+
toolExecution: "not-tested",
|
|
164
|
+
impactClassification: "heuristic"
|
|
147
165
|
},
|
|
148
166
|
changes
|
|
149
167
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { CatalogFinding } from "./mcp-tool-definition.js";
|
|
2
|
+
export declare const MAX_TOOL_FILE_BYTES: number;
|
|
2
3
|
export interface McpToolFileReport {
|
|
3
4
|
schemaVersion: 1;
|
|
4
5
|
scope: "tool-definitions-file";
|
|
@@ -28,5 +29,6 @@ export interface McpToolFileSnapshot {
|
|
|
28
29
|
report: McpToolFileReport;
|
|
29
30
|
tools: unknown[] | null;
|
|
30
31
|
}
|
|
32
|
+
export declare function isBlockedOfflinePath(filePath: string): boolean;
|
|
31
33
|
export declare function loadMcpToolFileSnapshot(filePath: string): Promise<McpToolFileSnapshot>;
|
|
32
34
|
export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, open } from "node:fs/promises";
|
|
3
3
|
import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
4
|
-
const
|
|
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);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type JsonObject = Record<string, unknown>;
|
|
2
|
+
export type ToolChangeImpact = "breaking" | "compatible" | "unclassified";
|
|
3
|
+
export declare const TOOL_CHANGE_REASONS: readonly ["tool-removed", "input-type-narrowed", "input-required-added", "input-property-removed", "input-property-type-narrowed", "input-property-enum-narrowed", "input-additional-properties-closed", "input-schema-unclassified", "output-schema-removed", "output-type-widened", "output-property-removed", "output-guarantee-removed", "output-property-type-widened", "output-property-enum-widened", "output-schema-unclassified", "annotation-safety-reduced", "other-fields-unclassified"];
|
|
4
|
+
export type ToolChangeReason = (typeof TOOL_CHANGE_REASONS)[number];
|
|
5
|
+
export interface ToolChangeClassification {
|
|
6
|
+
impact: ToolChangeImpact;
|
|
7
|
+
reasons: ToolChangeReason[];
|
|
8
|
+
}
|
|
9
|
+
type Equal = (left: unknown, right: unknown) => boolean;
|
|
10
|
+
export declare function classifyRemovedTool(): ToolChangeClassification;
|
|
11
|
+
export declare function classifyAddedTool(): ToolChangeClassification;
|
|
12
|
+
export declare function classifyChangedTool(before: JsonObject, after: JsonObject, fields: readonly string[], equal: Equal): ToolChangeClassification;
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
export const TOOL_CHANGE_REASONS = [
|
|
2
|
+
"tool-removed",
|
|
3
|
+
"input-type-narrowed",
|
|
4
|
+
"input-required-added",
|
|
5
|
+
"input-property-removed",
|
|
6
|
+
"input-property-type-narrowed",
|
|
7
|
+
"input-property-enum-narrowed",
|
|
8
|
+
"input-additional-properties-closed",
|
|
9
|
+
"input-schema-unclassified",
|
|
10
|
+
"output-schema-removed",
|
|
11
|
+
"output-type-widened",
|
|
12
|
+
"output-property-removed",
|
|
13
|
+
"output-guarantee-removed",
|
|
14
|
+
"output-property-type-widened",
|
|
15
|
+
"output-property-enum-widened",
|
|
16
|
+
"output-schema-unclassified",
|
|
17
|
+
"annotation-safety-reduced",
|
|
18
|
+
"other-fields-unclassified"
|
|
19
|
+
];
|
|
20
|
+
const UNCLASSIFIED_REASONS = new Set([
|
|
21
|
+
"input-schema-unclassified",
|
|
22
|
+
"output-schema-unclassified",
|
|
23
|
+
"other-fields-unclassified"
|
|
24
|
+
]);
|
|
25
|
+
// Keywords that document a schema without constraining accepted or produced values.
|
|
26
|
+
const DOCUMENTATION_KEYWORDS = new Set(["title", "description", "examples", "default", "deprecated", "$comment"]);
|
|
27
|
+
const ROOT_STRUCTURAL_KEYWORDS = new Set(["type", "properties", "required", "additionalProperties", "$schema"]);
|
|
28
|
+
const PROPERTY_STRUCTURAL_KEYWORDS = new Set(["type", "enum"]);
|
|
29
|
+
// MCP tool annotation defaults, oriented so `true` is the safer reading.
|
|
30
|
+
const SAFETY_HINTS = [
|
|
31
|
+
{ key: "readOnlyHint", absent: false, safe: true },
|
|
32
|
+
{ key: "destructiveHint", absent: true, safe: false },
|
|
33
|
+
{ key: "idempotentHint", absent: false, safe: true },
|
|
34
|
+
{ key: "openWorldHint", absent: true, safe: false }
|
|
35
|
+
];
|
|
36
|
+
function isJsonObject(value) {
|
|
37
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
/** Returns the set of declared JSON types, or null when the schema does not restrict type. */
|
|
40
|
+
function typeSet(schema) {
|
|
41
|
+
const declared = schema.type;
|
|
42
|
+
if (typeof declared === "string")
|
|
43
|
+
return new Set([declared]);
|
|
44
|
+
if (Array.isArray(declared) && declared.every((entry) => typeof entry === "string"))
|
|
45
|
+
return new Set(declared);
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/** True when every value admitted by `inner` is also admitted by `outer`. */
|
|
49
|
+
function typesCovered(inner, outer) {
|
|
50
|
+
if (outer === null)
|
|
51
|
+
return true;
|
|
52
|
+
if (inner === null)
|
|
53
|
+
return false;
|
|
54
|
+
for (const type of inner) {
|
|
55
|
+
if (outer.has(type))
|
|
56
|
+
continue;
|
|
57
|
+
if (type === "integer" && outer.has("number"))
|
|
58
|
+
continue;
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
function enumCovered(inner, outer, equal) {
|
|
64
|
+
if (outer === undefined)
|
|
65
|
+
return true;
|
|
66
|
+
if (!Array.isArray(outer))
|
|
67
|
+
return null;
|
|
68
|
+
if (inner === undefined)
|
|
69
|
+
return false;
|
|
70
|
+
if (!Array.isArray(inner))
|
|
71
|
+
return null;
|
|
72
|
+
return inner.every((value) => outer.some((candidate) => equal(value, candidate)));
|
|
73
|
+
}
|
|
74
|
+
function stringSet(value) {
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
return new Set();
|
|
77
|
+
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string"))
|
|
78
|
+
return null;
|
|
79
|
+
return new Set(value);
|
|
80
|
+
}
|
|
81
|
+
function objectMap(value) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return {};
|
|
84
|
+
return isJsonObject(value) ? value : null;
|
|
85
|
+
}
|
|
86
|
+
function keywordsOutside(schema, allowed) {
|
|
87
|
+
const rest = Object.create(null);
|
|
88
|
+
for (const key of Object.keys(schema)) {
|
|
89
|
+
if (!allowed.has(key) && !DOCUMENTATION_KEYWORDS.has(key))
|
|
90
|
+
rest[key] = schema[key];
|
|
91
|
+
}
|
|
92
|
+
return rest;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compares one property subschema. `direction` is "input" when callers must keep being
|
|
96
|
+
* accepted (after must cover before) and "output" when consumers must keep understanding
|
|
97
|
+
* results (before must cover after).
|
|
98
|
+
*/
|
|
99
|
+
function compareProperty(before, after, direction, equal, reasons) {
|
|
100
|
+
if (equal(before, after))
|
|
101
|
+
return;
|
|
102
|
+
const unclassified = direction === "input" ? "input-schema-unclassified" : "output-schema-unclassified";
|
|
103
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
104
|
+
|| !equal(keywordsOutside(before, PROPERTY_STRUCTURAL_KEYWORDS), keywordsOutside(after, PROPERTY_STRUCTURAL_KEYWORDS))) {
|
|
105
|
+
reasons.add(unclassified);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const [inner, outer] = direction === "input" ? [before, after] : [after, before];
|
|
109
|
+
if (!typesCovered(typeSet(inner), typeSet(outer))) {
|
|
110
|
+
reasons.add(direction === "input" ? "input-property-type-narrowed" : "output-property-type-widened");
|
|
111
|
+
}
|
|
112
|
+
const enumResult = enumCovered(inner.enum, outer.enum, equal);
|
|
113
|
+
if (enumResult === null)
|
|
114
|
+
reasons.add(unclassified);
|
|
115
|
+
else if (!enumResult)
|
|
116
|
+
reasons.add(direction === "input" ? "input-property-enum-narrowed" : "output-property-enum-widened");
|
|
117
|
+
}
|
|
118
|
+
function classifyInputSchema(before, after, equal, reasons) {
|
|
119
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
120
|
+
|| !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
|
|
121
|
+
|| !equal(before.$schema, after.$schema)) {
|
|
122
|
+
reasons.add("input-schema-unclassified");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (!typesCovered(typeSet(before), typeSet(after)))
|
|
126
|
+
reasons.add("input-type-narrowed");
|
|
127
|
+
const beforeRequired = stringSet(before.required);
|
|
128
|
+
const afterRequired = stringSet(after.required);
|
|
129
|
+
const beforeProperties = objectMap(before.properties);
|
|
130
|
+
const afterProperties = objectMap(after.properties);
|
|
131
|
+
if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
|
|
132
|
+
reasons.add("input-schema-unclassified");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
for (const key of afterRequired) {
|
|
136
|
+
if (!beforeRequired.has(key))
|
|
137
|
+
reasons.add("input-required-added");
|
|
138
|
+
}
|
|
139
|
+
for (const key of Object.keys(beforeProperties)) {
|
|
140
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
141
|
+
reasons.add("input-property-removed");
|
|
142
|
+
else
|
|
143
|
+
compareProperty(beforeProperties[key], afterProperties[key], "input", equal, reasons);
|
|
144
|
+
}
|
|
145
|
+
if (!equal(before.additionalProperties, after.additionalProperties)) {
|
|
146
|
+
if (after.additionalProperties === false)
|
|
147
|
+
reasons.add("input-additional-properties-closed");
|
|
148
|
+
else if (!(after.additionalProperties === undefined || after.additionalProperties === true))
|
|
149
|
+
reasons.add("input-schema-unclassified");
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
function classifyOutputSchema(before, after, equal, reasons) {
|
|
153
|
+
if (before === undefined)
|
|
154
|
+
return;
|
|
155
|
+
if (after === undefined) {
|
|
156
|
+
reasons.add("output-schema-removed");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (!isJsonObject(before) || !isJsonObject(after)
|
|
160
|
+
|| !equal(keywordsOutside(before, ROOT_STRUCTURAL_KEYWORDS), keywordsOutside(after, ROOT_STRUCTURAL_KEYWORDS))
|
|
161
|
+
|| !equal(before.$schema, after.$schema)) {
|
|
162
|
+
reasons.add("output-schema-unclassified");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!typesCovered(typeSet(after), typeSet(before)))
|
|
166
|
+
reasons.add("output-type-widened");
|
|
167
|
+
const beforeRequired = stringSet(before.required);
|
|
168
|
+
const afterRequired = stringSet(after.required);
|
|
169
|
+
const beforeProperties = objectMap(before.properties);
|
|
170
|
+
const afterProperties = objectMap(after.properties);
|
|
171
|
+
if (beforeRequired === null || afterRequired === null || beforeProperties === null || afterProperties === null) {
|
|
172
|
+
reasons.add("output-schema-unclassified");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const key of beforeRequired) {
|
|
176
|
+
if (!afterRequired.has(key))
|
|
177
|
+
reasons.add("output-guarantee-removed");
|
|
178
|
+
}
|
|
179
|
+
for (const key of Object.keys(beforeProperties)) {
|
|
180
|
+
if (!Object.hasOwn(afterProperties, key))
|
|
181
|
+
reasons.add("output-property-removed");
|
|
182
|
+
else
|
|
183
|
+
compareProperty(beforeProperties[key], afterProperties[key], "output", equal, reasons);
|
|
184
|
+
}
|
|
185
|
+
if (!equal(before.additionalProperties, after.additionalProperties))
|
|
186
|
+
reasons.add("output-schema-unclassified");
|
|
187
|
+
}
|
|
188
|
+
function hint(annotations, key, absent) {
|
|
189
|
+
if (annotations === undefined)
|
|
190
|
+
return absent;
|
|
191
|
+
if (!isJsonObject(annotations))
|
|
192
|
+
return null;
|
|
193
|
+
const value = annotations[key];
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
return absent;
|
|
196
|
+
return typeof value === "boolean" ? value : null;
|
|
197
|
+
}
|
|
198
|
+
function classifyAnnotations(before, after, reasons) {
|
|
199
|
+
for (const { key, absent, safe } of SAFETY_HINTS) {
|
|
200
|
+
const beforeValue = hint(before, key, absent);
|
|
201
|
+
const afterValue = hint(after, key, absent);
|
|
202
|
+
if (beforeValue === safe && afterValue !== safe)
|
|
203
|
+
reasons.add("annotation-safety-reduced");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
function result(reasons) {
|
|
207
|
+
const ordered = TOOL_CHANGE_REASONS.filter((reason) => reasons.has(reason));
|
|
208
|
+
const impact = ordered.some((reason) => !UNCLASSIFIED_REASONS.has(reason))
|
|
209
|
+
? "breaking"
|
|
210
|
+
: ordered.length > 0 ? "unclassified" : "compatible";
|
|
211
|
+
return { impact, reasons: ordered };
|
|
212
|
+
}
|
|
213
|
+
export function classifyRemovedTool() {
|
|
214
|
+
return { impact: "breaking", reasons: ["tool-removed"] };
|
|
215
|
+
}
|
|
216
|
+
export function classifyAddedTool() {
|
|
217
|
+
return { impact: "compatible", reasons: [] };
|
|
218
|
+
}
|
|
219
|
+
export function classifyChangedTool(before, after, fields, equal) {
|
|
220
|
+
const reasons = new Set();
|
|
221
|
+
if (fields.includes("inputSchema"))
|
|
222
|
+
classifyInputSchema(before.inputSchema, after.inputSchema, equal, reasons);
|
|
223
|
+
if (fields.includes("outputSchema"))
|
|
224
|
+
classifyOutputSchema(before.outputSchema, after.outputSchema, equal, reasons);
|
|
225
|
+
if (fields.includes("annotations"))
|
|
226
|
+
classifyAnnotations(before.annotations, after.annotations, reasons);
|
|
227
|
+
if (fields.includes("other"))
|
|
228
|
+
reasons.add("other-fields-unclassified");
|
|
229
|
+
return result(reasons);
|
|
230
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpToolCatalogCache } from "./mcp-tool-catalog.js";
|
|
2
|
+
export type McpToolResponseSaveResult = {
|
|
3
|
+
kind: "saved";
|
|
4
|
+
bytes: number;
|
|
5
|
+
tools: number;
|
|
6
|
+
} | {
|
|
7
|
+
kind: "path-blocked" | "not-regular" | "too-large" | "write-failed";
|
|
8
|
+
};
|
|
9
|
+
export declare function serializeMcpToolsListResponse(tools: unknown[], cache: McpToolCatalogCache): string;
|
|
10
|
+
export declare function saveMcpToolsListResponse(filePath: string, tools: unknown[], cache: McpToolCatalogCache): Promise<McpToolResponseSaveResult>;
|