codex-plugin-doctor 1.69.1 → 1.71.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 +675 -661
- package/dist/core/mcp-discovery.d.ts +19 -0
- package/dist/core/mcp-discovery.js +24 -5
- 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 +36 -4
- package/dist/core/mcp-tool-catalog.js +90 -69
- 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/run-cli.d.ts +2 -0
- package/dist/run-cli.js +109 -28
- package/package.json +71 -71
|
@@ -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")
|
|
@@ -73,7 +73,58 @@ function finalStatus(findings) {
|
|
|
73
73
|
function isAuthorizationFinding(discovery) {
|
|
74
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;
|
|
@@ -141,45 +192,40 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
141
192
|
const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
|
|
142
193
|
? "credentials-insecure-transport"
|
|
143
194
|
: "network-blocked";
|
|
144
|
-
return partial(
|
|
195
|
+
return partial(buildMcpToolCatalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
|
|
145
196
|
}
|
|
146
197
|
if (isAuthorizationFinding(discovery)) {
|
|
147
|
-
return partial(
|
|
198
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
|
|
148
199
|
}
|
|
149
200
|
if (discovery.status === "unsupported") {
|
|
150
|
-
return partial(
|
|
201
|
+
return partial(buildMcpToolCatalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []));
|
|
151
202
|
}
|
|
152
203
|
if (discovery.status !== "discovered") {
|
|
153
|
-
return partial(
|
|
204
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []));
|
|
154
205
|
}
|
|
155
206
|
const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
|
|
156
207
|
if (capabilities === null) {
|
|
157
|
-
return partial(
|
|
208
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []));
|
|
158
209
|
}
|
|
159
210
|
if (!Object.hasOwn(capabilities, "tools")) {
|
|
160
|
-
return partial(
|
|
211
|
+
return partial(buildMcpToolCatalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []));
|
|
161
212
|
}
|
|
162
|
-
const
|
|
163
|
-
const
|
|
213
|
+
const inspector = createMcpToolCatalogInspector();
|
|
214
|
+
const findings = inspector.findings;
|
|
164
215
|
const seenCursors = new Set();
|
|
165
216
|
let pagesRead = 0;
|
|
166
|
-
let toolsChecked = 0;
|
|
167
217
|
let cursor;
|
|
168
218
|
let incompleteReason = null;
|
|
169
219
|
let blocked = false;
|
|
170
|
-
const tools = [];
|
|
171
220
|
let cache = null;
|
|
172
221
|
const addFindings = (items) => {
|
|
173
|
-
if (
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
findings.push(...items);
|
|
179
|
-
return true;
|
|
222
|
+
if (inspector.addFindings(items))
|
|
223
|
+
return true;
|
|
224
|
+
incompleteReason = "finding-limit";
|
|
225
|
+
return false;
|
|
180
226
|
};
|
|
181
227
|
while (incompleteReason === null && !blocked) {
|
|
182
|
-
if (pagesRead >=
|
|
228
|
+
if (pagesRead >= MAX_CATALOG_PAGES) {
|
|
183
229
|
incompleteReason = "page-limit";
|
|
184
230
|
break;
|
|
185
231
|
}
|
|
@@ -195,7 +241,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
195
241
|
lookup: options.lookup,
|
|
196
242
|
bearerToken: options.bearerToken,
|
|
197
243
|
method: "POST",
|
|
198
|
-
body:
|
|
244
|
+
body: serializeToolsListRequest(requestId, cursor),
|
|
199
245
|
headers: {
|
|
200
246
|
Accept: "application/json, text/event-stream",
|
|
201
247
|
"Content-Type": "application/json",
|
|
@@ -226,19 +272,19 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
226
272
|
}
|
|
227
273
|
const message = parseMcpResponse(response, requestId);
|
|
228
274
|
if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
|
|
229
|
-
&&
|
|
230
|
-
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.")]))
|
|
231
277
|
incompleteReason = "tools-list-method-unsupported";
|
|
232
278
|
break;
|
|
233
279
|
}
|
|
234
280
|
if (response.statusCode !== 200) {
|
|
235
|
-
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.")]))
|
|
236
282
|
incompleteReason = "http-status-invalid";
|
|
237
283
|
break;
|
|
238
284
|
}
|
|
239
285
|
const page = message === null ? null : parseToolsResult(message, requestId);
|
|
240
286
|
if (page === null) {
|
|
241
|
-
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.")]))
|
|
242
288
|
incompleteReason = "response-invalid";
|
|
243
289
|
break;
|
|
244
290
|
}
|
|
@@ -247,57 +293,32 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
|
|
|
247
293
|
ttlMs: Math.min(cache.ttlMs, page.cache.ttlMs),
|
|
248
294
|
cacheScope: cache.cacheScope === "private" ? "private" : page.cache.cacheScope
|
|
249
295
|
};
|
|
250
|
-
|
|
251
|
-
if (toolsChecked >= MAX_TOOLS) {
|
|
252
|
-
incompleteReason = "tool-limit";
|
|
253
|
-
break;
|
|
254
|
-
}
|
|
255
|
-
toolsChecked += 1;
|
|
256
|
-
tools.push(tool);
|
|
257
|
-
const location = { page: pagesRead, toolIndex: toolsChecked };
|
|
258
|
-
if (!addFindings(inspectMcpToolDefinition(tool, location))) {
|
|
259
|
-
break;
|
|
260
|
-
}
|
|
261
|
-
if (isPlainObject(tool) && typeof tool.name === "string") {
|
|
262
|
-
const previous = names.get(tool.name);
|
|
263
|
-
if (previous !== undefined && !addFindings([{
|
|
264
|
-
...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."),
|
|
265
|
-
location: { ...location, relatedToolIndex: previous }
|
|
266
|
-
}])) {
|
|
267
|
-
break;
|
|
268
|
-
}
|
|
269
|
-
if (previous === undefined)
|
|
270
|
-
names.set(tool.name, toolsChecked);
|
|
271
|
-
}
|
|
272
|
-
if (deadline - now() <= 0) {
|
|
273
|
-
incompleteReason = "total-time-limit";
|
|
274
|
-
break;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
296
|
+
incompleteReason = inspector.inspectPage(page.tools, pagesRead, () => deadline - now() <= 0);
|
|
277
297
|
if (incompleteReason !== null)
|
|
278
298
|
break;
|
|
279
299
|
if (page.cursor === undefined)
|
|
280
300
|
break;
|
|
281
301
|
if (seenCursors.has(page.cursor)) {
|
|
282
|
-
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.")]))
|
|
283
303
|
incompleteReason = "cursor-loop";
|
|
284
304
|
break;
|
|
285
305
|
}
|
|
286
306
|
seenCursors.add(page.cursor);
|
|
287
307
|
cursor = page.cursor;
|
|
288
308
|
}
|
|
309
|
+
const toolsChecked = inspector.toolsChecked;
|
|
289
310
|
if (blocked) {
|
|
290
|
-
return partial(
|
|
311
|
+
return partial(buildMcpToolCatalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings));
|
|
291
312
|
}
|
|
292
313
|
if (incompleteReason !== null) {
|
|
293
|
-
return partial(
|
|
314
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings));
|
|
294
315
|
}
|
|
295
316
|
if (deadline - now() <= 0) {
|
|
296
|
-
return partial(
|
|
317
|
+
return partial(buildMcpToolCatalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings));
|
|
297
318
|
}
|
|
298
319
|
return {
|
|
299
|
-
report:
|
|
300
|
-
tools,
|
|
320
|
+
report: buildMcpToolCatalogReport(discovery, finalCatalogStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings),
|
|
321
|
+
tools: inspector.tools,
|
|
301
322
|
cache
|
|
302
323
|
};
|
|
303
324
|
}
|
|
@@ -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}`,
|
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;
|
package/dist/run-cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { buildSubmissionPreflight } from "./core/submission-preflight.js";
|
|
|
24
24
|
import { isValidBearerToken } from "./core/bounded-http-client.js";
|
|
25
25
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
26
26
|
import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
27
|
+
import { captureStdioMcpToolCatalog, resolveMcpStdioServer } from "./core/mcp-stdio-tool-catalog.js";
|
|
27
28
|
import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
|
|
28
29
|
import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
|
|
29
30
|
import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
|
|
@@ -112,7 +113,7 @@ class CliUsageError extends Error {
|
|
|
112
113
|
}
|
|
113
114
|
const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
|
|
114
115
|
const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
|
|
115
|
-
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]";
|
|
116
|
+
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]\n codex-plugin-doctor doctor tools <path> --server <name> --runtime [--sandbox docker] [--require-runtime-approval [--runtime-approval-digest <digest>]] [--json] [--save-response <path>]";
|
|
116
117
|
const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
|
|
117
118
|
const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
|
|
118
119
|
const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
|
|
@@ -128,6 +129,105 @@ function resolveBearerToken(name, env) {
|
|
|
128
129
|
return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
|
|
129
130
|
return { token };
|
|
130
131
|
}
|
|
132
|
+
async function finishToolCatalogCommand(io, snapshot, json, savePath) {
|
|
133
|
+
const report = snapshot.report;
|
|
134
|
+
io.writeStdout(json
|
|
135
|
+
? JSON.stringify(report, null, 2)
|
|
136
|
+
: renderMcpToolCatalogReport(report));
|
|
137
|
+
const exitCode = report.status === "pass" ? 0
|
|
138
|
+
: report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
|
|
139
|
+
if (savePath === undefined)
|
|
140
|
+
return exitCode;
|
|
141
|
+
if (snapshot.tools === null || snapshot.cache === null) {
|
|
142
|
+
io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
|
|
146
|
+
if (saved.kind !== "saved") {
|
|
147
|
+
io.writeStderr({
|
|
148
|
+
"path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
|
|
149
|
+
"not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
|
|
150
|
+
"too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
|
|
151
|
+
"write-failed": "Tool catalog response could not be written."
|
|
152
|
+
}[saved.kind]);
|
|
153
|
+
return 2;
|
|
154
|
+
}
|
|
155
|
+
io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
|
|
156
|
+
return exitCode;
|
|
157
|
+
}
|
|
158
|
+
async function runStdioToolCatalogCommand(targetPath, flags, io, options) {
|
|
159
|
+
const allowedSwitches = new Set(["--runtime", "--json", "--require-runtime-approval"]);
|
|
160
|
+
const switches = [];
|
|
161
|
+
const values = new Map();
|
|
162
|
+
let invalid = false;
|
|
163
|
+
for (let index = 0; index < flags.length; index += 1) {
|
|
164
|
+
const flag = flags[index];
|
|
165
|
+
if (["--server", "--save-response", "--sandbox", "--runtime-approval-digest"].includes(flag) && !values.has(flag)) {
|
|
166
|
+
const value = flags[index + 1];
|
|
167
|
+
if (!value || value.startsWith("-")) {
|
|
168
|
+
invalid = true;
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
values.set(flag, value);
|
|
172
|
+
index += 1;
|
|
173
|
+
}
|
|
174
|
+
else if (allowedSwitches.has(flag) && !switches.includes(flag)) {
|
|
175
|
+
switches.push(flag);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
invalid = true;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const serverName = values.get("--server");
|
|
183
|
+
const savePath = values.get("--save-response");
|
|
184
|
+
const sandbox = values.get("--sandbox");
|
|
185
|
+
const approvalDigest = values.get("--runtime-approval-digest");
|
|
186
|
+
const requireApproval = switches.includes("--require-runtime-approval");
|
|
187
|
+
if (invalid || serverName === undefined || (sandbox !== undefined && sandbox !== "docker")) {
|
|
188
|
+
io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
|
|
189
|
+
return 2;
|
|
190
|
+
}
|
|
191
|
+
if (!switches.includes("--runtime")) {
|
|
192
|
+
io.writeStderr("doctor tools --server starts a local MCP server and requires explicit --runtime consent.");
|
|
193
|
+
return 2;
|
|
194
|
+
}
|
|
195
|
+
if (approvalDigest !== undefined && !requireApproval) {
|
|
196
|
+
io.writeStderr("--runtime-approval-digest requires --require-runtime-approval.");
|
|
197
|
+
return 2;
|
|
198
|
+
}
|
|
199
|
+
if (isBlockedOfflinePath(targetPath)) {
|
|
200
|
+
io.writeStderr("doctor tools --server requires a local package path.");
|
|
201
|
+
return 2;
|
|
202
|
+
}
|
|
203
|
+
if (savePath !== undefined && isBlockedOfflinePath(savePath)) {
|
|
204
|
+
io.writeStderr("doctor tools --save-response requires a local file path.");
|
|
205
|
+
return 2;
|
|
206
|
+
}
|
|
207
|
+
const resolved = await resolveMcpStdioServer(targetPath, serverName);
|
|
208
|
+
if (!resolved.ok) {
|
|
209
|
+
io.writeStderr(resolved.message);
|
|
210
|
+
return 2;
|
|
211
|
+
}
|
|
212
|
+
if (requireApproval) {
|
|
213
|
+
// Runtime plan digests cover only the manifest mcpServers file, so .mcp.json servers cannot be approved.
|
|
214
|
+
if (resolved.server.source !== "manifest") {
|
|
215
|
+
io.writeStderr("Runtime approval covers servers from the plugin manifest mcpServers file; this server comes from .mcp.json.");
|
|
216
|
+
return 2;
|
|
217
|
+
}
|
|
218
|
+
const runtimePlan = await buildDoctorRuntimePlan(targetPath, new Date().toISOString(), sandbox === "docker" ? { sandbox: "docker" } : {});
|
|
219
|
+
const approval = evaluateRuntimeApproval(runtimePlan, {
|
|
220
|
+
required: true,
|
|
221
|
+
approvedDigest: approvalDigest ?? null
|
|
222
|
+
});
|
|
223
|
+
if (!runtimeApprovalPassed(approval)) {
|
|
224
|
+
io.writeStderr(`${approval.message}\nCurrent runtime plan digest: ${runtimePlan.digest}`);
|
|
225
|
+
return 1;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
const snapshot = await (options.captureStdioMcpToolCatalogImpl ?? captureStdioMcpToolCatalog)(resolved.server, sandbox === "docker" ? { sandbox: "docker" } : {});
|
|
229
|
+
return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
|
|
230
|
+
}
|
|
131
231
|
function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
|
|
132
232
|
const index = flags.indexOf("--sandbox");
|
|
133
233
|
if (index === -1) {
|
|
@@ -1176,9 +1276,10 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1176
1276
|
(command === "release" && maybePath === "check") ||
|
|
1177
1277
|
(command === "doctor" &&
|
|
1178
1278
|
(["runtime-plan", "runtime-policy"].includes(maybePath ?? "") ||
|
|
1179
|
-
(maybePath === "release-evidence" && remainingArgs[0] !== "verify")
|
|
1279
|
+
(maybePath === "release-evidence" && remainingArgs[0] !== "verify") ||
|
|
1280
|
+
(maybePath === "tools" && remainingArgs.includes("--server"))));
|
|
1180
1281
|
if (args.includes("--sandbox") && !runtimeSandboxSupported) {
|
|
1181
|
-
io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy,
|
|
1282
|
+
io.writeStderr("--sandbox is supported only by single-package check, release check, runtime-plan, runtime-policy, release-evidence, and doctor tools --server.");
|
|
1182
1283
|
return 2;
|
|
1183
1284
|
}
|
|
1184
1285
|
if (command === "--version" || command === "-v" || command === "version") {
|
|
@@ -1299,7 +1400,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1299
1400
|
}
|
|
1300
1401
|
if (maybePath === "tools") {
|
|
1301
1402
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1302
|
-
io.writeStdout(`${toolsUsage}\
|
|
1403
|
+
io.writeStdout(`${toolsUsage}\nTool catalog structure only; tools are not executed.\n--server starts the named stdio server from the package MCP configuration after explicit --runtime consent and stops it after reading the tool list.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
|
|
1303
1404
|
return 0;
|
|
1304
1405
|
}
|
|
1305
1406
|
const [url, ...flags] = remainingArgs;
|
|
@@ -1307,6 +1408,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1307
1408
|
io.writeStderr(toolsUsage);
|
|
1308
1409
|
return 2;
|
|
1309
1410
|
}
|
|
1411
|
+
if (flags.includes("--server")) {
|
|
1412
|
+
return runStdioToolCatalogCommand(url, flags, io, options);
|
|
1413
|
+
}
|
|
1310
1414
|
const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
|
|
1311
1415
|
const switches = [];
|
|
1312
1416
|
let savePath;
|
|
@@ -1359,30 +1463,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1359
1463
|
const snapshot = savePath === undefined
|
|
1360
1464
|
? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
|
|
1361
1465
|
: await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
|
|
1362
|
-
|
|
1363
|
-
io.writeStdout(switches.includes("--json")
|
|
1364
|
-
? JSON.stringify(report, null, 2)
|
|
1365
|
-
: renderMcpToolCatalogReport(report));
|
|
1366
|
-
const exitCode = report.status === "pass" ? 0
|
|
1367
|
-
: report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
|
|
1368
|
-
if (savePath === undefined)
|
|
1369
|
-
return exitCode;
|
|
1370
|
-
if (snapshot.tools === null || snapshot.cache === null) {
|
|
1371
|
-
io.writeStderr("Tool catalog response was not saved because enumeration did not complete.");
|
|
1372
|
-
return 2;
|
|
1373
|
-
}
|
|
1374
|
-
const saved = await saveMcpToolsListResponse(savePath, snapshot.tools, snapshot.cache);
|
|
1375
|
-
if (saved.kind !== "saved") {
|
|
1376
|
-
io.writeStderr({
|
|
1377
|
-
"path-blocked": "Tool catalog response was not saved because the path is not a local file path.",
|
|
1378
|
-
"not-regular": "Tool catalog response was not saved because the existing target is not a regular file.",
|
|
1379
|
-
"too-large": "Tool catalog response was not saved because it exceeds the one MiB tools-file limit.",
|
|
1380
|
-
"write-failed": "Tool catalog response could not be written."
|
|
1381
|
-
}[saved.kind]);
|
|
1382
|
-
return 2;
|
|
1383
|
-
}
|
|
1384
|
-
io.writeStderr(`Saved a complete tools/list response with ${saved.tools} tools (${saved.bytes} bytes).`);
|
|
1385
|
-
return exitCode;
|
|
1466
|
+
return finishToolCatalogCommand(io, snapshot, switches.includes("--json"), savePath);
|
|
1386
1467
|
}
|
|
1387
1468
|
if (maybePath === "discover") {
|
|
1388
1469
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|