codex-plugin-doctor 1.68.0 → 1.70.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 +24 -8
- package/dist/core/bounded-http-client.d.ts +5 -2
- package/dist/core/bounded-http-client.js +14 -0
- package/dist/core/mcp-discovery.d.ts +20 -0
- package/dist/core/mcp-discovery.js +31 -7
- package/dist/core/mcp-stdio-tool-catalog.d.ts +31 -0
- package/dist/core/mcp-stdio-tool-catalog.js +595 -0
- package/dist/core/mcp-tool-catalog.d.ts +37 -4
- package/dist/core/mcp-tool-catalog.js +103 -75
- package/dist/core/mcp-tool-impact.js +10 -6
- package/dist/core/output-contract.js +20 -0
- package/dist/core/runtime-probe.d.ts +3 -1
- package/dist/core/runtime-probe.js +2 -2
- package/dist/reporting/render-mcp-tool-catalog-report.js +7 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.js +33 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +182 -42
- package/package.json +1 -1
|
@@ -4,20 +4,20 @@ import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
|
4
4
|
import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
|
|
5
5
|
import { packageVersion } from "../version.js";
|
|
6
6
|
const REQUESTED_VERSION = "2026-07-28";
|
|
7
|
-
const
|
|
7
|
+
export const MAX_CATALOG_PAGES = 5;
|
|
8
8
|
const MAX_TOOLS = 500;
|
|
9
9
|
const MAX_FINDINGS = 100;
|
|
10
|
-
const
|
|
11
|
-
const
|
|
10
|
+
export const MAX_CATALOG_RESPONSE_BYTES = 1024 * 1024;
|
|
11
|
+
export const MAX_CATALOG_TOTAL_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
12
12
|
const REQUEST_TIMEOUT_MS = 3_000;
|
|
13
13
|
const TOTAL_TIMEOUT_MS = 15_000;
|
|
14
14
|
function isPlainObject(value) {
|
|
15
15
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
16
|
}
|
|
17
|
-
function
|
|
17
|
+
export function catalogFinding(id, severity, message, impact, suggestedFix) {
|
|
18
18
|
return { id, severity, message, impact, suggestedFix };
|
|
19
19
|
}
|
|
20
|
-
function
|
|
20
|
+
export function validJsonRpcError(value) {
|
|
21
21
|
return isPlainObject(value) && typeof value.code === "number" && Number.isFinite(value.code) && typeof value.message === "string";
|
|
22
22
|
}
|
|
23
23
|
function validCacheMetadata(value) {
|
|
@@ -25,11 +25,11 @@ function validCacheMetadata(value) {
|
|
|
25
25
|
&& typeof value.ttlMs === "number" && Number.isFinite(value.ttlMs) && value.ttlMs >= 0
|
|
26
26
|
&& (value.cacheScope === "public" || value.cacheScope === "private");
|
|
27
27
|
}
|
|
28
|
-
function hasMatchingEnvelope(message, requestId) {
|
|
28
|
+
export function hasMatchingEnvelope(message, requestId) {
|
|
29
29
|
return message.jsonrpc === "2.0" && message.id === requestId
|
|
30
30
|
&& Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
|
|
31
31
|
}
|
|
32
|
-
function parseToolsResult(message, requestId) {
|
|
32
|
+
export function parseToolsResult(message, requestId) {
|
|
33
33
|
if (!hasMatchingEnvelope(message, requestId)) {
|
|
34
34
|
return null;
|
|
35
35
|
}
|
|
@@ -45,7 +45,7 @@ function parseToolsResult(message, requestId) {
|
|
|
45
45
|
cache: { ttlMs: message.result.ttlMs, cacheScope: message.result.cacheScope }
|
|
46
46
|
};
|
|
47
47
|
}
|
|
48
|
-
function
|
|
48
|
+
export function buildMcpToolCatalogReport(discovery, status, catalog, catalogEnumeration, findings) {
|
|
49
49
|
return {
|
|
50
50
|
schemaVersion: 1,
|
|
51
51
|
scope: "tool-catalog-structure",
|
|
@@ -63,7 +63,7 @@ function catalogReport(discovery, status, catalog, catalogEnumeration, findings)
|
|
|
63
63
|
findings
|
|
64
64
|
};
|
|
65
65
|
}
|
|
66
|
-
function
|
|
66
|
+
export function finalCatalogStatus(findings) {
|
|
67
67
|
return findings.some((item) => item.severity === "fail")
|
|
68
68
|
? "fail"
|
|
69
69
|
: findings.some((item) => item.severity === "warn")
|
|
@@ -71,9 +71,60 @@ function finalStatus(findings) {
|
|
|
71
71
|
: "pass";
|
|
72
72
|
}
|
|
73
73
|
function isAuthorizationFinding(discovery) {
|
|
74
|
-
return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required");
|
|
74
|
+
return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required" || item.id === "plugin.discovery.authorization.rejected");
|
|
75
75
|
}
|
|
76
|
-
|
|
76
|
+
// Shared by the HTTP and stdio catalogs so both apply the same tool, finding, and duplicate-name rules.
|
|
77
|
+
export function createMcpToolCatalogInspector() {
|
|
78
|
+
const findings = [];
|
|
79
|
+
const tools = [];
|
|
80
|
+
const names = new Map();
|
|
81
|
+
let toolsChecked = 0;
|
|
82
|
+
const addFindings = (items) => {
|
|
83
|
+
if (findings.length + items.length > MAX_FINDINGS) {
|
|
84
|
+
findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
findings.push(...items);
|
|
88
|
+
return true;
|
|
89
|
+
};
|
|
90
|
+
return {
|
|
91
|
+
findings,
|
|
92
|
+
tools,
|
|
93
|
+
get toolsChecked() {
|
|
94
|
+
return toolsChecked;
|
|
95
|
+
},
|
|
96
|
+
addFindings,
|
|
97
|
+
inspectPage(pageTools, page, timeExpired) {
|
|
98
|
+
for (const tool of pageTools) {
|
|
99
|
+
if (toolsChecked >= MAX_TOOLS) {
|
|
100
|
+
return "tool-limit";
|
|
101
|
+
}
|
|
102
|
+
toolsChecked += 1;
|
|
103
|
+
tools.push(tool);
|
|
104
|
+
const location = { page, toolIndex: toolsChecked };
|
|
105
|
+
if (!addFindings(inspectMcpToolDefinition(tool, location))) {
|
|
106
|
+
return "finding-limit";
|
|
107
|
+
}
|
|
108
|
+
if (isPlainObject(tool) && typeof tool.name === "string") {
|
|
109
|
+
const previous = names.get(tool.name);
|
|
110
|
+
if (previous !== undefined && !addFindings([{
|
|
111
|
+
...catalogFinding("plugin.catalog.tool.name.duplicate", "warn", "Two enumerated tools use the same name.", "Duplicate tool names can make client selection ambiguous.", "Use a unique MCP tool name for each definition."),
|
|
112
|
+
location: { ...location, relatedToolIndex: previous }
|
|
113
|
+
}])) {
|
|
114
|
+
return "finding-limit";
|
|
115
|
+
}
|
|
116
|
+
if (previous === undefined)
|
|
117
|
+
names.set(tool.name, toolsChecked);
|
|
118
|
+
}
|
|
119
|
+
if (timeExpired()) {
|
|
120
|
+
return "total-time-limit";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function serializeToolsListRequest(id, cursor) {
|
|
77
128
|
return JSON.stringify({
|
|
78
129
|
jsonrpc: "2.0",
|
|
79
130
|
id,
|
|
@@ -100,7 +151,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
100
151
|
let budgetReason = null;
|
|
101
152
|
const requestWithinBudget = async (url, requestOptions = {}) => {
|
|
102
153
|
const remainingTime = deadline - now();
|
|
103
|
-
const remainingBytes =
|
|
154
|
+
const remainingBytes = MAX_CATALOG_TOTAL_RESPONSE_BYTES - receivedBytes;
|
|
104
155
|
const timeoutMs = Math.floor(Math.min(REQUEST_TIMEOUT_MS, remainingTime));
|
|
105
156
|
if (timeoutMs < 1) {
|
|
106
157
|
budgetReason = "total-time-limit";
|
|
@@ -110,7 +161,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
110
161
|
budgetReason = "total-response-byte-limit";
|
|
111
162
|
throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response byte budget exhausted.");
|
|
112
163
|
}
|
|
113
|
-
const maxResponseBytes = Math.min(
|
|
164
|
+
const maxResponseBytes = Math.min(MAX_CATALOG_RESPONSE_BYTES, remainingBytes);
|
|
114
165
|
const response = await request(url, {
|
|
115
166
|
...requestOptions,
|
|
116
167
|
timeoutMs,
|
|
@@ -118,7 +169,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
118
169
|
});
|
|
119
170
|
receivedBytes += response.body.length;
|
|
120
171
|
if (response.body.length > maxResponseBytes) {
|
|
121
|
-
budgetReason = receivedBytes >
|
|
172
|
+
budgetReason = receivedBytes > MAX_CATALOG_TOTAL_RESPONSE_BYTES ? "total-response-byte-limit" : "response-byte-limit";
|
|
122
173
|
throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response exceeded the byte budget.");
|
|
123
174
|
}
|
|
124
175
|
return response;
|
|
@@ -128,6 +179,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
128
179
|
allowNetwork: options.allowNetwork,
|
|
129
180
|
allowLocalNetwork: options.allowLocalNetwork,
|
|
130
181
|
lookup: options.lookup,
|
|
182
|
+
bearerToken: options.bearerToken,
|
|
131
183
|
request: async (url, requestOptions) => {
|
|
132
184
|
const response = await requestWithinBudget(url, requestOptions);
|
|
133
185
|
discoveryResponse = response;
|
|
@@ -137,45 +189,43 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
137
189
|
const partial = (report) => ({ report, tools: null, cache: null });
|
|
138
190
|
const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
|
|
139
191
|
if (discovery.status === "blocked") {
|
|
140
|
-
|
|
192
|
+
const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
|
|
193
|
+
? "credentials-insecure-transport"
|
|
194
|
+
: "network-blocked";
|
|
195
|
+
return partial(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
|
|
141
196
|
}
|
|
142
197
|
if (isAuthorizationFinding(discovery)) {
|
|
143
|
-
return partial(
|
|
198
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
|
|
144
199
|
}
|
|
145
200
|
if (discovery.status === "unsupported") {
|
|
146
|
-
return partial(
|
|
201
|
+
return partial(buildMcpToolCatalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
|
|
147
202
|
}
|
|
148
203
|
if (discovery.status !== "discovered") {
|
|
149
|
-
return partial(
|
|
204
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
|
|
150
205
|
}
|
|
151
206
|
const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
|
|
152
207
|
if (capabilities === null) {
|
|
153
|
-
return partial(
|
|
208
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
|
|
154
209
|
}
|
|
155
210
|
if (!Object.hasOwn(capabilities, "tools")) {
|
|
156
|
-
return partial(
|
|
211
|
+
return partial(buildMcpToolCatalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
|
|
157
212
|
}
|
|
158
|
-
const
|
|
159
|
-
const
|
|
213
|
+
const inspector = createMcpToolCatalogInspector();
|
|
214
|
+
const findings = inspector.findings;
|
|
160
215
|
const seenCursors = new Set();
|
|
161
216
|
let pagesRead = 0;
|
|
162
|
-
let toolsChecked = 0;
|
|
163
217
|
let cursor;
|
|
164
218
|
let incompleteReason = null;
|
|
165
219
|
let blocked = false;
|
|
166
|
-
const tools = [];
|
|
167
220
|
let cache = null;
|
|
168
221
|
const addFindings = (items) => {
|
|
169
|
-
if (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
174
|
-
findings.push(...items);
|
|
175
|
-
return true;
|
|
222
|
+
if (inspector.addFindings(items))
|
|
223
|
+
return true;
|
|
224
|
+
incompleteReason = "finding-limit";
|
|
225
|
+
return false;
|
|
176
226
|
};
|
|
177
227
|
while (incompleteReason === null && !blocked) {
|
|
178
|
-
if (pagesRead >=
|
|
228
|
+
if (pagesRead >= MAX_CATALOG_PAGES) {
|
|
179
229
|
incompleteReason = "page-limit";
|
|
180
230
|
break;
|
|
181
231
|
}
|
|
@@ -189,8 +239,9 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
189
239
|
response = await requestWithinBudget(rawUrl, {
|
|
190
240
|
allowLocalNetwork: options.allowLocalNetwork,
|
|
191
241
|
lookup: options.lookup,
|
|
242
|
+
bearerToken: options.bearerToken,
|
|
192
243
|
method: "POST",
|
|
193
|
-
body:
|
|
244
|
+
body: serializeToolsListRequest(requestId, cursor),
|
|
194
245
|
headers: {
|
|
195
246
|
Accept: "application/json, text/event-stream",
|
|
196
247
|
"Content-Type": "application/json",
|
|
@@ -205,11 +256,13 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
205
256
|
blocked = true;
|
|
206
257
|
}
|
|
207
258
|
else {
|
|
208
|
-
incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "
|
|
209
|
-
? "
|
|
210
|
-
: error instanceof BoundedHttpError && error.code === "
|
|
211
|
-
? "
|
|
212
|
-
: "
|
|
259
|
+
incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_INSECURE_CREDENTIALS"
|
|
260
|
+
? "credentials-insecure-transport"
|
|
261
|
+
: error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
|
|
262
|
+
? "request-timeout"
|
|
263
|
+
: error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
|
|
264
|
+
? "response-byte-limit"
|
|
265
|
+
: "request-failed");
|
|
213
266
|
}
|
|
214
267
|
break;
|
|
215
268
|
}
|
|
@@ -219,19 +272,19 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
219
272
|
}
|
|
220
273
|
const message = parseMcpResponse(response, requestId);
|
|
221
274
|
if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
|
|
222
|
-
&&
|
|
223
|
-
if (addFindings([
|
|
275
|
+
&& validJsonRpcError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
|
|
276
|
+
if (addFindings([catalogFinding("plugin.catalog.tools_list.method_unsupported", "warn", "The endpoint advertised tools but does not implement tools/list.", "Tool definitions cannot be enumerated from the advertised capability.", "Implement tools/list for the advertised modern MCP tools capability.")]))
|
|
224
277
|
incompleteReason = "tools-list-method-unsupported";
|
|
225
278
|
break;
|
|
226
279
|
}
|
|
227
280
|
if (response.statusCode !== 200) {
|
|
228
|
-
if (addFindings([
|
|
281
|
+
if (addFindings([catalogFinding("plugin.catalog.http_status.invalid", "fail", "The endpoint returned an unexpected tools/list HTTP status.", "The tool catalog cannot be safely enumerated from this response.", "Return HTTP 200 for a tools/list response after authorization succeeds.")]))
|
|
229
282
|
incompleteReason = "http-status-invalid";
|
|
230
283
|
break;
|
|
231
284
|
}
|
|
232
285
|
const page = message === null ? null : parseToolsResult(message, requestId);
|
|
233
286
|
if (page === null) {
|
|
234
|
-
if (addFindings([
|
|
287
|
+
if (addFindings([catalogFinding("plugin.catalog.response.invalid", "fail", "The endpoint returned an invalid tools/list response.", "The tool catalog cannot be safely enumerated from this response.", "Return a matching JSON-RPC complete result with valid tools and cache metadata.")]))
|
|
235
288
|
incompleteReason = "response-invalid";
|
|
236
289
|
break;
|
|
237
290
|
}
|
|
@@ -240,57 +293,32 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
240
293
|
ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
|
|
241
294
|
cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
|
|
242
295
|
};
|
|
243
|
-
|
|
244
|
-
if (toolsChecked >= MAX_TOOLS) {
|
|
245
|
-
incompleteReason = "tool-limit";
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
toolsChecked += 1;
|
|
249
|
-
tools.push(tool);
|
|
250
|
-
const location = { page: pagesRead, toolIndex: toolsChecked };
|
|
251
|
-
if (!addFindings(inspectMcpToolDefinition(tool, location))) {
|
|
252
|
-
break;
|
|
253
|
-
}
|
|
254
|
-
if (isPlainObject(tool) && typeof tool.name === "string") {
|
|
255
|
-
const previous = names.get(tool.name);
|
|
256
|
-
if (previous !== undefined && !addFindings([{
|
|
257
|
-
...finding("plugin.catalog.tool.name.duplicate", "warn", "Two enumerated tools use the same name.", "Duplicate tool names can make client selection ambiguous.", "Use a unique MCP tool name for each definition."),
|
|
258
|
-
location: { ...location, relatedToolIndex: previous }
|
|
259
|
-
}])) {
|
|
260
|
-
break;
|
|
261
|
-
}
|
|
262
|
-
if (previous === undefined)
|
|
263
|
-
names.set(tool.name, toolsChecked);
|
|
264
|
-
}
|
|
265
|
-
if (deadline - now() <= 0) {
|
|
266
|
-
incompleteReason = "total-time-limit";
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
}
|
|
296
|
+
incompleteReason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
|
|
270
297
|
if (incompleteReason !== null)
|
|
271
298
|
break;
|
|
272
299
|
if (page.cursor === undefined)
|
|
273
300
|
break;
|
|
274
301
|
if (seenCursors.has(page.cursor)) {
|
|
275
|
-
if (addFindings([
|
|
302
|
+
if (addFindings([catalogFinding("plugin.catalog.cursor.loop", "warn", "The endpoint repeated a tools/list cursor.", "The bounded catalog enumeration cannot determine a complete result set.", "Return each continuation cursor at most once for a single enumeration.")]))
|
|
276
303
|
incompleteReason = "cursor-loop";
|
|
277
304
|
break;
|
|
278
305
|
}
|
|
279
306
|
seenCursors.add(page.cursor);
|
|
280
307
|
cursor = page.cursor;
|
|
281
308
|
}
|
|
309
|
+
const toolsChecked = inspector.toolsChecked;
|
|
282
310
|
if (blocked) {
|
|
283
|
-
return partial(
|
|
311
|
+
return partial(buildMcpToolCatalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
|
|
284
312
|
}
|
|
285
313
|
if (incompleteReason !== null) {
|
|
286
|
-
return partial(
|
|
314
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
|
|
287
315
|
}
|
|
288
316
|
if (deadline - now() <= 0) {
|
|
289
|
-
return partial(
|
|
317
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
|
|
290
318
|
}
|
|
291
319
|
return {
|
|
292
|
-
report:
|
|
293
|
-
tools,
|
|
320
|
+
report: buildMcpToolCatalogReport(discovery, finalCatalogStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
|
|
321
|
+
tools: inspector.tools,
|
|
294
322
|
cache
|
|
295
323
|
};
|
|
296
324
|
}
|
|
@@ -34,6 +34,9 @@ const NESTED_STRUCTURAL_KEYWORDS = new Set([
|
|
|
34
34
|
]);
|
|
35
35
|
// Subschemas nested deeper than this below the root are not compared and stay unclassified.
|
|
36
36
|
const MAX_NESTED_DEPTH = 8;
|
|
37
|
+
// Keywords that only hold or name subschemas and never validate the object's own keys, so a
|
|
38
|
+
// new property beside them stays compatible. Any other unclassified keyword may govern keys.
|
|
39
|
+
const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
|
|
37
40
|
const CODES = {
|
|
38
41
|
input: {
|
|
39
42
|
unclassified: "input-schema-unclassified",
|
|
@@ -197,13 +200,14 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
|
|
|
197
200
|
else
|
|
198
201
|
compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
|
|
199
202
|
}
|
|
200
|
-
// In
|
|
201
|
-
// declared property can reject values callers send (input) or
|
|
202
|
-
// not expect (output).
|
|
203
|
+
// In an object whose undeclared keys were governed by other keywords, or a nested input object
|
|
204
|
+
// that left them open, a newly declared property can reject values callers send (input) or
|
|
205
|
+
// produce values consumers did not expect (output).
|
|
203
206
|
const addedKeys = Object.keys(afterProperties).filter((key) => !Object.hasOwn(beforeProperties, key));
|
|
204
|
-
if (
|
|
205
|
-
const governed = isJsonObject(before.additionalProperties)
|
|
206
|
-
|
|
207
|
+
if (addedKeys.length > 0) {
|
|
208
|
+
const governed = isJsonObject(before.additionalProperties)
|
|
209
|
+
|| Object.keys(keywordsOutside(before, structural)).some((key) => !DEFINITION_KEYWORDS.has(key));
|
|
210
|
+
const open = !root && Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
|
|
207
211
|
if (governed || (direction === "input" && open))
|
|
208
212
|
reasons.add(codes.unclassified);
|
|
209
213
|
}
|
|
@@ -650,7 +650,27 @@ const publicSchemaDefinitions = [
|
|
|
650
650
|
properties: {
|
|
651
651
|
schemaVersion: { const: 1 },
|
|
652
652
|
scope: { const: "tool-catalog-structure" },
|
|
653
|
+
// transport, protocol, and execution appear only on reports from `doctor tools <path> --server <name> --runtime`.
|
|
654
|
+
transport: { const: "stdio" },
|
|
653
655
|
status: { enum: ["pass", "warn", "fail", "unsupported", "not-applicable", "incomplete", "blocked"] },
|
|
656
|
+
protocol: {
|
|
657
|
+
type: "object", required: ["era", "version"],
|
|
658
|
+
properties: {
|
|
659
|
+
era: { enum: ["modern", "legacy", null] },
|
|
660
|
+
version: { type: ["string", "null"], pattern: "^\\d{4}-\\d{2}-\\d{2}$" }
|
|
661
|
+
},
|
|
662
|
+
additionalProperties: false
|
|
663
|
+
},
|
|
664
|
+
execution: {
|
|
665
|
+
type: ["object", "null"], required: ["backend", "image", "network", "packageMount"],
|
|
666
|
+
properties: {
|
|
667
|
+
backend: { enum: ["native", "docker"] },
|
|
668
|
+
image: { type: ["string", "null"] },
|
|
669
|
+
network: { enum: ["host", "none"] },
|
|
670
|
+
packageMount: { enum: ["host", "read_only"] }
|
|
671
|
+
},
|
|
672
|
+
additionalProperties: false
|
|
673
|
+
},
|
|
654
674
|
discovery: {
|
|
655
675
|
type: "object",
|
|
656
676
|
required: ["schemaVersion", "requestedVersion", "scope", "status", "supportedVersions", "findings", "coverage"],
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import type { DiscoveredPackage, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
|
|
1
|
+
import type { DiscoveredPackage, Finding, RuntimeProbeResult, RuntimeSandboxMode, RuntimeScorecard } from "../domain/types.js";
|
|
2
2
|
import { type RemoteMcpRequest } from "./remote-mcp-probe.js";
|
|
3
3
|
import type { RemoteLookup } from "./remote-network-policy.js";
|
|
4
|
+
export declare function cleanupDockerContainer(containerName: string): Promise<Finding | null>;
|
|
5
|
+
export declare function resolveRuntimeCwd(rootPath: string, configuredCwd: unknown): Promise<string | null>;
|
|
4
6
|
export declare function probeRuntime(discoveredPackage: DiscoveredPackage, options?: RuntimeProbeOptions): Promise<RuntimeProbeResult>;
|
|
5
7
|
export interface RuntimeProbeOptions {
|
|
6
8
|
startupTimeoutMs?: number;
|
|
@@ -110,7 +110,7 @@ function withRuntimeEvidence(finding, serverName) {
|
|
|
110
110
|
}
|
|
111
111
|
};
|
|
112
112
|
}
|
|
113
|
-
function cleanupDockerContainer(containerName) {
|
|
113
|
+
export function cleanupDockerContainer(containerName) {
|
|
114
114
|
return new Promise((resolve) => {
|
|
115
115
|
execFile("docker", ["rm", "-f", containerName], {
|
|
116
116
|
encoding: "utf8",
|
|
@@ -588,7 +588,7 @@ function findCallableTool(tools) {
|
|
|
588
588
|
function hasValidToolInputSchemas(tools) {
|
|
589
589
|
return tools.every((tool) => isPlainObject(tool.inputSchema) && tool.inputSchema.type === "object");
|
|
590
590
|
}
|
|
591
|
-
async function resolveRuntimeCwd(rootPath, configuredCwd) {
|
|
591
|
+
export async function resolveRuntimeCwd(rootPath, configuredCwd) {
|
|
592
592
|
const resolvedCwd = typeof configuredCwd === "string"
|
|
593
593
|
? path.resolve(rootPath, configuredCwd)
|
|
594
594
|
: rootPath;
|
|
@@ -4,6 +4,13 @@ export function renderMcpToolCatalogReport(report) {
|
|
|
4
4
|
"================",
|
|
5
5
|
`Status: ${report.status}`,
|
|
6
6
|
`Scope: ${report.scope}`,
|
|
7
|
+
...(report.transport === "stdio"
|
|
8
|
+
? [
|
|
9
|
+
"Transport: stdio",
|
|
10
|
+
`Protocol: ${report.protocol?.era ?? "not established"}${report.protocol?.version ? ` (${report.protocol.version})` : ""}`,
|
|
11
|
+
`Execution: ${report.execution?.backend ?? "not started"}`
|
|
12
|
+
]
|
|
13
|
+
: []),
|
|
7
14
|
`Discovery: ${report.discovery.status}`,
|
|
8
15
|
`Catalog complete: ${report.catalog.complete}`,
|
|
9
16
|
`Pages read: ${report.catalog.pagesRead}`,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function count(value) {
|
|
2
|
+
return value === null || value === undefined ? "not compared" : String(value);
|
|
3
|
+
}
|
|
4
|
+
function toolIndex(value) {
|
|
5
|
+
return value === null ? "-" : String(value);
|
|
6
|
+
}
|
|
7
|
+
export function renderMcpToolDiffMarkdown(report) {
|
|
8
|
+
const comparison = report.comparison;
|
|
9
|
+
const lines = [
|
|
10
|
+
"## MCP Tool Definition Diff",
|
|
11
|
+
"",
|
|
12
|
+
`**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
|
|
13
|
+
"",
|
|
14
|
+
"| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
|
|
15
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
16
|
+
`| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
|
|
17
|
+
];
|
|
18
|
+
if (report.changes.length > 0) {
|
|
19
|
+
lines.push("", "### Changes", "", "| Change | Before tool | After tool | Fields | Impact | Reasons |", "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
|
|
20
|
+
}
|
|
21
|
+
const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
|
|
22
|
+
const location = finding.location;
|
|
23
|
+
const locator = location
|
|
24
|
+
? `${side} tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}`
|
|
25
|
+
: side;
|
|
26
|
+
return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
|
|
27
|
+
}));
|
|
28
|
+
if (findings.length > 0) {
|
|
29
|
+
lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
|
|
30
|
+
}
|
|
31
|
+
lines.push("", `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
}
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
2
2
|
import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
3
|
+
import { captureStdioMcpToolCatalog } from "./core/mcp-stdio-tool-catalog.js";
|
|
3
4
|
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
4
5
|
import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
|
|
5
6
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
|
|
@@ -21,6 +22,7 @@ export interface RunCliOptions {
|
|
|
21
22
|
inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
|
|
22
23
|
inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
|
|
23
24
|
captureMcpToolCatalogImpl?: typeof captureMcpToolCatalog;
|
|
25
|
+
captureStdioMcpToolCatalogImpl?: typeof captureStdioMcpToolCatalog;
|
|
24
26
|
discoverMcpServerImpl?: typeof discoverMcpServer;
|
|
25
27
|
terminalContext?: CliTerminalContext;
|
|
26
28
|
runCheckImpl?: typeof runCheck;
|