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 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
- ### HTTP MCP Tool Catalog
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. The GitHub Action can compare saved responses with
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`, and skip the plugin package check with
147
- `check: "false"` in repositories that only ship an MCP server.
148
- See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
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.68.0
584
+ - uses: Esquetta/CodexPluginDoctor@v1.70.0
569
585
  with:
570
- version: "1.68.0"
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 = JSON.stringify({ jsonrpc: "2.0", id: REQUEST_ID, method: "server/discover", params: { _meta: {
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 unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.");
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>;