codex-plugin-doctor 1.68.0 → 1.70.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -8
- package/dist/core/bounded-http-client.d.ts +5 -2
- package/dist/core/bounded-http-client.js +14 -0
- package/dist/core/mcp-discovery.d.ts +20 -0
- package/dist/core/mcp-discovery.js +31 -7
- package/dist/core/mcp-stdio-tool-catalog.d.ts +31 -0
- package/dist/core/mcp-stdio-tool-catalog.js +595 -0
- package/dist/core/mcp-tool-catalog.d.ts +37 -4
- package/dist/core/mcp-tool-catalog.js +103 -75
- package/dist/core/mcp-tool-impact.js +10 -6
- package/dist/core/output-contract.js +20 -0
- package/dist/core/runtime-probe.d.ts +3 -1
- package/dist/core/runtime-probe.js +2 -2
- package/dist/reporting/render-mcp-tool-catalog-report.js +7 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.d.ts +2 -0
- package/dist/reporting/render-mcp-tool-diff-markdown.js +33 -0
- package/dist/run-cli.d.ts +2 -0
- package/dist/run-cli.js +182 -42
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -120,14 +120,14 @@ codex-plugin-doctor doctor tools-diff --before ./tools-old.json --after ./tools-
|
|
|
120
120
|
This reports added, removed, and structurally changed definitions using numeric
|
|
121
121
|
locations, and labels each change `breaking`, `compatible`, or `unclassified`
|
|
122
122
|
with fixed reason codes. Add `--fail-on breaking` to fail CI only on breaking or
|
|
123
|
-
unclassified changes. Incomplete inputs, invalid definitions, or duplicate names
|
|
123
|
+
unclassified changes, and `--markdown` for pull request tables. Incomplete inputs, invalid definitions, or duplicate names
|
|
124
124
|
prevent a comparison. Impact labels follow nested object properties, array
|
|
125
125
|
items, and common length and range bounds, but remain heuristics, not proof of
|
|
126
126
|
compatibility.
|
|
127
127
|
See [Offline MCP Tool Diff](docs/guides/offline-tool-diff.md) for matching rules,
|
|
128
128
|
privacy, limits, and exit codes.
|
|
129
129
|
|
|
130
|
-
###
|
|
130
|
+
### MCP Tool Catalog
|
|
131
131
|
|
|
132
132
|
Inspect a modern HTTP MCP server's tool definitions without invoking them:
|
|
133
133
|
|
|
@@ -141,11 +141,27 @@ and partial inspection limits. It does not claim full schema validation, tool
|
|
|
141
141
|
execution, or client compatibility. Existing `doctor discover` behavior is unchanged.
|
|
142
142
|
Add `--save-response <path>` to record a completely enumerated catalog as a
|
|
143
143
|
`tools/list` response for `tools-file` and `tools-diff`; nothing is written when
|
|
144
|
-
enumeration is incomplete.
|
|
144
|
+
enumeration is incomplete. For protected servers, `--bearer-token-env <NAME>`
|
|
145
|
+
sends a bearer token from an environment variable only over HTTPS or to an
|
|
146
|
+
approved loopback endpoint; it is never printed or saved. The GitHub Action can compare saved responses with
|
|
145
147
|
`tools-diff-before`, `tools-diff-after`, and `tools-diff-fail-on`, capture the
|
|
146
|
-
live list with `tools-capture-url
|
|
147
|
-
`check: "false"` in repositories that only ship an MCP
|
|
148
|
-
|
|
148
|
+
live list with `tools-capture-url` (and `tools-capture-token-env`), and skip the
|
|
149
|
+
plugin package check with `check: "false"` in repositories that only ship an MCP
|
|
150
|
+
server.
|
|
151
|
+
|
|
152
|
+
Local stdio servers from a package's MCP configuration can be cataloged too.
|
|
153
|
+
`--runtime` is required because this starts package code; the server is stopped
|
|
154
|
+
after its tool list is read:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
codex-plugin-doctor doctor tools ./my-plugin --server my-server --runtime --save-response ./tools-current.json
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Doctor tries `server/discover` (`2026-07-28`) first and falls back to the
|
|
161
|
+
`initialize` handshake for older stdio servers, so the saved list works with
|
|
162
|
+
`tools-file` and `tools-diff` either way. `--sandbox docker` and
|
|
163
|
+
`--require-runtime-approval` work as they do for `check --runtime`.
|
|
164
|
+
See [MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
|
|
149
165
|
|
|
150
166
|
### Remote MCP Readiness
|
|
151
167
|
|
|
@@ -565,9 +581,9 @@ jobs:
|
|
|
565
581
|
runs-on: ubuntu-latest
|
|
566
582
|
steps:
|
|
567
583
|
- uses: actions/checkout@v5
|
|
568
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
584
|
+
- uses: Esquetta/CodexPluginDoctor@v1.70.0
|
|
569
585
|
with:
|
|
570
|
-
version: "1.
|
|
586
|
+
version: "1.70.0"
|
|
571
587
|
path: .
|
|
572
588
|
runtime: "true"
|
|
573
589
|
policy: codex-publish
|
|
@@ -7,17 +7,20 @@ export interface BoundedHttpRequestOptions {
|
|
|
7
7
|
method?: string;
|
|
8
8
|
body?: string | Buffer;
|
|
9
9
|
headers?: Record<string, string | undefined>;
|
|
10
|
+
bearerToken?: string;
|
|
10
11
|
stopAfter?: (body: Buffer) => boolean;
|
|
11
12
|
}
|
|
13
|
+
/** RFC 6750 b64token syntax, which also rules out header injection. */
|
|
14
|
+
export declare function isValidBearerToken(token: string): boolean;
|
|
12
15
|
export interface BoundedHttpResponse {
|
|
13
16
|
statusCode: number;
|
|
14
17
|
headers: Record<string, string | string[]>;
|
|
15
18
|
body: Buffer;
|
|
16
19
|
}
|
|
17
20
|
export declare class BoundedHttpError extends Error {
|
|
18
|
-
readonly code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED";
|
|
21
|
+
readonly code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_INSECURE_CREDENTIALS" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED";
|
|
19
22
|
readonly statusCode?: number | undefined;
|
|
20
23
|
readonly headers?: Record<string, string | string[]> | undefined;
|
|
21
|
-
constructor(code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED", message: string, statusCode?: number | undefined, headers?: Record<string, string | string[]> | undefined);
|
|
24
|
+
constructor(code: "REMOTE_HTTP_ENCODING_UNSUPPORTED" | "REMOTE_HTTP_HEADER_FORBIDDEN" | "REMOTE_HTTP_INSECURE_CREDENTIALS" | "REMOTE_HTTP_OPTIONS_INVALID" | "REMOTE_HTTP_PEER_MISMATCH" | "REMOTE_HTTP_REDIRECT" | "REMOTE_HTTP_REQUEST_FAILED" | "REMOTE_HTTP_RESPONSE_TOO_LARGE" | "REMOTE_HTTP_STOP_CONDITION_FAILED" | "REMOTE_HTTP_TIMEOUT" | "REMOTE_HTTP_URL_CREDENTIALS" | "REMOTE_HTTP_URL_UNSUPPORTED", message: string, statusCode?: number | undefined, headers?: Record<string, string | string[]> | undefined);
|
|
22
25
|
}
|
|
23
26
|
export declare function requestBoundedHttp(rawUrl: string, options?: BoundedHttpRequestOptions): Promise<BoundedHttpResponse>;
|
|
@@ -20,6 +20,11 @@ const safeResponseHeaders = new Set([
|
|
|
20
20
|
"mcp-protocol-version",
|
|
21
21
|
"location"
|
|
22
22
|
]);
|
|
23
|
+
const MAX_BEARER_TOKEN_LENGTH = 4_096;
|
|
24
|
+
/** RFC 6750 b64token syntax, which also rules out header injection. */
|
|
25
|
+
export function isValidBearerToken(token) {
|
|
26
|
+
return token.length > 0 && token.length <= MAX_BEARER_TOKEN_LENGTH && /^[A-Za-z0-9\-._~+/]+=*$/u.test(token);
|
|
27
|
+
}
|
|
23
28
|
export class BoundedHttpError extends Error {
|
|
24
29
|
code;
|
|
25
30
|
statusCode;
|
|
@@ -127,8 +132,17 @@ export async function requestBoundedHttp(rawUrl, options = {}) {
|
|
|
127
132
|
}
|
|
128
133
|
const { timeoutMs, maxResponseBytes } = validateOptions(options);
|
|
129
134
|
const headers = validateHeaders(options.headers);
|
|
135
|
+
if (options.bearerToken !== undefined && !isValidBearerToken(options.bearerToken)) {
|
|
136
|
+
throw new BoundedHttpError("REMOTE_HTTP_OPTIONS_INVALID", "Remote HTTP bearer token is invalid.");
|
|
137
|
+
}
|
|
130
138
|
const deadline = Date.now() + timeoutMs;
|
|
131
139
|
const target = await resolveWithinDeadline(url, options, timeoutMs);
|
|
140
|
+
if (options.bearerToken !== undefined) {
|
|
141
|
+
if (url.protocol !== "https:" && !target.local) {
|
|
142
|
+
throw new BoundedHttpError("REMOTE_HTTP_INSECURE_CREDENTIALS", "Remote HTTP bearer tokens require HTTPS or an approved loopback target.");
|
|
143
|
+
}
|
|
144
|
+
headers.authorization = `Bearer ${options.bearerToken}`;
|
|
145
|
+
}
|
|
132
146
|
const remainingMs = deadline - Date.now();
|
|
133
147
|
if (remainingMs <= 0) {
|
|
134
148
|
throw new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.");
|
|
@@ -9,6 +9,7 @@ export interface McpDiscoveryOptions {
|
|
|
9
9
|
requestTimeoutMs?: number;
|
|
10
10
|
lookup?: RemoteLookup;
|
|
11
11
|
request?: RemoteMcpRequest;
|
|
12
|
+
bearerToken?: string;
|
|
12
13
|
}
|
|
13
14
|
export interface McpDiscoveryReport {
|
|
14
15
|
schemaVersion: 1;
|
|
@@ -25,4 +26,23 @@ export interface McpDiscoveryReport {
|
|
|
25
26
|
export declare function findMcpSseResponse(body: Buffer, requestId?: string): McpJsonObject | null;
|
|
26
27
|
export declare function parseMcpResponse(response: BoundedHttpResponse, requestId?: string): McpJsonObject | null;
|
|
27
28
|
export declare function discoverMcpCapabilities(response: BoundedHttpResponse, requestId?: string): McpJsonObject | null;
|
|
29
|
+
export declare function serializeMcpDiscoverRequest(requestId?: string): string;
|
|
30
|
+
export type McpDiscoverOutcome = {
|
|
31
|
+
kind: "discovered";
|
|
32
|
+
supportedVersions: string[];
|
|
33
|
+
capabilities: McpJsonObject;
|
|
34
|
+
} | {
|
|
35
|
+
kind: "version-not-listed";
|
|
36
|
+
supportedVersions: string[];
|
|
37
|
+
} | {
|
|
38
|
+
kind: "version-unsupported";
|
|
39
|
+
supportedVersions: string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: "result-invalid";
|
|
42
|
+
} | {
|
|
43
|
+
kind: "envelope-invalid";
|
|
44
|
+
} | {
|
|
45
|
+
kind: "other-error";
|
|
46
|
+
};
|
|
47
|
+
export declare function classifyMcpDiscoverMessage(message: McpJsonObject, requestId?: string): McpDiscoverOutcome;
|
|
28
48
|
export declare function discoverMcpServer(rawUrl: string, options?: McpDiscoveryOptions): Promise<McpDiscoveryReport>;
|
|
@@ -102,6 +102,29 @@ export function discoverMcpCapabilities(response, requestId = REQUEST_ID) {
|
|
|
102
102
|
return null;
|
|
103
103
|
return result.supportedVersions.includes(REQUESTED_VERSION) ? capabilities : null;
|
|
104
104
|
}
|
|
105
|
+
export function serializeMcpDiscoverRequest(requestId = REQUEST_ID) {
|
|
106
|
+
return JSON.stringify({ jsonrpc: "2.0", id: requestId, method: "server/discover", params: { _meta: {
|
|
107
|
+
"io.modelcontextprotocol/protocolVersion": REQUESTED_VERSION,
|
|
108
|
+
"io.modelcontextprotocol/clientInfo": { name: "Codex Plugin Doctor", version: packageVersion },
|
|
109
|
+
"io.modelcontextprotocol/clientCapabilities": {}
|
|
110
|
+
} } });
|
|
111
|
+
}
|
|
112
|
+
// Classifies a transport-neutral server/discover reply. Only a valid result or a valid
|
|
113
|
+
// UnsupportedProtocolVersionError identifies a modern server; other errors do not.
|
|
114
|
+
export function classifyMcpDiscoverMessage(message, requestId = REQUEST_ID) {
|
|
115
|
+
if (!hasMatchingResponseEnvelope(message, requestId))
|
|
116
|
+
return { kind: "envelope-invalid" };
|
|
117
|
+
if (Object.hasOwn(message, "error")) {
|
|
118
|
+
const versions = unsupportedVersions(message.error);
|
|
119
|
+
return versions === null ? { kind: "other-error" } : { kind: "version-unsupported", supportedVersions: versions };
|
|
120
|
+
}
|
|
121
|
+
const result = message.result;
|
|
122
|
+
if (!validDiscoverResult(result))
|
|
123
|
+
return { kind: "result-invalid" };
|
|
124
|
+
return result.supportedVersions.includes(REQUESTED_VERSION)
|
|
125
|
+
? { kind: "discovered", supportedVersions: result.supportedVersions, capabilities: result.capabilities }
|
|
126
|
+
: { kind: "version-not-listed", supportedVersions: result.supportedVersions };
|
|
127
|
+
}
|
|
105
128
|
function failureReport(id, message) {
|
|
106
129
|
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.")]);
|
|
107
130
|
}
|
|
@@ -116,16 +139,12 @@ export async function discoverMcpServer(rawUrl, options = {}) {
|
|
|
116
139
|
return report("blocked", "skipped", [finding("plugin.discovery.url.invalid", "warn", "The MCP discovery endpoint is unsafe or unsupported.", "Unsafe endpoint URLs can bypass network controls or expose credentials.", "Use an absolute HTTP or HTTPS endpoint without credentials, query parameters, fragments, or IP literals.")]);
|
|
117
140
|
}
|
|
118
141
|
const request = options.request ?? requestBoundedHttp;
|
|
119
|
-
const body =
|
|
120
|
-
"io.modelcontextprotocol/protocolVersion": REQUESTED_VERSION,
|
|
121
|
-
"io.modelcontextprotocol/clientInfo": { name: "Codex Plugin Doctor", version: packageVersion },
|
|
122
|
-
"io.modelcontextprotocol/clientCapabilities": {}
|
|
123
|
-
} } });
|
|
142
|
+
const body = serializeMcpDiscoverRequest();
|
|
124
143
|
let response;
|
|
125
144
|
try {
|
|
126
145
|
response = await request(rawUrl, {
|
|
127
146
|
allowLocalNetwork: options.allowLocalNetwork, lookup: options.lookup, timeoutMs: options.requestTimeoutMs,
|
|
128
|
-
method: "POST", body,
|
|
147
|
+
method: "POST", body, bearerToken: options.bearerToken,
|
|
129
148
|
headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": REQUESTED_VERSION, "Mcp-Method": "server/discover" },
|
|
130
149
|
stopAfter: (received) => findMcpSseResponse(received) !== null
|
|
131
150
|
});
|
|
@@ -134,10 +153,15 @@ export async function discoverMcpServer(rawUrl, options = {}) {
|
|
|
134
153
|
if (error instanceof RemoteNetworkPolicyError) {
|
|
135
154
|
return report("blocked", "skipped", [finding("plugin.discovery.network.blocked", "warn", "The MCP discovery target is blocked by the remote network policy.", "Discovery could not safely reach the configured target.", "Use an endpoint permitted by the remote network policy.")]);
|
|
136
155
|
}
|
|
156
|
+
if (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_INSECURE_CREDENTIALS") {
|
|
157
|
+
return report("blocked", "skipped", [finding("plugin.discovery.credentials.insecure_transport", "warn", "The bearer token was not sent because the endpoint is neither HTTPS nor an approved loopback target.", "Sending a token over plain HTTP would expose it to the network.", "Use an HTTPS endpoint, or a loopback endpoint with local network access approved.")]);
|
|
158
|
+
}
|
|
137
159
|
return failureReport(error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT" ? "plugin.discovery.transport.timeout" : "plugin.discovery.transport.failed", "The MCP discovery request did not complete within the configured transport bounds.");
|
|
138
160
|
}
|
|
139
161
|
if (response.statusCode === 401 || response.statusCode === 403) {
|
|
140
|
-
return
|
|
162
|
+
return options.bearerToken === undefined
|
|
163
|
+
? unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.")
|
|
164
|
+
: unsupportedReport("plugin.discovery.authorization.rejected", "The MCP endpoint rejected the supplied bearer token.");
|
|
141
165
|
}
|
|
142
166
|
const message = parseMcpResponse(response);
|
|
143
167
|
if (!message || !hasMatchingResponseEnvelope(message))
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import type { RuntimeSandboxMode } from "../domain/types.js";
|
|
3
|
+
import { type McpToolCatalogSnapshot } from "./mcp-tool-catalog.js";
|
|
4
|
+
export interface McpStdioServer {
|
|
5
|
+
packageRoot: string;
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
cwd: string;
|
|
9
|
+
source: "manifest" | "mcp-json";
|
|
10
|
+
}
|
|
11
|
+
export type McpStdioServerResolution = {
|
|
12
|
+
ok: true;
|
|
13
|
+
server: McpStdioServer;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export interface McpStdioToolCatalogOptions {
|
|
19
|
+
sandbox?: RuntimeSandboxMode;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
spawnImpl?: typeof spawn;
|
|
22
|
+
removeContainerImpl?: (containerName: string) => Promise<boolean>;
|
|
23
|
+
timeouts?: {
|
|
24
|
+
startupMs?: number;
|
|
25
|
+
requestMs?: number;
|
|
26
|
+
totalMs?: number;
|
|
27
|
+
shutdownMs?: number;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export declare function resolveMcpStdioServer(targetPath: string, serverName: string): Promise<McpStdioServerResolution>;
|
|
31
|
+
export declare function captureStdioMcpToolCatalog(server: McpStdioServer, options?: McpStdioToolCatalogOptions): Promise<McpToolCatalogSnapshot>;
|