codex-plugin-doctor 1.61.0 → 1.63.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 +44 -17
- package/dist/core/mcp-discovery.d.ts +5 -0
- package/dist/core/mcp-discovery.js +21 -8
- package/dist/core/mcp-tool-catalog.d.ts +36 -0
- package/dist/core/mcp-tool-catalog.js +276 -0
- package/dist/core/mcp-tool-definition.d.ts +14 -0
- package/dist/core/mcp-tool-definition.js +116 -0
- package/dist/core/mcp-tool-file.d.ts +27 -0
- package/dist/core/mcp-tool-file.js +241 -0
- package/dist/core/output-contract.js +124 -0
- package/dist/reporting/render-mcp-tool-catalog-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-catalog-report.js +25 -0
- package/dist/reporting/render-mcp-tool-file-report.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-file-report.js +26 -0
- package/dist/run-cli.d.ts +4 -0
- package/dist/run-cli.js +54 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -81,21 +81,48 @@ Runtime MCP validation with `--runtime`:
|
|
|
81
81
|
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
82
|
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
83
|
|
|
84
|
-
### MCP Protocol Discovery
|
|
85
|
-
|
|
86
|
-
Inspect an HTTP MCP endpoint's advertised protocol versions using a bounded MCP
|
|
87
|
-
2026-07-28 `server/discover` request:
|
|
88
|
-
|
|
89
|
-
```bash
|
|
90
|
-
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network
|
|
91
|
-
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network --json
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
This is an opt-in discovery-only check. A successful result validates the discovery
|
|
95
|
-
response, not full protocol conformance, tool behavior, or compatibility with a
|
|
96
|
-
particular client. Existing `check --runtime` remains on its 2025-11-25 baseline.
|
|
97
|
-
See [MCP Discovery](docs/guides/mcp-discovery.md) for consent, scope, and exit codes.
|
|
98
|
-
|
|
84
|
+
### MCP Protocol Discovery
|
|
85
|
+
|
|
86
|
+
Inspect an HTTP MCP endpoint's advertised protocol versions using a bounded MCP
|
|
87
|
+
2026-07-28 `server/discover` request:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network
|
|
91
|
+
codex-plugin-doctor doctor discover https://mcp.example.com/mcp --allow-network --json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This is an opt-in discovery-only check. A successful result validates the discovery
|
|
95
|
+
response, not full protocol conformance, tool behavior, or compatibility with a
|
|
96
|
+
particular client. Existing `check --runtime` remains on its 2025-11-25 baseline.
|
|
97
|
+
See [MCP Discovery](docs/guides/mcp-discovery.md) for consent, scope, and exit codes.
|
|
98
|
+
|
|
99
|
+
### Offline MCP Tool Definitions
|
|
100
|
+
|
|
101
|
+
Inspect a saved MCP 2026-07-28 `tools/list` response without contacting a server:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
codex-plugin-doctor doctor tools-file ./tools-list.json --json
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
This checks the supplied tool definitions and root-schema shapes within fixed file,
|
|
108
|
+
tool, and finding limits. It does not establish server inventory completeness or
|
|
109
|
+
execute tools. See [Offline MCP Tool Definitions](docs/guides/offline-tool-definitions.md)
|
|
110
|
+
for the accepted input format, coverage, and exit codes.
|
|
111
|
+
|
|
112
|
+
### HTTP MCP Tool Catalog
|
|
113
|
+
|
|
114
|
+
Inspect a modern HTTP MCP server's tool definitions without invoking them:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
codex-plugin-doctor doctor tools https://mcp.example.com/mcp --allow-network --json
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
This opt-in command performs discovery and bounded `tools/list` pagination. It
|
|
121
|
+
reports tool-definition and root-schema shape findings, explicit completion state,
|
|
122
|
+
and partial inspection limits. It does not claim full schema validation, tool
|
|
123
|
+
execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
|
|
124
|
+
See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
125
|
+
|
|
99
126
|
### Remote MCP Readiness
|
|
100
127
|
|
|
101
128
|
Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
|
|
@@ -514,9 +541,9 @@ jobs:
|
|
|
514
541
|
runs-on: ubuntu-latest
|
|
515
542
|
steps:
|
|
516
543
|
- uses: actions/checkout@v5
|
|
517
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
544
|
+
- uses: Esquetta/CodexPluginDoctor@v1.63.0
|
|
518
545
|
with:
|
|
519
|
-
version: "1.
|
|
546
|
+
version: "1.63.0"
|
|
520
547
|
path: .
|
|
521
548
|
runtime: "true"
|
|
522
549
|
policy: codex-publish
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { type BoundedHttpResponse } from "./bounded-http-client.js";
|
|
1
2
|
import { type RemoteLookup } from "./remote-network-policy.js";
|
|
2
3
|
import type { RemoteMcpRequest } from "./remote-mcp-probe.js";
|
|
3
4
|
import type { Finding } from "../domain/types.js";
|
|
5
|
+
export type McpJsonObject = Record<string, unknown>;
|
|
4
6
|
export interface McpDiscoveryOptions {
|
|
5
7
|
allowNetwork?: boolean;
|
|
6
8
|
allowLocalNetwork?: boolean;
|
|
@@ -20,4 +22,7 @@ export interface McpDiscoveryReport {
|
|
|
20
22
|
runtime: "not-tested";
|
|
21
23
|
};
|
|
22
24
|
}
|
|
25
|
+
export declare function findMcpSseResponse(body: Buffer, requestId?: string): McpJsonObject | null;
|
|
26
|
+
export declare function parseMcpResponse(response: BoundedHttpResponse, requestId?: string): McpJsonObject | null;
|
|
27
|
+
export declare function discoverMcpCapabilities(response: BoundedHttpResponse, requestId?: string): McpJsonObject | null;
|
|
23
28
|
export declare function discoverMcpServer(rawUrl: string, options?: McpDiscoveryOptions): Promise<McpDiscoveryReport>;
|
|
@@ -27,7 +27,7 @@ function mediaType(response) {
|
|
|
27
27
|
const value = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
28
28
|
return typeof value === "string" ? value.split(";", 1)[0]?.trim().toLowerCase() ?? null : null;
|
|
29
29
|
}
|
|
30
|
-
function
|
|
30
|
+
export function findMcpSseResponse(body, requestId = REQUEST_ID) {
|
|
31
31
|
const text = body.toString("utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
32
32
|
let offset = 0;
|
|
33
33
|
while (offset < text.length) {
|
|
@@ -38,16 +38,16 @@ function findSseResponse(body) {
|
|
|
38
38
|
.filter((line) => line.startsWith("data:"))
|
|
39
39
|
.map((line) => line.slice(5).replace(/^ /, ""));
|
|
40
40
|
const candidate = data.length === 0 ? null : parseJsonObject(data.join("\n"));
|
|
41
|
-
if (candidate?.id ===
|
|
41
|
+
if (candidate?.id === requestId)
|
|
42
42
|
return candidate;
|
|
43
43
|
offset = boundary + 2;
|
|
44
44
|
}
|
|
45
45
|
return null;
|
|
46
46
|
}
|
|
47
|
-
function
|
|
47
|
+
export function parseMcpResponse(response, requestId = REQUEST_ID) {
|
|
48
48
|
const contentType = mediaType(response);
|
|
49
49
|
return contentType === "application/json" ? parseJsonObject(response.body.toString("utf8"))
|
|
50
|
-
: contentType === "text/event-stream" ?
|
|
50
|
+
: contentType === "text/event-stream" ? findMcpSseResponse(response.body, requestId)
|
|
51
51
|
: null;
|
|
52
52
|
}
|
|
53
53
|
function isValidVersion(value) {
|
|
@@ -60,8 +60,8 @@ function isValidVersion(value) {
|
|
|
60
60
|
function validVersions(value) {
|
|
61
61
|
return Array.isArray(value) && value.length > 0 && value.length <= 32 && value.every(isValidVersion) && new Set(value).size === value.length;
|
|
62
62
|
}
|
|
63
|
-
function hasMatchingResponseEnvelope(message) {
|
|
64
|
-
return message.jsonrpc === "2.0" && message.id ===
|
|
63
|
+
function hasMatchingResponseEnvelope(message, requestId = REQUEST_ID) {
|
|
64
|
+
return message.jsonrpc === "2.0" && message.id === requestId
|
|
65
65
|
&& Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
|
|
66
66
|
}
|
|
67
67
|
function validDiscoverResult(value) {
|
|
@@ -89,6 +89,19 @@ function unsupportedVersions(error) {
|
|
|
89
89
|
return null;
|
|
90
90
|
return error.data.requested === REQUESTED_VERSION && validVersions(error.data.supported) ? error.data.supported : null;
|
|
91
91
|
}
|
|
92
|
+
export function discoverMcpCapabilities(response, requestId = REQUEST_ID) {
|
|
93
|
+
const message = parseMcpResponse(response, requestId);
|
|
94
|
+
if (!message || !hasMatchingResponseEnvelope(message, requestId) || !Object.hasOwn(message, "result")) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const result = message.result;
|
|
98
|
+
if (!validDiscoverResult(result))
|
|
99
|
+
return null;
|
|
100
|
+
const capabilities = result.capabilities;
|
|
101
|
+
if (!isPlainObject(capabilities))
|
|
102
|
+
return null;
|
|
103
|
+
return result.supportedVersions.includes(REQUESTED_VERSION) ? capabilities : null;
|
|
104
|
+
}
|
|
92
105
|
function failureReport(id, message) {
|
|
93
106
|
return report("failed", "fail", [finding(id, "fail", message, "The discovery response cannot safely establish modern MCP discovery support.", "Verify the endpoint returns a valid server/discover response for the requested protocol version.")]);
|
|
94
107
|
}
|
|
@@ -114,7 +127,7 @@ export async function discoverMcpServer(rawUrl, options = {}) {
|
|
|
114
127
|
allowLocalNetwork: options.allowLocalNetwork, lookup: options.lookup, timeoutMs: options.requestTimeoutMs,
|
|
115
128
|
method: "POST", body,
|
|
116
129
|
headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": REQUESTED_VERSION, "Mcp-Method": "server/discover" },
|
|
117
|
-
stopAfter: (received) =>
|
|
130
|
+
stopAfter: (received) => findMcpSseResponse(received) !== null
|
|
118
131
|
});
|
|
119
132
|
}
|
|
120
133
|
catch (error) {
|
|
@@ -126,7 +139,7 @@ export async function discoverMcpServer(rawUrl, options = {}) {
|
|
|
126
139
|
if (response.statusCode === 401 || response.statusCode === 403) {
|
|
127
140
|
return unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.");
|
|
128
141
|
}
|
|
129
|
-
const message =
|
|
142
|
+
const message = parseMcpResponse(response);
|
|
130
143
|
if (!message || !hasMatchingResponseEnvelope(message))
|
|
131
144
|
return failureReport("plugin.discovery.response.invalid", "The MCP endpoint returned an invalid discovery response.");
|
|
132
145
|
if (Object.hasOwn(message, "error")) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type McpDiscoveryReport } from "./mcp-discovery.js";
|
|
2
|
+
import { type CatalogFinding } from "./mcp-tool-definition.js";
|
|
3
|
+
import { type RemoteLookup } from "./remote-network-policy.js";
|
|
4
|
+
import type { RemoteMcpRequest } from "./remote-mcp-probe.js";
|
|
5
|
+
type CatalogStatus = "pass" | "warn" | "fail" | "unsupported" | "not-applicable" | "incomplete" | "blocked";
|
|
6
|
+
type EnumerationCoverage = "complete" | "incomplete" | "not-tested";
|
|
7
|
+
export interface McpToolCatalogOptions {
|
|
8
|
+
allowNetwork?: boolean;
|
|
9
|
+
allowLocalNetwork?: boolean;
|
|
10
|
+
lookup?: RemoteLookup;
|
|
11
|
+
request?: RemoteMcpRequest;
|
|
12
|
+
now?: () => number;
|
|
13
|
+
}
|
|
14
|
+
export interface McpToolCatalogReport {
|
|
15
|
+
schemaVersion: 1;
|
|
16
|
+
scope: "tool-catalog-structure";
|
|
17
|
+
status: CatalogStatus;
|
|
18
|
+
discovery: McpDiscoveryReport;
|
|
19
|
+
catalog: {
|
|
20
|
+
complete: boolean;
|
|
21
|
+
pagesRead: number;
|
|
22
|
+
toolsChecked: number;
|
|
23
|
+
reason: string | null;
|
|
24
|
+
};
|
|
25
|
+
coverage: {
|
|
26
|
+
discovery: "pass" | "fail" | "skipped";
|
|
27
|
+
catalogEnumeration: EnumerationCoverage;
|
|
28
|
+
schema: "root-shape-only";
|
|
29
|
+
toolExecution: "not-tested";
|
|
30
|
+
fullConformance: "not-tested";
|
|
31
|
+
customHeaderAnnotations: "not-tested";
|
|
32
|
+
};
|
|
33
|
+
findings: CatalogFinding[];
|
|
34
|
+
}
|
|
35
|
+
export declare function inspectMcpToolCatalog(rawUrl: string, options?: McpToolCatalogOptions): Promise<McpToolCatalogReport>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { BoundedHttpError, requestBoundedHttp } from "./bounded-http-client.js";
|
|
2
|
+
import { discoverMcpCapabilities, discoverMcpServer, findMcpSseResponse, parseMcpResponse } from "./mcp-discovery.js";
|
|
3
|
+
import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
4
|
+
import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
|
|
5
|
+
import { packageVersion } from "../version.js";
|
|
6
|
+
const REQUESTED_VERSION = "2026-07-28";
|
|
7
|
+
const MAX_PAGES = 5;
|
|
8
|
+
const MAX_TOOLS = 500;
|
|
9
|
+
const MAX_FINDINGS = 100;
|
|
10
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
11
|
+
const MAX_TOTAL_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
13
|
+
const TOTAL_TIMEOUT_MS = 15_000;
|
|
14
|
+
function isPlainObject(value) {
|
|
15
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
16
|
+
}
|
|
17
|
+
function finding(id, severity, message, impact, suggestedFix) {
|
|
18
|
+
return { id, severity, message, impact, suggestedFix };
|
|
19
|
+
}
|
|
20
|
+
function validError(value) {
|
|
21
|
+
return isPlainObject(value) && typeof value.code === "number" && Number.isFinite(value.code) && typeof value.message === "string";
|
|
22
|
+
}
|
|
23
|
+
function validCacheMetadata(value) {
|
|
24
|
+
return value.resultType === "complete"
|
|
25
|
+
&& typeof value.ttlMs === "number" && Number.isFinite(value.ttlMs) && value.ttlMs >= 0
|
|
26
|
+
&& (value.cacheScope === "public" || value.cacheScope === "private");
|
|
27
|
+
}
|
|
28
|
+
function hasMatchingEnvelope(message, requestId) {
|
|
29
|
+
return message.jsonrpc === "2.0" && message.id === requestId
|
|
30
|
+
&& Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
|
|
31
|
+
}
|
|
32
|
+
function parseToolsResult(message, requestId) {
|
|
33
|
+
if (!hasMatchingEnvelope(message, requestId)) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!Object.hasOwn(message, "result") || !isPlainObject(message.result) || !validCacheMetadata(message.result) || !Array.isArray(message.result.tools)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (Object.hasOwn(message.result, "nextCursor") && typeof message.result.nextCursor !== "string") {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return { tools: message.result.tools, cursor: message.result.nextCursor };
|
|
43
|
+
}
|
|
44
|
+
function catalogReport(discovery, status, catalog, catalogEnumeration, findings) {
|
|
45
|
+
return {
|
|
46
|
+
schemaVersion: 1,
|
|
47
|
+
scope: "tool-catalog-structure",
|
|
48
|
+
status,
|
|
49
|
+
discovery,
|
|
50
|
+
catalog,
|
|
51
|
+
coverage: {
|
|
52
|
+
discovery: discovery.coverage.discovery,
|
|
53
|
+
catalogEnumeration,
|
|
54
|
+
schema: "root-shape-only",
|
|
55
|
+
toolExecution: "not-tested",
|
|
56
|
+
fullConformance: "not-tested",
|
|
57
|
+
customHeaderAnnotations: "not-tested"
|
|
58
|
+
},
|
|
59
|
+
findings
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function finalStatus(findings) {
|
|
63
|
+
return findings.some((item) => item.severity === "fail")
|
|
64
|
+
? "fail"
|
|
65
|
+
: findings.some((item) => item.severity === "warn")
|
|
66
|
+
? "warn"
|
|
67
|
+
: "pass";
|
|
68
|
+
}
|
|
69
|
+
function isAuthorizationFinding(discovery) {
|
|
70
|
+
return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required");
|
|
71
|
+
}
|
|
72
|
+
function requestBody(id, cursor) {
|
|
73
|
+
return JSON.stringify({
|
|
74
|
+
jsonrpc: "2.0",
|
|
75
|
+
id,
|
|
76
|
+
method: "tools/list",
|
|
77
|
+
params: {
|
|
78
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
79
|
+
_meta: {
|
|
80
|
+
"io.modelcontextprotocol/protocolVersion": REQUESTED_VERSION,
|
|
81
|
+
"io.modelcontextprotocol/clientInfo": { name: "Codex Plugin Doctor", version: packageVersion },
|
|
82
|
+
"io.modelcontextprotocol/clientCapabilities": {}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
export async function inspectMcpToolCatalog(rawUrl, options = {}) {
|
|
88
|
+
const request = options.request ?? requestBoundedHttp;
|
|
89
|
+
const now = options.now ?? (() => performance.now());
|
|
90
|
+
const deadline = now() + TOTAL_TIMEOUT_MS;
|
|
91
|
+
let receivedBytes = 0;
|
|
92
|
+
let budgetReason = null;
|
|
93
|
+
const requestWithinBudget = async (url, requestOptions = {}) => {
|
|
94
|
+
const remainingTime = deadline - now();
|
|
95
|
+
const remainingBytes = MAX_TOTAL_RESPONSE_BYTES - receivedBytes;
|
|
96
|
+
const timeoutMs = Math.floor(Math.min(REQUEST_TIMEOUT_MS, remainingTime));
|
|
97
|
+
if (timeoutMs < 1) {
|
|
98
|
+
budgetReason = "total-time-limit";
|
|
99
|
+
throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Catalog time budget exhausted.");
|
|
100
|
+
}
|
|
101
|
+
if (remainingBytes <= 0) {
|
|
102
|
+
budgetReason = "total-response-byte-limit";
|
|
103
|
+
throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response byte budget exhausted.");
|
|
104
|
+
}
|
|
105
|
+
const maxResponseBytes = Math.min(MAX_RESPONSE_BYTES, remainingBytes);
|
|
106
|
+
const response = await request(url, {
|
|
107
|
+
...requestOptions,
|
|
108
|
+
timeoutMs,
|
|
109
|
+
maxResponseBytes
|
|
110
|
+
});
|
|
111
|
+
receivedBytes += response.body.length;
|
|
112
|
+
if (response.body.length > maxResponseBytes) {
|
|
113
|
+
budgetReason = receivedBytes > MAX_TOTAL_RESPONSE_BYTES ? "total-response-byte-limit" : "response-byte-limit";
|
|
114
|
+
throw new BoundedHttpError("REMOTE_HTTP_RESPONSE_TOO_LARGE", "Catalog response exceeded the byte budget.");
|
|
115
|
+
}
|
|
116
|
+
return response;
|
|
117
|
+
};
|
|
118
|
+
let discoveryResponse = null;
|
|
119
|
+
const discovery = await discoverMcpServer(rawUrl, {
|
|
120
|
+
allowNetwork: options.allowNetwork,
|
|
121
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
122
|
+
lookup: options.lookup,
|
|
123
|
+
request: async (url, requestOptions) => {
|
|
124
|
+
const response = await requestWithinBudget(url, requestOptions);
|
|
125
|
+
discoveryResponse = response;
|
|
126
|
+
return response;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
|
|
130
|
+
if (discovery.status === "blocked") {
|
|
131
|
+
return catalogReport(discovery, "blocked", { ...emptyCatalog, reason: "network-blocked" }, "not-tested", []);
|
|
132
|
+
}
|
|
133
|
+
if (isAuthorizationFinding(discovery)) {
|
|
134
|
+
return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []);
|
|
135
|
+
}
|
|
136
|
+
if (discovery.status === "unsupported") {
|
|
137
|
+
return catalogReport(discovery, "unsupported", { ...emptyCatalog, reason: "discovery-unsupported" }, "not-tested", []);
|
|
138
|
+
}
|
|
139
|
+
if (discovery.status !== "discovered") {
|
|
140
|
+
return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: budgetReason ?? "discovery-failed" }, "not-tested", []);
|
|
141
|
+
}
|
|
142
|
+
const capabilities = discoveryResponse === null ? null : discoverMcpCapabilities(discoveryResponse);
|
|
143
|
+
if (capabilities === null) {
|
|
144
|
+
return catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "discovery-response-unavailable" }, "not-tested", []);
|
|
145
|
+
}
|
|
146
|
+
if (!Object.hasOwn(capabilities, "tools")) {
|
|
147
|
+
return catalogReport(discovery, "not-applicable", emptyCatalog, "not-tested", []);
|
|
148
|
+
}
|
|
149
|
+
const findings = [];
|
|
150
|
+
const names = new Map();
|
|
151
|
+
const seenCursors = new Set();
|
|
152
|
+
let pagesRead = 0;
|
|
153
|
+
let toolsChecked = 0;
|
|
154
|
+
let cursor;
|
|
155
|
+
let incompleteReason = null;
|
|
156
|
+
let blocked = false;
|
|
157
|
+
const addFindings = (items) => {
|
|
158
|
+
if (findings.length + items.length > MAX_FINDINGS) {
|
|
159
|
+
findings.push(...items.slice(0, MAX_FINDINGS - findings.length));
|
|
160
|
+
incompleteReason = "finding-limit";
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
findings.push(...items);
|
|
164
|
+
return true;
|
|
165
|
+
};
|
|
166
|
+
while (incompleteReason === null && !blocked) {
|
|
167
|
+
if (pagesRead >= MAX_PAGES) {
|
|
168
|
+
incompleteReason = "page-limit";
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
if (Math.floor(deadline - now()) < 1) {
|
|
172
|
+
incompleteReason = "total-time-limit";
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
const requestId = `tools-list-${pagesRead + 1}`;
|
|
176
|
+
let response;
|
|
177
|
+
try {
|
|
178
|
+
response = await requestWithinBudget(rawUrl, {
|
|
179
|
+
allowLocalNetwork: options.allowLocalNetwork,
|
|
180
|
+
lookup: options.lookup,
|
|
181
|
+
method: "POST",
|
|
182
|
+
body: requestBody(requestId, cursor),
|
|
183
|
+
headers: {
|
|
184
|
+
Accept: "application/json, text/event-stream",
|
|
185
|
+
"Content-Type": "application/json",
|
|
186
|
+
"MCP-Protocol-Version": REQUESTED_VERSION,
|
|
187
|
+
"Mcp-Method": "tools/list"
|
|
188
|
+
},
|
|
189
|
+
stopAfter: (received) => findMcpSseResponse(received, requestId) !== null
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof RemoteNetworkPolicyError) {
|
|
194
|
+
blocked = true;
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
|
|
198
|
+
? "request-timeout"
|
|
199
|
+
: error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
|
|
200
|
+
? "response-byte-limit"
|
|
201
|
+
: "request-failed");
|
|
202
|
+
}
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (response.statusCode === 401 || response.statusCode === 403) {
|
|
206
|
+
incompleteReason = "authorization-required";
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
const message = parseMcpResponse(response, requestId);
|
|
210
|
+
if (message !== null && hasMatchingEnvelope(message, requestId) && Object.hasOwn(message, "error")
|
|
211
|
+
&& validError(message.error) && message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404)) {
|
|
212
|
+
if (addFindings([finding("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.")]))
|
|
213
|
+
incompleteReason = "tools-list-method-unsupported";
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
if (response.statusCode !== 200) {
|
|
217
|
+
if (addFindings([finding("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.")]))
|
|
218
|
+
incompleteReason = "http-status-invalid";
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const page = message === null ? null : parseToolsResult(message, requestId);
|
|
222
|
+
if (page === null) {
|
|
223
|
+
if (addFindings([finding("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.")]))
|
|
224
|
+
incompleteReason = "response-invalid";
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
pagesRead += 1;
|
|
228
|
+
for (const tool of page.tools) {
|
|
229
|
+
if (toolsChecked >= MAX_TOOLS) {
|
|
230
|
+
incompleteReason = "tool-limit";
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
toolsChecked += 1;
|
|
234
|
+
const location = { page: pagesRead, toolIndex: toolsChecked };
|
|
235
|
+
if (!addFindings(inspectMcpToolDefinition(tool, location))) {
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
if (isPlainObject(tool) && typeof tool.name === "string") {
|
|
239
|
+
const previous = names.get(tool.name);
|
|
240
|
+
if (previous !== undefined && !addFindings([{
|
|
241
|
+
...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."),
|
|
242
|
+
location: { ...location, relatedToolIndex: previous }
|
|
243
|
+
}])) {
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
if (previous === undefined)
|
|
247
|
+
names.set(tool.name, toolsChecked);
|
|
248
|
+
}
|
|
249
|
+
if (deadline - now() <= 0) {
|
|
250
|
+
incompleteReason = "total-time-limit";
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (incompleteReason !== null)
|
|
255
|
+
break;
|
|
256
|
+
if (page.cursor === undefined)
|
|
257
|
+
break;
|
|
258
|
+
if (seenCursors.has(page.cursor)) {
|
|
259
|
+
if (addFindings([finding("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.")]))
|
|
260
|
+
incompleteReason = "cursor-loop";
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
seenCursors.add(page.cursor);
|
|
264
|
+
cursor = page.cursor;
|
|
265
|
+
}
|
|
266
|
+
if (blocked) {
|
|
267
|
+
return catalogReport(discovery, "blocked", { complete: false, pagesRead, toolsChecked, reason: "network-blocked" }, "incomplete", findings);
|
|
268
|
+
}
|
|
269
|
+
if (incompleteReason !== null) {
|
|
270
|
+
return catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: incompleteReason }, "incomplete", findings);
|
|
271
|
+
}
|
|
272
|
+
if (deadline - now() <= 0) {
|
|
273
|
+
return catalogReport(discovery, "incomplete", { complete: false, pagesRead, toolsChecked, reason: "total-time-limit" }, "incomplete", findings);
|
|
274
|
+
}
|
|
275
|
+
return catalogReport(discovery, finalStatus(findings), { complete: true, pagesRead, toolsChecked, reason: null }, "complete", findings);
|
|
276
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Finding } from "../domain/types.js";
|
|
2
|
+
export interface CatalogLocation {
|
|
3
|
+
page: number;
|
|
4
|
+
toolIndex: number;
|
|
5
|
+
schema?: "inputSchema" | "outputSchema";
|
|
6
|
+
relatedToolIndex?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface CatalogFinding extends Finding {
|
|
9
|
+
location?: CatalogLocation;
|
|
10
|
+
}
|
|
11
|
+
export declare function inspectMcpToolDefinition(value: unknown, location: {
|
|
12
|
+
page: number;
|
|
13
|
+
toolIndex: number;
|
|
14
|
+
}): CatalogFinding[];
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const CANONICAL_SCHEMA_DIALECT = "https://json-schema.org/draft/2020-12/schema";
|
|
2
|
+
const JSON_SCHEMA_TYPES = new Set([
|
|
3
|
+
"array",
|
|
4
|
+
"boolean",
|
|
5
|
+
"integer",
|
|
6
|
+
"null",
|
|
7
|
+
"number",
|
|
8
|
+
"object",
|
|
9
|
+
"string"
|
|
10
|
+
]);
|
|
11
|
+
const MCP_TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
|
|
12
|
+
function isJsonObject(value) {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
function hasOwn(object, key) {
|
|
16
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
17
|
+
}
|
|
18
|
+
function isAbsoluteUri(value) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(value).protocol.length > 0;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function schemaLocation(location, schema) {
|
|
27
|
+
return { ...location, schema };
|
|
28
|
+
}
|
|
29
|
+
function addFinding(findings, id, message, impact, suggestedFix, location) {
|
|
30
|
+
const severity = id === "plugin.catalog.tool.name.recommended" ||
|
|
31
|
+
id === "plugin.catalog.schema.dialect.unsupported"
|
|
32
|
+
? "warn"
|
|
33
|
+
: "fail";
|
|
34
|
+
findings.push({ id, severity, message, impact, suggestedFix, location });
|
|
35
|
+
}
|
|
36
|
+
function inspectRootSchema(schema, schemaSlot, location, findings) {
|
|
37
|
+
const locator = schemaLocation(location, schemaSlot);
|
|
38
|
+
const hasDialect = hasOwn(schema, "$schema");
|
|
39
|
+
const dialect = hasDialect ? schema.$schema : undefined;
|
|
40
|
+
if (hasDialect && (typeof dialect !== "string" || !isAbsoluteUri(dialect))) {
|
|
41
|
+
addFinding(findings, "plugin.catalog.schema.dialect.invalid", "The root schema dialect must be an absolute URI string when provided.", "The validator cannot reliably select a schema dialect for this tool.", "Omit $schema or provide an absolute JSON Schema dialect URI.", locator);
|
|
42
|
+
}
|
|
43
|
+
if (typeof dialect === "string" &&
|
|
44
|
+
isAbsoluteUri(dialect) &&
|
|
45
|
+
dialect !== CANONICAL_SCHEMA_DIALECT &&
|
|
46
|
+
dialect !== `${CANONICAL_SCHEMA_DIALECT}#`) {
|
|
47
|
+
addFinding(findings, "plugin.catalog.schema.dialect.unsupported", "The root schema uses a dialect outside this validator's supported baseline.", "The schema may be valid, but its root keywords are outside this validator's bounded coverage.", "Use the JSON Schema 2020-12 dialect or validate this schema with a dialect-aware validator.", locator);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (hasOwn(schema, "properties") && !isJsonObject(schema.properties)) {
|
|
51
|
+
addFinding(findings, "plugin.catalog.schema.properties.invalid", "The root schema properties keyword must be an object when provided.", "Clients cannot reliably interpret the declared object properties.", "Set properties to an object or omit it.", locator);
|
|
52
|
+
}
|
|
53
|
+
if (hasOwn(schema, "required")) {
|
|
54
|
+
const required = schema.required;
|
|
55
|
+
const isValidRequired = Array.isArray(required) &&
|
|
56
|
+
required.every((entry) => typeof entry === "string") &&
|
|
57
|
+
new Set(required).size === required.length;
|
|
58
|
+
if (!isValidRequired) {
|
|
59
|
+
addFinding(findings, "plugin.catalog.schema.required.invalid", "The root schema required keyword must be an array of unique strings when provided.", "Clients cannot reliably determine which inputs are required.", "Set required to an array of unique property names or omit it.", locator);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (hasOwn(schema, "type")) {
|
|
63
|
+
const type = schema.type;
|
|
64
|
+
const isValidType = (typeof type === "string" && JSON_SCHEMA_TYPES.has(type)) ||
|
|
65
|
+
(Array.isArray(type) &&
|
|
66
|
+
type.length > 0 &&
|
|
67
|
+
type.every((entry) => typeof entry === "string" && JSON_SCHEMA_TYPES.has(entry)) &&
|
|
68
|
+
new Set(type).size === type.length);
|
|
69
|
+
if (!isValidType) {
|
|
70
|
+
addFinding(findings, "plugin.catalog.schema.type.invalid", "The root schema type keyword must be a supported type string or a non-empty array of unique supported type strings.", "Clients cannot reliably interpret the root value type.", "Use JSON Schema primitive types for type, or omit the keyword.", locator);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (hasOwn(schema, "additionalProperties") &&
|
|
74
|
+
typeof schema.additionalProperties !== "boolean" &&
|
|
75
|
+
!isJsonObject(schema.additionalProperties)) {
|
|
76
|
+
addFinding(findings, "plugin.catalog.schema.additional_properties.invalid", "The root schema additionalProperties keyword must be a boolean or object when provided.", "Clients cannot reliably interpret whether additional object properties are allowed.", "Set additionalProperties to a boolean or object, or omit it.", locator);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function inspectMcpToolDefinition(value, location) {
|
|
80
|
+
const findings = [];
|
|
81
|
+
const baseLocation = { page: location.page, toolIndex: location.toolIndex };
|
|
82
|
+
if (!isJsonObject(value)) {
|
|
83
|
+
addFinding(findings, "plugin.catalog.tool.invalid_object", "A catalog tool entry must be an object.", "The validator cannot inspect a tool definition that is not an object.", "Return each tools/list entry as an object.", baseLocation);
|
|
84
|
+
return findings;
|
|
85
|
+
}
|
|
86
|
+
const name = hasOwn(value, "name") ? value.name : undefined;
|
|
87
|
+
if (typeof name !== "string") {
|
|
88
|
+
addFinding(findings, "plugin.catalog.tool.name.invalid", "A catalog tool name must be a string.", "Clients cannot identify or invoke a tool without a string name.", "Provide a string name for the tool.", baseLocation);
|
|
89
|
+
}
|
|
90
|
+
else if (!MCP_TOOL_NAME.test(name)) {
|
|
91
|
+
addFinding(findings, "plugin.catalog.tool.name.recommended", "The tool name does not meet the MCP recommended ASCII length and character constraints.", "Some clients may not accept or consistently display this tool name.", "Use 1 to 128 ASCII letters, digits, dots, underscores, or hyphens for the tool name.", baseLocation);
|
|
92
|
+
}
|
|
93
|
+
if (hasOwn(value, "title") && typeof value.title !== "string") {
|
|
94
|
+
addFinding(findings, "plugin.catalog.tool.title.invalid", "A catalog tool title must be a string when provided.", "Clients cannot reliably display a non-string tool title.", "Provide a string title or omit the title field.", baseLocation);
|
|
95
|
+
}
|
|
96
|
+
if (hasOwn(value, "description") && typeof value.description !== "string") {
|
|
97
|
+
addFinding(findings, "plugin.catalog.tool.description.invalid", "A catalog tool description must be a string when provided.", "Clients cannot reliably display a non-string tool description.", "Provide a string description or omit the description field.", baseLocation);
|
|
98
|
+
}
|
|
99
|
+
const inputSchema = hasOwn(value, "inputSchema") ? value.inputSchema : undefined;
|
|
100
|
+
if (!isJsonObject(inputSchema)) {
|
|
101
|
+
addFinding(findings, "plugin.catalog.schema.input.invalid", "A catalog tool inputSchema must be a non-null object.", "Clients cannot reliably construct valid tool inputs without an object schema.", "Provide inputSchema as an object.", schemaLocation(baseLocation, "inputSchema"));
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
inspectRootSchema(inputSchema, "inputSchema", baseLocation, findings);
|
|
105
|
+
}
|
|
106
|
+
if (hasOwn(value, "outputSchema")) {
|
|
107
|
+
const outputSchema = value.outputSchema;
|
|
108
|
+
if (!isJsonObject(outputSchema)) {
|
|
109
|
+
addFinding(findings, "plugin.catalog.schema.output.invalid", "A catalog tool outputSchema must be an object when provided.", "Clients cannot reliably interpret tool outputs without an object schema.", "Provide outputSchema as an object or omit it.", schemaLocation(baseLocation, "outputSchema"));
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
inspectRootSchema(outputSchema, "outputSchema", baseLocation, findings);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return findings;
|
|
116
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { CatalogFinding } from "./mcp-tool-definition.js";
|
|
2
|
+
export interface McpToolFileReport {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
scope: "tool-definitions-file";
|
|
5
|
+
status: "pass" | "warn" | "fail" | "incomplete" | "blocked";
|
|
6
|
+
source: {
|
|
7
|
+
kind: "file";
|
|
8
|
+
format: "mcp-tools-list-response";
|
|
9
|
+
bytesRead: number;
|
|
10
|
+
};
|
|
11
|
+
inspection: {
|
|
12
|
+
complete: boolean;
|
|
13
|
+
toolsChecked: number;
|
|
14
|
+
hasNextCursor: boolean | null;
|
|
15
|
+
reason: string | null;
|
|
16
|
+
};
|
|
17
|
+
coverage: {
|
|
18
|
+
schema: "root-shape-only";
|
|
19
|
+
discovery: "not-tested";
|
|
20
|
+
serverCatalog: "not-tested";
|
|
21
|
+
toolExecution: "not-tested";
|
|
22
|
+
fullConformance: "not-tested";
|
|
23
|
+
customHeaderAnnotations: "not-tested";
|
|
24
|
+
};
|
|
25
|
+
findings: CatalogFinding[];
|
|
26
|
+
}
|
|
27
|
+
export declare function inspectMcpToolFile(filePath: string): Promise<McpToolFileReport>;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, open } from "node:fs/promises";
|
|
3
|
+
import { inspectMcpToolDefinition } from "./mcp-tool-definition.js";
|
|
4
|
+
const MAX_FILE_BYTES = 1024 * 1024;
|
|
5
|
+
const MAX_TOOLS = 500;
|
|
6
|
+
const MAX_FINDINGS = 100;
|
|
7
|
+
function isJsonObject(value) {
|
|
8
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
function hasOwn(object, key) {
|
|
11
|
+
return Object.hasOwn(object, key);
|
|
12
|
+
}
|
|
13
|
+
function sameFile(left, right) {
|
|
14
|
+
return left.dev === right.dev && left.ino === right.ino;
|
|
15
|
+
}
|
|
16
|
+
function sameSnapshot(left, right) {
|
|
17
|
+
return sameFile(left, right)
|
|
18
|
+
&& left.size === right.size
|
|
19
|
+
&& left.mtimeMs === right.mtimeMs
|
|
20
|
+
&& left.ctimeMs === right.ctimeMs;
|
|
21
|
+
}
|
|
22
|
+
function isBlockedOfflinePath(filePath) {
|
|
23
|
+
return /^[A-Za-z][A-Za-z0-9+.-]+:/u.test(filePath)
|
|
24
|
+
|| /^[A-Za-z]:\/\//u.test(filePath)
|
|
25
|
+
|| /^[\\/]{2}/u.test(filePath);
|
|
26
|
+
}
|
|
27
|
+
function baseReport(bytesRead) {
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion: 1,
|
|
30
|
+
scope: "tool-definitions-file",
|
|
31
|
+
source: { kind: "file", format: "mcp-tools-list-response", bytesRead },
|
|
32
|
+
coverage: {
|
|
33
|
+
schema: "root-shape-only",
|
|
34
|
+
discovery: "not-tested",
|
|
35
|
+
serverCatalog: "not-tested",
|
|
36
|
+
toolExecution: "not-tested",
|
|
37
|
+
fullConformance: "not-tested",
|
|
38
|
+
customHeaderAnnotations: "not-tested"
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function report(bytesRead, status, inspection, findings) {
|
|
43
|
+
return { ...baseReport(bytesRead), status, inspection, findings };
|
|
44
|
+
}
|
|
45
|
+
function fileFinding(id, severity, message, impact, suggestedFix) {
|
|
46
|
+
return { id, severity, message, impact, suggestedFix };
|
|
47
|
+
}
|
|
48
|
+
function incompleteFileReport(bytesRead, reason, finding) {
|
|
49
|
+
return report(bytesRead, "incomplete", {
|
|
50
|
+
complete: false,
|
|
51
|
+
toolsChecked: 0,
|
|
52
|
+
hasNextCursor: null,
|
|
53
|
+
reason
|
|
54
|
+
}, [finding]);
|
|
55
|
+
}
|
|
56
|
+
function blockedFileReport(reason, finding) {
|
|
57
|
+
return report(0, "blocked", {
|
|
58
|
+
complete: false,
|
|
59
|
+
toolsChecked: 0,
|
|
60
|
+
hasNextCursor: null,
|
|
61
|
+
reason
|
|
62
|
+
}, [finding]);
|
|
63
|
+
}
|
|
64
|
+
function finalStatus(findings) {
|
|
65
|
+
if (findings.some((finding) => finding.severity === "fail"))
|
|
66
|
+
return "fail";
|
|
67
|
+
return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass";
|
|
68
|
+
}
|
|
69
|
+
function parseToolsResult(value) {
|
|
70
|
+
if (!isJsonObject(value)
|
|
71
|
+
|| value.jsonrpc !== "2.0"
|
|
72
|
+
|| !hasOwn(value, "id")
|
|
73
|
+
|| !(typeof value.id === "string" || (typeof value.id === "number" && Number.isFinite(value.id)))
|
|
74
|
+
|| !hasOwn(value, "result")
|
|
75
|
+
|| hasOwn(value, "error")
|
|
76
|
+
|| !isJsonObject(value.result)
|
|
77
|
+
|| value.result.resultType !== "complete"
|
|
78
|
+
|| !Array.isArray(value.result.tools)
|
|
79
|
+
|| typeof value.result.ttlMs !== "number"
|
|
80
|
+
|| !Number.isFinite(value.result.ttlMs)
|
|
81
|
+
|| value.result.ttlMs < 0
|
|
82
|
+
|| (value.result.cacheScope !== "public" && value.result.cacheScope !== "private")) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
const hasNextCursor = hasOwn(value.result, "nextCursor");
|
|
86
|
+
if (hasNextCursor && typeof value.result.nextCursor !== "string")
|
|
87
|
+
return null;
|
|
88
|
+
return { tools: value.result.tools, hasNextCursor };
|
|
89
|
+
}
|
|
90
|
+
async function readBoundedRegularFile(filePath) {
|
|
91
|
+
let initial;
|
|
92
|
+
try {
|
|
93
|
+
initial = await lstat(filePath);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return { kind: "unavailable", bytesRead: 0 };
|
|
97
|
+
}
|
|
98
|
+
if (initial.isSymbolicLink())
|
|
99
|
+
return { kind: "symlink", bytesRead: 0 };
|
|
100
|
+
if (!initial.isFile())
|
|
101
|
+
return { kind: "not-regular", bytesRead: 0 };
|
|
102
|
+
if (!Number.isSafeInteger(initial.size) || initial.size < 0 || initial.size > MAX_FILE_BYTES) {
|
|
103
|
+
return { kind: "too-large", bytesRead: 0 };
|
|
104
|
+
}
|
|
105
|
+
const flags = process.platform === "win32"
|
|
106
|
+
? constants.O_RDONLY
|
|
107
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
108
|
+
let fileHandle = null;
|
|
109
|
+
let bytesRead = 0;
|
|
110
|
+
try {
|
|
111
|
+
fileHandle = await open(filePath, flags);
|
|
112
|
+
const opened = await fileHandle.stat();
|
|
113
|
+
const current = await lstat(filePath);
|
|
114
|
+
if (!opened.isFile() || current.isSymbolicLink() || !current.isFile() || !sameSnapshot(initial, opened) || !sameSnapshot(opened, current)) {
|
|
115
|
+
return { kind: "changed", bytesRead };
|
|
116
|
+
}
|
|
117
|
+
if (!Number.isSafeInteger(opened.size) || opened.size < 0 || opened.size > MAX_FILE_BYTES) {
|
|
118
|
+
return { kind: "too-large", bytesRead };
|
|
119
|
+
}
|
|
120
|
+
const content = Buffer.alloc(opened.size);
|
|
121
|
+
while (bytesRead < content.length) {
|
|
122
|
+
const read = await fileHandle.read(content, bytesRead, content.length - bytesRead, bytesRead);
|
|
123
|
+
if (read.bytesRead === 0)
|
|
124
|
+
return { kind: "changed", bytesRead };
|
|
125
|
+
bytesRead += read.bytesRead;
|
|
126
|
+
}
|
|
127
|
+
const probe = Buffer.alloc(1);
|
|
128
|
+
const extra = await fileHandle.read(probe, 0, 1, bytesRead);
|
|
129
|
+
bytesRead += extra.bytesRead;
|
|
130
|
+
if (extra.bytesRead !== 0)
|
|
131
|
+
return { kind: "changed", bytesRead };
|
|
132
|
+
const completed = await fileHandle.stat();
|
|
133
|
+
const final = await lstat(filePath);
|
|
134
|
+
if (!final.isFile() || final.isSymbolicLink() || !sameSnapshot(opened, completed) || !sameSnapshot(completed, final)) {
|
|
135
|
+
return { kind: "changed", bytesRead };
|
|
136
|
+
}
|
|
137
|
+
return { kind: "content", content, bytesRead };
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return { kind: "unavailable", bytesRead };
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
await fileHandle?.close().catch(() => undefined);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export async function inspectMcpToolFile(filePath) {
|
|
147
|
+
if (isBlockedOfflinePath(filePath)) {
|
|
148
|
+
return blockedFileReport("offline-path-blocked", fileFinding("plugin.tools_file.path.blocked", "fail", "The local tool definition path is not permitted for offline inspection.", "The offline tool definition inspection does not access remote or device paths.", "Provide a local regular file path."));
|
|
149
|
+
}
|
|
150
|
+
const file = await readBoundedRegularFile(filePath);
|
|
151
|
+
if (file.kind === "unavailable") {
|
|
152
|
+
return incompleteFileReport(file.bytesRead, "file-unavailable", fileFinding("plugin.tools_file.file.unavailable", "fail", "The local tool definition file could not be read.", "The offline tool definition inspection could not start.", "Provide a readable local regular file."));
|
|
153
|
+
}
|
|
154
|
+
if (file.kind === "too-large") {
|
|
155
|
+
return incompleteFileReport(file.bytesRead, "file-size-limit", fileFinding("plugin.tools_file.file.too_large", "fail", "The local tool definition file exceeds the one MiB inspection limit.", "The offline tool definition inspection cannot safely read this file within its byte limit.", "Provide a tool definition file no larger than one MiB."));
|
|
156
|
+
}
|
|
157
|
+
if (file.kind === "changed") {
|
|
158
|
+
return incompleteFileReport(file.bytesRead, "file-changed", fileFinding("plugin.tools_file.file.changed", "fail", "The local tool definition file changed during inspection.", "The offline tool definition inspection cannot rely on an inconsistent file snapshot.", "Retry after the file is stable."));
|
|
159
|
+
}
|
|
160
|
+
if (file.kind === "symlink") {
|
|
161
|
+
return blockedFileReport("final-symlink", fileFinding("plugin.tools_file.file.symlink", "fail", "The local tool definition target is a symbolic link.", "The offline tool definition inspection does not follow final symbolic links.", "Provide a regular file directly."));
|
|
162
|
+
}
|
|
163
|
+
if (file.kind === "not-regular") {
|
|
164
|
+
return blockedFileReport("non-regular-file", fileFinding("plugin.tools_file.file.not_regular", "fail", "The local tool definition target is not a regular file.", "The offline tool definition inspection reads only bounded regular files.", "Provide a regular file directly."));
|
|
165
|
+
}
|
|
166
|
+
let decoded;
|
|
167
|
+
try {
|
|
168
|
+
decoded = new TextDecoder("utf-8", { fatal: true }).decode(file.content);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return incompleteFileReport(file.bytesRead, "invalid-utf8", fileFinding("plugin.tools_file.encoding.invalid", "fail", "The local tool definition file is not valid UTF-8.", "The offline tool definition inspection cannot parse the file safely.", "Save the tools/list response as UTF-8 JSON."));
|
|
172
|
+
}
|
|
173
|
+
let value;
|
|
174
|
+
try {
|
|
175
|
+
value = JSON.parse(decoded);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return incompleteFileReport(file.bytesRead, "invalid-json", fileFinding("plugin.tools_file.json.invalid", "fail", "The local tool definition file is not valid JSON.", "The offline tool definition inspection cannot parse the file safely.", "Provide one UTF-8 JSON-RPC tools/list response."));
|
|
179
|
+
}
|
|
180
|
+
const parsed = parseToolsResult(value);
|
|
181
|
+
if (parsed === null) {
|
|
182
|
+
return incompleteFileReport(file.bytesRead, "response-invalid", fileFinding("plugin.tools_file.response.invalid", "fail", "The local JSON value is not a supported complete tools/list response.", "The offline tool definition inspection cannot identify a valid tool list.", "Provide one JSON-RPC 2.0 complete tools/list result with valid cache metadata."));
|
|
183
|
+
}
|
|
184
|
+
const findings = [];
|
|
185
|
+
const names = new Map();
|
|
186
|
+
let reason = null;
|
|
187
|
+
let toolsChecked = 0;
|
|
188
|
+
const addFindings = (items) => {
|
|
189
|
+
const remaining = MAX_FINDINGS - findings.length;
|
|
190
|
+
if (items.length > remaining) {
|
|
191
|
+
findings.push(...items.slice(0, remaining));
|
|
192
|
+
reason = "finding-limit";
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
findings.push(...items);
|
|
196
|
+
return true;
|
|
197
|
+
};
|
|
198
|
+
for (const tool of parsed.tools) {
|
|
199
|
+
if (toolsChecked >= MAX_TOOLS) {
|
|
200
|
+
reason = "tool-limit";
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
toolsChecked += 1;
|
|
204
|
+
const location = { page: 1, toolIndex: toolsChecked };
|
|
205
|
+
if (!addFindings(inspectMcpToolDefinition(tool, location)))
|
|
206
|
+
break;
|
|
207
|
+
if (isJsonObject(tool) && typeof tool.name === "string") {
|
|
208
|
+
const previous = names.get(tool.name);
|
|
209
|
+
if (previous !== undefined && !addFindings([fileFinding("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.")])) {
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (previous !== undefined) {
|
|
213
|
+
findings[findings.length - 1] = {
|
|
214
|
+
...findings[findings.length - 1],
|
|
215
|
+
location: { ...location, relatedToolIndex: previous }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
names.set(tool.name, toolsChecked);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (reason === null && parsed.tools.length > MAX_TOOLS)
|
|
224
|
+
reason = "tool-limit";
|
|
225
|
+
if (reason === null && parsed.hasNextCursor)
|
|
226
|
+
reason = "more-pages-present";
|
|
227
|
+
if (reason !== null) {
|
|
228
|
+
return report(file.bytesRead, "incomplete", {
|
|
229
|
+
complete: false,
|
|
230
|
+
toolsChecked,
|
|
231
|
+
hasNextCursor: parsed.hasNextCursor,
|
|
232
|
+
reason
|
|
233
|
+
}, findings);
|
|
234
|
+
}
|
|
235
|
+
return report(file.bytesRead, finalStatus(findings), {
|
|
236
|
+
complete: true,
|
|
237
|
+
toolsChecked,
|
|
238
|
+
hasNextCursor: false,
|
|
239
|
+
reason: null
|
|
240
|
+
}, findings);
|
|
241
|
+
}
|
|
@@ -641,6 +641,130 @@ const publicSchemaDefinitions = [
|
|
|
641
641
|
id: "doctor.snapshot.json",
|
|
642
642
|
command: "codex-plugin-doctor doctor snapshot --json",
|
|
643
643
|
required: ["schemaVersion", "generatedAt", "version", "environment", "clients", "installedPlugins", "nextCommands"]
|
|
644
|
+
},
|
|
645
|
+
{
|
|
646
|
+
id: "doctor.tools.json",
|
|
647
|
+
command: "codex-plugin-doctor doctor tools <url> --allow-network --json",
|
|
648
|
+
required: ["schemaVersion", "scope", "status", "discovery", "catalog", "coverage", "findings"],
|
|
649
|
+
properties: {
|
|
650
|
+
schemaVersion: { const: 1 },
|
|
651
|
+
scope: { const: "tool-catalog-structure" },
|
|
652
|
+
status: { enum: ["pass", "warn", "fail", "unsupported", "not-applicable", "incomplete", "blocked"] },
|
|
653
|
+
discovery: {
|
|
654
|
+
type: "object",
|
|
655
|
+
required: ["schemaVersion", "requestedVersion", "scope", "status", "supportedVersions", "findings", "coverage"],
|
|
656
|
+
properties: {
|
|
657
|
+
schemaVersion: { const: 1 },
|
|
658
|
+
requestedVersion: { const: "2026-07-28" },
|
|
659
|
+
scope: { const: "discovery-only" },
|
|
660
|
+
status: { enum: ["discovered", "unsupported", "blocked", "failed"] },
|
|
661
|
+
supportedVersions: { type: "array", maxItems: 32, uniqueItems: true, items: { type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" } },
|
|
662
|
+
findings: { type: "array" },
|
|
663
|
+
coverage: {
|
|
664
|
+
type: "object", required: ["discovery", "runtime"],
|
|
665
|
+
properties: { discovery: { enum: ["pass", "fail", "skipped"] }, runtime: { const: "not-tested" } },
|
|
666
|
+
additionalProperties: false
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
additionalProperties: false
|
|
670
|
+
},
|
|
671
|
+
catalog: {
|
|
672
|
+
type: "object", required: ["complete", "pagesRead", "toolsChecked", "reason"],
|
|
673
|
+
properties: {
|
|
674
|
+
complete: { type: "boolean" },
|
|
675
|
+
pagesRead: { type: "integer", minimum: 0, maximum: 5 },
|
|
676
|
+
toolsChecked: { type: "integer", minimum: 0, maximum: 500 },
|
|
677
|
+
reason: { type: ["string", "null"] }
|
|
678
|
+
},
|
|
679
|
+
additionalProperties: false
|
|
680
|
+
},
|
|
681
|
+
coverage: {
|
|
682
|
+
type: "object",
|
|
683
|
+
required: ["discovery", "catalogEnumeration", "schema", "toolExecution", "fullConformance", "customHeaderAnnotations"],
|
|
684
|
+
properties: {
|
|
685
|
+
discovery: { enum: ["pass", "fail", "skipped"] },
|
|
686
|
+
catalogEnumeration: { enum: ["complete", "incomplete", "not-tested"] },
|
|
687
|
+
schema: { const: "root-shape-only" },
|
|
688
|
+
toolExecution: { const: "not-tested" },
|
|
689
|
+
fullConformance: { const: "not-tested" },
|
|
690
|
+
customHeaderAnnotations: { const: "not-tested" }
|
|
691
|
+
},
|
|
692
|
+
additionalProperties: false
|
|
693
|
+
},
|
|
694
|
+
findings: {
|
|
695
|
+
type: "array", maxItems: 100,
|
|
696
|
+
items: {
|
|
697
|
+
type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
|
|
698
|
+
properties: {
|
|
699
|
+
id: { type: "string", pattern: "^plugin\\.catalog\\." },
|
|
700
|
+
severity: { enum: ["warn", "fail"] },
|
|
701
|
+
message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
|
|
702
|
+
location: {
|
|
703
|
+
type: "object", required: ["page", "toolIndex"],
|
|
704
|
+
properties: {
|
|
705
|
+
page: { type: "integer", minimum: 1, maximum: 5 },
|
|
706
|
+
toolIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
707
|
+
schema: { enum: ["inputSchema", "outputSchema"] },
|
|
708
|
+
relatedToolIndex: { type: "integer", minimum: 1, maximum: 500 }
|
|
709
|
+
},
|
|
710
|
+
additionalProperties: false
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
additionalProperties: false
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
id: "doctor.tools.file.json",
|
|
720
|
+
command: "codex-plugin-doctor doctor tools-file <path> --json",
|
|
721
|
+
required: ["schemaVersion", "scope", "status", "source", "inspection", "coverage", "findings"],
|
|
722
|
+
properties: {
|
|
723
|
+
schemaVersion: { const: 1 },
|
|
724
|
+
scope: { const: "tool-definitions-file" },
|
|
725
|
+
status: { enum: ["pass", "warn", "fail", "incomplete", "blocked"] },
|
|
726
|
+
source: {
|
|
727
|
+
type: "object", required: ["kind", "format", "bytesRead"],
|
|
728
|
+
properties: {
|
|
729
|
+
kind: { const: "file" }, format: { const: "mcp-tools-list-response" },
|
|
730
|
+
bytesRead: { type: "integer", minimum: 0, maximum: 1048577 }
|
|
731
|
+
}, additionalProperties: false
|
|
732
|
+
},
|
|
733
|
+
inspection: {
|
|
734
|
+
type: "object", required: ["complete", "toolsChecked", "hasNextCursor", "reason"],
|
|
735
|
+
properties: {
|
|
736
|
+
complete: { type: "boolean" }, toolsChecked: { type: "integer", minimum: 0, maximum: 500 },
|
|
737
|
+
hasNextCursor: { type: ["boolean", "null"] }, reason: { type: ["string", "null"] }
|
|
738
|
+
}, additionalProperties: false
|
|
739
|
+
},
|
|
740
|
+
coverage: {
|
|
741
|
+
type: "object", required: ["schema", "discovery", "serverCatalog", "toolExecution", "fullConformance", "customHeaderAnnotations"],
|
|
742
|
+
properties: {
|
|
743
|
+
schema: { const: "root-shape-only" }, discovery: { const: "not-tested" },
|
|
744
|
+
serverCatalog: { const: "not-tested" }, toolExecution: { const: "not-tested" },
|
|
745
|
+
fullConformance: { const: "not-tested" }, customHeaderAnnotations: { const: "not-tested" }
|
|
746
|
+
}, additionalProperties: false
|
|
747
|
+
},
|
|
748
|
+
findings: {
|
|
749
|
+
type: "array", maxItems: 100,
|
|
750
|
+
items: {
|
|
751
|
+
type: "object", required: ["id", "severity", "message", "impact", "suggestedFix"],
|
|
752
|
+
properties: {
|
|
753
|
+
id: { type: "string", pattern: "^plugin\\.(tools_file|catalog)\\." },
|
|
754
|
+
severity: { enum: ["warn", "fail"] },
|
|
755
|
+
message: { type: "string" }, impact: { type: "string" }, suggestedFix: { type: "string" },
|
|
756
|
+
location: {
|
|
757
|
+
type: "object", required: ["page", "toolIndex"],
|
|
758
|
+
properties: {
|
|
759
|
+
page: { const: 1 }, toolIndex: { type: "integer", minimum: 1, maximum: 500 },
|
|
760
|
+
schema: { enum: ["inputSchema", "outputSchema"] },
|
|
761
|
+
relatedToolIndex: { type: "integer", minimum: 1, maximum: 500 }
|
|
762
|
+
}, additionalProperties: false
|
|
763
|
+
}
|
|
764
|
+
}, additionalProperties: false
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
644
768
|
}
|
|
645
769
|
];
|
|
646
770
|
function sha256(value) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export function renderMcpToolCatalogReport(report) {
|
|
2
|
+
return [
|
|
3
|
+
"MCP Tool Catalog",
|
|
4
|
+
"================",
|
|
5
|
+
`Status: ${report.status}`,
|
|
6
|
+
`Scope: ${report.scope}`,
|
|
7
|
+
`Discovery: ${report.discovery.status}`,
|
|
8
|
+
`Catalog complete: ${report.catalog.complete}`,
|
|
9
|
+
`Pages read: ${report.catalog.pagesRead}`,
|
|
10
|
+
`Tools checked: ${report.catalog.toolsChecked}`,
|
|
11
|
+
...(report.catalog.reason === null ? [] : [`Reason: ${report.catalog.reason}`]),
|
|
12
|
+
`Enumeration: ${report.coverage.catalogEnumeration}`,
|
|
13
|
+
`Schema coverage: ${report.coverage.schema}`,
|
|
14
|
+
`Tool execution: ${report.coverage.toolExecution}`,
|
|
15
|
+
`Custom header annotations: ${report.coverage.customHeaderAnnotations}`,
|
|
16
|
+
`Full conformance: ${report.coverage.fullConformance}`,
|
|
17
|
+
"Counts describe inspected content, not a guaranteed server-wide inventory.",
|
|
18
|
+
...report.discovery.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`),
|
|
19
|
+
...report.findings.map((finding) => {
|
|
20
|
+
const location = finding.location;
|
|
21
|
+
const locator = location ? ` [page ${location.page}, tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}]` : "";
|
|
22
|
+
return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
23
|
+
})
|
|
24
|
+
].join("\n");
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function renderMcpToolFileReport(report) {
|
|
2
|
+
return [
|
|
3
|
+
"Offline MCP Tool Definitions",
|
|
4
|
+
"============================",
|
|
5
|
+
`Status: ${report.status}`,
|
|
6
|
+
`Scope: ${report.scope}`,
|
|
7
|
+
`Source: ${report.source.kind} (${report.source.format})`,
|
|
8
|
+
`Bytes read: ${report.source.bytesRead}`,
|
|
9
|
+
`Inspection complete: ${report.inspection.complete}`,
|
|
10
|
+
`Tools checked: ${report.inspection.toolsChecked}`,
|
|
11
|
+
`More pages advertised: ${report.inspection.hasNextCursor ?? "unknown"}`,
|
|
12
|
+
...(report.inspection.reason === null ? [] : [`Reason: ${report.inspection.reason}`]),
|
|
13
|
+
`Schema coverage: ${report.coverage.schema}`,
|
|
14
|
+
`Discovery: ${report.coverage.discovery}`,
|
|
15
|
+
`Server catalog: ${report.coverage.serverCatalog}`,
|
|
16
|
+
`Tool execution: ${report.coverage.toolExecution}`,
|
|
17
|
+
`Full conformance: ${report.coverage.fullConformance}`,
|
|
18
|
+
`Custom header annotations: ${report.coverage.customHeaderAnnotations}`,
|
|
19
|
+
"Only the supplied file was inspected. Its provenance and server completeness are not established.",
|
|
20
|
+
...report.findings.map((finding) => {
|
|
21
|
+
const location = finding.location;
|
|
22
|
+
const locator = location ? ` [tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}]` : "";
|
|
23
|
+
return `${finding.severity.toUpperCase()} ${finding.id}${locator}: ${finding.message}\n ${finding.suggestedFix}`;
|
|
24
|
+
})
|
|
25
|
+
].join("\n");
|
|
26
|
+
}
|
package/dist/run-cli.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
2
|
+
import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
3
|
+
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
2
4
|
import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
|
|
3
5
|
import { runCheck } from "./index.js";
|
|
4
6
|
import { writeRawDoctorConfig } from "./index.js";
|
|
@@ -14,6 +16,8 @@ export interface CliTerminalContext {
|
|
|
14
16
|
platform?: NodeJS.Platform;
|
|
15
17
|
}
|
|
16
18
|
export interface RunCliOptions {
|
|
19
|
+
inspectMcpToolFileImpl?: typeof inspectMcpToolFile;
|
|
20
|
+
inspectMcpToolCatalogImpl?: typeof inspectMcpToolCatalog;
|
|
17
21
|
discoverMcpServerImpl?: typeof discoverMcpServer;
|
|
18
22
|
terminalContext?: CliTerminalContext;
|
|
19
23
|
runCheckImpl?: typeof runCheck;
|
package/dist/run-cli.js
CHANGED
|
@@ -22,6 +22,10 @@ import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutp
|
|
|
22
22
|
import { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
|
|
23
23
|
import { buildSubmissionPreflight } from "./core/submission-preflight.js";
|
|
24
24
|
import { discoverMcpServer } from "./core/mcp-discovery.js";
|
|
25
|
+
import { inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
|
|
26
|
+
import { inspectMcpToolFile } from "./core/mcp-tool-file.js";
|
|
27
|
+
import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
|
|
28
|
+
import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
|
|
25
29
|
import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
|
|
26
30
|
import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
|
|
27
31
|
import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
|
|
@@ -101,6 +105,8 @@ function writeExactStdout(io, message) {
|
|
|
101
105
|
}
|
|
102
106
|
class CliUsageError extends Error {
|
|
103
107
|
}
|
|
108
|
+
const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
|
|
109
|
+
const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json]";
|
|
104
110
|
const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
|
|
105
111
|
function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
|
|
106
112
|
const index = flags.indexOf("--sandbox");
|
|
@@ -185,6 +191,8 @@ function parseRemoteNetworkFlags(flags, runtime) {
|
|
|
185
191
|
function printUsage(io) {
|
|
186
192
|
io.writeStderr("Usage: codex-plugin-doctor check <path|--installed> [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output <path>] [--history <path>] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest <digest>] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since <ref>] [--fail-on <rule-id>]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output <path>] [--cache] [--changed]\n codex-plugin-doctor audit deps <path> [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output <path>]\n codex-plugin-doctor mcp <path> [--runtime [--allow-network [--allow-local-network]]] [--json] [--output <path>]\n codex-plugin-doctor security <path> [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat <path> [--all|--client <client>] [--json] [--scorecard] [--output <path>] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add <path> [--fingerprint <sha256> --reason <text> --expires-at YYYY-MM-DD] [--config <path>] [--json]\n codex-plugin-doctor suppress list <path> [--config <path>] [--json]\n codex-plugin-doctor suppress remove <path> [--fingerprint <sha256>|--index <n>] [--config <path>] [--json]\n codex-plugin-doctor fix <path> (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history <history.jsonl> [--json] [--fail-on-regression]\n codex-plugin-doctor watch <path> [--runtime] [--json] [--output <path>] [--debounce-ms <ms>] [--max-iterations <n>] [--fail-fast] [--accumulate-json <path>]\n codex-plugin-doctor doctor [npm <package>|contract|corpus [--manifest <corpus.json>] [--json] [--output <path>]|corpus metrics --manifest <corpus.json> [--json|--markdown] [--output <path>] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan <path> [--sandbox docker] [--json|--markdown] [--output <path>]|runtime-policy <path> [--sandbox docker] [--json] [--output <path>]|review-bundle <path> --output <dir> --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify <bundle-dir> --target <path> --sign-key-env NAME [--json] [--output <path>] [--failures-only]|review-bundle diff --before <dir> --after <dir> [--json]|attest <path> [--sign-key-env NAME]|attest verify <attestation.json> --target <path> --sign-key-env NAME|release-evidence <path> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>]|release-evidence verify <evidence.json> --target <path> --sign-key-env NAME|release-evidence asset <path> --tag <tag> --output <evidence.json> --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest <digest>] [--upload]|mcp <path> [--runtime [--allow-network [--allow-local-network]]]|inspector <path>|diff --before <path> --after <path>|recommend <path>|trust <path>|perf <path> [--max-total-ms <ms>] [--max-stage-ms stage=ms]|export --bundle <path>|snapshot|clients|--json|--update-check]\n codex-plugin-doctor init [path] [--template skill-only|mcp-stdio|mcp-http|full-runtime]\n codex-plugin-doctor init-ci [path]\n codex-plugin-doctor init-git-hooks [path] [--force] [--json]\n codex-plugin-doctor init-git-hooks [path] --remove [--json]\n codex-plugin-doctor completion bash|zsh|fish\n codex-plugin-doctor config validate <path> [--json]\n codex-plugin-doctor release check <path> [--json] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker]\n codex-plugin-doctor self-test\n codex-plugin-doctor list --installed\n codex-plugin-doctor explain <finding-id>\n codex-plugin-doctor --version\n\nFirst run:\n codex-plugin-doctor doctor\n codex-plugin-doctor self-test\n codex-plugin-doctor init my-plugin\n codex-plugin-doctor check . --runtime --explain");
|
|
187
193
|
io.writeStderr(discoveryUsage);
|
|
194
|
+
io.writeStderr(toolsUsage);
|
|
195
|
+
io.writeStderr(toolsFileUsage);
|
|
188
196
|
io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
189
197
|
+ " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
|
|
190
198
|
+ "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
|
|
@@ -1195,6 +1203,52 @@ export async function runCli(args, io = defaultIo, options = {}) {
|
|
|
1195
1203
|
return 0;
|
|
1196
1204
|
}
|
|
1197
1205
|
if (command === "doctor") {
|
|
1206
|
+
if (maybePath === "tools-file") {
|
|
1207
|
+
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1208
|
+
io.writeStdout(`${toolsFileUsage}\nInspects one saved UTF-8 MCP tools/list response. No server connection or tool execution.`);
|
|
1209
|
+
return 0;
|
|
1210
|
+
}
|
|
1211
|
+
const [filePath, ...flags] = remainingArgs;
|
|
1212
|
+
if (!filePath || filePath.startsWith("-") || flags.some((flag) => flag !== "--json") || flags.length > 1) {
|
|
1213
|
+
io.writeStderr(`Invalid file inspection arguments. ${toolsFileUsage}`);
|
|
1214
|
+
return 2;
|
|
1215
|
+
}
|
|
1216
|
+
const report = await (options.inspectMcpToolFileImpl ?? inspectMcpToolFile)(filePath);
|
|
1217
|
+
io.writeStdout(flags.includes("--json")
|
|
1218
|
+
? JSON.stringify(report, null, 2)
|
|
1219
|
+
: renderMcpToolFileReport(report));
|
|
1220
|
+
return report.status === "pass" ? 0
|
|
1221
|
+
: report.status === "warn" || report.status === "fail" ? 1 : 2;
|
|
1222
|
+
}
|
|
1223
|
+
if (maybePath === "tools") {
|
|
1224
|
+
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1225
|
+
io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.`);
|
|
1226
|
+
return 0;
|
|
1227
|
+
}
|
|
1228
|
+
const [url, ...flags] = remainingArgs;
|
|
1229
|
+
if (!url || url.startsWith("-")) {
|
|
1230
|
+
io.writeStderr(toolsUsage);
|
|
1231
|
+
return 2;
|
|
1232
|
+
}
|
|
1233
|
+
const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
|
|
1234
|
+
if (flags.some((flag) => !allowedFlags.has(flag)) || new Set(flags).size !== flags.length) {
|
|
1235
|
+
io.writeStderr(`Invalid or duplicate tool catalog arguments. ${toolsUsage}`);
|
|
1236
|
+
return 2;
|
|
1237
|
+
}
|
|
1238
|
+
if (!flags.includes("--allow-network")) {
|
|
1239
|
+
io.writeStderr("doctor tools requires explicit --allow-network consent.");
|
|
1240
|
+
return 2;
|
|
1241
|
+
}
|
|
1242
|
+
const report = await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, {
|
|
1243
|
+
allowNetwork: true,
|
|
1244
|
+
allowLocalNetwork: flags.includes("--allow-local-network")
|
|
1245
|
+
});
|
|
1246
|
+
io.writeStdout(flags.includes("--json")
|
|
1247
|
+
? JSON.stringify(report, null, 2)
|
|
1248
|
+
: renderMcpToolCatalogReport(report));
|
|
1249
|
+
return report.status === "pass" ? 0
|
|
1250
|
+
: report.status === "incomplete" || report.status === "blocked" ? 2 : 1;
|
|
1251
|
+
}
|
|
1198
1252
|
if (maybePath === "discover") {
|
|
1199
1253
|
if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
|
|
1200
1254
|
io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.`);
|
package/package.json
CHANGED