codex-plugin-doctor 1.68.0 → 1.69.1

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,7 +120,7 @@ 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.
@@ -141,10 +141,13 @@ 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
+ 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.
148
151
  See [HTTP MCP Tool Catalog](docs/guides/mcp-tool-catalog.md).
149
152
 
150
153
  ### Remote MCP Readiness
@@ -565,9 +568,9 @@ jobs:
565
568
  runs-on: ubuntu-latest
566
569
  steps:
567
570
  - uses: actions/checkout@v5
568
- - uses: Esquetta/CodexPluginDoctor@v1.68.0
571
+ - uses: Esquetta/CodexPluginDoctor@v1.69.1
569
572
  with:
570
- version: "1.68.0"
573
+ version: "1.69.1"
571
574
  path: .
572
575
  runtime: "true"
573
576
  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;
@@ -125,7 +125,7 @@ export async function discoverMcpServer(rawUrl, options = {}) {
125
125
  try {
126
126
  response = await request(rawUrl, {
127
127
  allowLocalNetwork: options.allowLocalNetwork, lookup: options.lookup, timeoutMs: options.requestTimeoutMs,
128
- method: "POST", body,
128
+ method: "POST", body, bearerToken: options.bearerToken,
129
129
  headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": REQUESTED_VERSION, "Mcp-Method": "server/discover" },
130
130
  stopAfter: (received) => findMcpSseResponse(received) !== null
131
131
  });
@@ -134,10 +134,15 @@ export async function discoverMcpServer(rawUrl, options = {}) {
134
134
  if (error instanceof RemoteNetworkPolicyError) {
135
135
  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
136
  }
137
+ if (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_INSECURE_CREDENTIALS") {
138
+ 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.")]);
139
+ }
137
140
  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
141
  }
139
142
  if (response.statusCode === 401 || response.statusCode === 403) {
140
- return unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.");
143
+ return options.bearerToken === undefined
144
+ ? unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.")
145
+ : unsupportedReport("plugin.discovery.authorization.rejected", "The MCP endpoint rejected the supplied bearer token.");
141
146
  }
142
147
  const message = parseMcpResponse(response);
143
148
  if (!message || !hasMatchingResponseEnvelope(message))
@@ -10,6 +10,7 @@ export interface McpToolCatalogOptions {
10
10
  lookup?: RemoteLookup;
11
11
  request?: RemoteMcpRequest;
12
12
  now?: () => number;
13
+ bearerToken?: string;
13
14
  }
14
15
  export interface McpToolCatalogReport {
15
16
  schemaVersion: 1;
@@ -71,7 +71,7 @@ function finalStatus(findings) {
71
71
  : "pass";
72
72
  }
73
73
  function isAuthorizationFinding(discovery) {
74
- return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required");
74
+ return discovery.findings.some((item) => item.id === "plugin.discovery.authorization.required" || item.id === "plugin.discovery.authorization.rejected");
75
75
  }
76
76
  function requestBody(id, cursor) {
77
77
  return JSON.stringify({
@@ -128,6 +128,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
128
128
  allowNetwork: options.allowNetwork,
129
129
  allowLocalNetwork: options.allowLocalNetwork,
130
130
  lookup: options.lookup,
131
+ bearerToken: options.bearerToken,
131
132
  request: async (url, requestOptions) => {
132
133
  const response = await requestWithinBudget(url, requestOptions);
133
134
  discoveryResponse = response;
@@ -137,7 +138,10 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
137
138
  const partial = (report) => ({ report, tools: null, cache: null });
138
139
  const emptyCatalog = { complete: false, pagesRead: 0, toolsChecked: 0, reason: null };
139
140
  if (discovery.status === "blocked") {
140
- return partial(catalogReport(discovery, "blocked", { ...emptyCatalog, reason: "network-blocked" }, "not-tested", []));
141
+ const reason = discovery.findings.some((item) => item.id === "plugin.discovery.credentials.insecure_transport")
142
+ ? "credentials-insecure-transport"
143
+ : "network-blocked";
144
+ return partial(catalogReport(discovery, "blocked", { ...emptyCatalog, reason }, "not-tested", []));
141
145
  }
142
146
  if (isAuthorizationFinding(discovery)) {
143
147
  return partial(catalogReport(discovery, "incomplete", { ...emptyCatalog, reason: "authorization-required" }, "not-tested", []));
@@ -189,6 +193,7 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
189
193
  response = await requestWithinBudget(rawUrl, {
190
194
  allowLocalNetwork: options.allowLocalNetwork,
191
195
  lookup: options.lookup,
196
+ bearerToken: options.bearerToken,
192
197
  method: "POST",
193
198
  body: requestBody(requestId, cursor),
194
199
  headers: {
@@ -205,11 +210,13 @@ export async function captureMcpToolCatalog(rawUrl, options = {}) {
205
210
  blocked = true;
206
211
  }
207
212
  else {
208
- incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
209
- ? "request-timeout"
210
- : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
211
- ? "response-byte-limit"
212
- : "request-failed");
213
+ incompleteReason = budgetReason ?? (error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_INSECURE_CREDENTIALS"
214
+ ? "credentials-insecure-transport"
215
+ : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_TIMEOUT"
216
+ ? "request-timeout"
217
+ : error instanceof BoundedHttpError && error.code === "REMOTE_HTTP_RESPONSE_TOO_LARGE"
218
+ ? "response-byte-limit"
219
+ : "request-failed");
213
220
  }
214
221
  break;
215
222
  }
@@ -34,6 +34,9 @@ const NESTED_STRUCTURAL_KEYWORDS = new Set([
34
34
  ]);
35
35
  // Subschemas nested deeper than this below the root are not compared and stay unclassified.
36
36
  const MAX_NESTED_DEPTH = 8;
37
+ // Keywords that only hold or name subschemas and never validate the object's own keys, so a
38
+ // new property beside them stays compatible. Any other unclassified keyword may govern keys.
39
+ const DEFINITION_KEYWORDS = new Set(["$defs", "definitions", "$id", "$anchor", "$dynamicAnchor"]);
37
40
  const CODES = {
38
41
  input: {
39
42
  unclassified: "input-schema-unclassified",
@@ -197,13 +200,14 @@ function compareSchema(before, after, direction, depth, equal, reasons) {
197
200
  else
198
201
  compareSchema(beforeProperties[key], afterProperties[key], direction, depth + 1, equal, reasons);
199
202
  }
200
- // In a nested object whose undeclared keys were open or governed by other keywords, a newly
201
- // declared property can reject values callers send (input) or produce values consumers did
202
- // not expect (output).
203
+ // In an object whose undeclared keys were governed by other keywords, or a nested input object
204
+ // that left them open, a newly declared property can reject values callers send (input) or
205
+ // produce values consumers did not expect (output).
203
206
  const addedKeys = Object.keys(afterProperties).filter((key) => !Object.hasOwn(beforeProperties, key));
204
- if (!root && addedKeys.length > 0) {
205
- const governed = isJsonObject(before.additionalProperties) || Object.keys(keywordsOutside(before, structural)).length > 0;
206
- const open = Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
207
+ if (addedKeys.length > 0) {
208
+ const governed = isJsonObject(before.additionalProperties)
209
+ || Object.keys(keywordsOutside(before, structural)).some((key) => !DEFINITION_KEYWORDS.has(key));
210
+ const open = !root && Object.keys(beforeProperties).length === 0 && before.additionalProperties !== false;
207
211
  if (governed || (direction === "input" && open))
208
212
  reasons.add(codes.unclassified);
209
213
  }
@@ -0,0 +1,2 @@
1
+ import type { McpToolDiffReport } from "../core/mcp-tool-diff.js";
2
+ export declare function renderMcpToolDiffMarkdown(report: McpToolDiffReport): string;
@@ -0,0 +1,33 @@
1
+ function count(value) {
2
+ return value === null || value === undefined ? "not compared" : String(value);
3
+ }
4
+ function toolIndex(value) {
5
+ return value === null ? "-" : String(value);
6
+ }
7
+ export function renderMcpToolDiffMarkdown(report) {
8
+ const comparison = report.comparison;
9
+ const lines = [
10
+ "## MCP Tool Definition Diff",
11
+ "",
12
+ `**Status:** \`${report.status}\` · **Comparison complete:** ${comparison.complete ? "yes" : "no"}${comparison.reason === null ? "" : ` · **Reason:** \`${comparison.reason}\``}`,
13
+ "",
14
+ "| Added | Removed | Changed | Unchanged | Breaking | Unclassified |",
15
+ "| --- | --- | --- | --- | --- | --- |",
16
+ `| ${count(comparison.added)} | ${count(comparison.removed)} | ${count(comparison.changed)} | ${count(comparison.unchanged)} | ${count(comparison.breaking)} | ${count(comparison.unclassified)} |`
17
+ ];
18
+ if (report.changes.length > 0) {
19
+ lines.push("", "### Changes", "", "| Change | Before tool | After tool | Fields | Impact | Reasons |", "| --- | --- | --- | --- | --- | --- |", ...report.changes.map((change) => `| ${change.kind} | ${toolIndex(change.beforeToolIndex)} | ${toolIndex(change.afterToolIndex)} | ${change.fields.map((field) => `\`${field}\``).join(", ") || "-"} | **${change.impact}** | ${change.reasons.map((reason) => `\`${reason}\``).join(", ") || "-"} |`));
20
+ }
21
+ const findings = ["before", "after"].flatMap((side) => report[side].findings.map((finding) => {
22
+ const location = finding.location;
23
+ const locator = location
24
+ ? `${side} tool ${location.toolIndex}${location.schema ? `, ${location.schema}` : ""}${location.relatedToolIndex ? `, related tool ${location.relatedToolIndex}` : ""}`
25
+ : side;
26
+ return `| ${finding.severity} | \`${finding.id}\` | ${locator} | ${finding.message} |`;
27
+ }));
28
+ if (findings.length > 0) {
29
+ lines.push("", "### Input Findings", "", "| Severity | Finding | Location | Message |", "| --- | --- | --- | --- |", ...findings);
30
+ }
31
+ lines.push("", `Tool numbers are one-based positions in the before and after files; names and schema values are not reported. Impact labels are ${report.coverage.impactClassification} schema checks, not proof of compatibility.`);
32
+ return lines.join("\n");
33
+ }
package/dist/run-cli.js CHANGED
@@ -21,11 +21,13 @@ import { buildDoctorAttestation, renderDoctorAttestation, renderDoctorAttestatio
21
21
  import { buildDoctorOutputContract, renderDoctorOutputContract, renderDoctorOutputContractJson } from "./core/output-contract.js";
22
22
  import { buildSubmissionArchivePreflight, submissionArchiveExitCode } from "./core/submission-archive-preflight.js";
23
23
  import { buildSubmissionPreflight } from "./core/submission-preflight.js";
24
+ import { isValidBearerToken } from "./core/bounded-http-client.js";
24
25
  import { discoverMcpServer } from "./core/mcp-discovery.js";
25
26
  import { captureMcpToolCatalog, inspectMcpToolCatalog } from "./core/mcp-tool-catalog.js";
26
27
  import { inspectMcpToolFile, isBlockedOfflinePath } from "./core/mcp-tool-file.js";
27
28
  import { saveMcpToolsListResponse } from "./core/mcp-tool-response-file.js";
28
29
  import { compareMcpToolFiles } from "./core/mcp-tool-diff.js";
30
+ import { renderMcpToolDiffMarkdown } from "./reporting/render-mcp-tool-diff-markdown.js";
29
31
  import { renderMcpToolDiffReport } from "./reporting/render-mcp-tool-diff-report.js";
30
32
  import { renderMcpToolFileReport } from "./reporting/render-mcp-tool-file-report.js";
31
33
  import { renderMcpToolCatalogReport } from "./reporting/render-mcp-tool-catalog-report.js";
@@ -108,10 +110,24 @@ function writeExactStdout(io, message) {
108
110
  }
109
111
  class CliUsageError extends Error {
110
112
  }
111
- const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json] [--fail-on any|breaking]";
113
+ const toolsDiffUsage = "Usage: codex-plugin-doctor doctor tools-diff --before <path> --after <path> [--json|--markdown] [--fail-on any|breaking]";
112
114
  const toolsFileUsage = "Usage: codex-plugin-doctor doctor tools-file <path> [--json]";
113
- const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>]";
114
- const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
115
+ const toolsUsage = "Usage: codex-plugin-doctor doctor tools <url> --allow-network [--allow-local-network] [--json] [--save-response <path>] [--bearer-token-env <NAME>]";
116
+ const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json] [--bearer-token-env <NAME>]";
117
+ const bearerTokenHelp = "--bearer-token-env reads a bearer token from the named environment variable and sends it only over HTTPS or to an approved loopback target; the token is never printed or saved.";
118
+ const ENVIRONMENT_VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/u;
119
+ function resolveBearerToken(name, env) {
120
+ if (name === undefined)
121
+ return {};
122
+ if (!ENVIRONMENT_VARIABLE_NAME.test(name))
123
+ return { error: "--bearer-token-env requires an environment variable name." };
124
+ const token = env[name];
125
+ if (token === undefined || token === "")
126
+ return { error: `Bearer token environment variable is not set: ${name}` };
127
+ if (!isValidBearerToken(token))
128
+ return { error: `Bearer token environment variable does not hold a valid bearer token: ${name}` };
129
+ return { token };
130
+ }
115
131
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
116
132
  const index = flags.indexOf("--sandbox");
117
133
  if (index === -1) {
@@ -1215,13 +1231,13 @@ export async function runCli(args, io = defaultIo, options = {}) {
1215
1231
  }
1216
1232
  let beforePath;
1217
1233
  let afterPath;
1218
- let json = false;
1234
+ let format;
1219
1235
  let failOn;
1220
1236
  let invalid = false;
1221
1237
  for (let index = 0; index < remainingArgs.length; index += 1) {
1222
1238
  const flag = remainingArgs[index];
1223
- if (flag === "--json" && !json) {
1224
- json = true;
1239
+ if ((flag === "--json" || flag === "--markdown") && format === undefined) {
1240
+ format = flag === "--json" ? "json" : "markdown";
1225
1241
  }
1226
1242
  else if (flag === "--fail-on" && failOn === undefined) {
1227
1243
  const value = remainingArgs[index + 1];
@@ -1254,7 +1270,9 @@ export async function runCli(args, io = defaultIo, options = {}) {
1254
1270
  return 2;
1255
1271
  }
1256
1272
  const report = await (options.compareMcpToolFilesImpl ?? compareMcpToolFiles)(beforePath, afterPath);
1257
- io.writeStdout(json ? JSON.stringify(report, null, 2) : renderMcpToolDiffReport(report));
1273
+ io.writeStdout(format === "json" ? JSON.stringify(report, null, 2)
1274
+ : format === "markdown" ? renderMcpToolDiffMarkdown(report)
1275
+ : renderMcpToolDiffReport(report));
1258
1276
  if (report.status !== "pass" && report.status !== "warn")
1259
1277
  return 2;
1260
1278
  if (failOn === "breaking") {
@@ -1281,7 +1299,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1281
1299
  }
1282
1300
  if (maybePath === "tools") {
1283
1301
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1284
- io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.`);
1302
+ io.writeStdout(`${toolsUsage}\nHTTP tool catalog structure only; tools are not executed.\n--save-response writes a complete enumeration as one tools/list response for doctor tools-file and tools-diff; nothing is written when enumeration is incomplete.\n${bearerTokenHelp}`);
1285
1303
  return 0;
1286
1304
  }
1287
1305
  const [url, ...flags] = remainingArgs;
@@ -1292,16 +1310,20 @@ export async function runCli(args, io = defaultIo, options = {}) {
1292
1310
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1293
1311
  const switches = [];
1294
1312
  let savePath;
1313
+ let tokenEnv;
1295
1314
  let invalid = false;
1296
1315
  for (let index = 0; index < flags.length; index += 1) {
1297
1316
  const flag = flags[index];
1298
- if (flag === "--save-response" && savePath === undefined) {
1317
+ if ((flag === "--save-response" && savePath === undefined) || (flag === "--bearer-token-env" && tokenEnv === undefined)) {
1299
1318
  const value = flags[index + 1];
1300
1319
  if (!value || value.startsWith("-")) {
1301
1320
  invalid = true;
1302
1321
  break;
1303
1322
  }
1304
- savePath = value;
1323
+ if (flag === "--save-response")
1324
+ savePath = value;
1325
+ else
1326
+ tokenEnv = value;
1305
1327
  index += 1;
1306
1328
  }
1307
1329
  else if (allowedFlags.has(flag) && !switches.includes(flag)) {
@@ -1324,7 +1346,16 @@ export async function runCli(args, io = defaultIo, options = {}) {
1324
1346
  io.writeStderr("doctor tools --save-response requires a local file path.");
1325
1347
  return 2;
1326
1348
  }
1327
- const catalogOptions = { allowNetwork: true, allowLocalNetwork: switches.includes("--allow-local-network") };
1349
+ const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1350
+ if ("error" in bearer) {
1351
+ io.writeStderr(bearer.error);
1352
+ return 2;
1353
+ }
1354
+ const catalogOptions = {
1355
+ allowNetwork: true,
1356
+ allowLocalNetwork: switches.includes("--allow-local-network"),
1357
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
1358
+ };
1328
1359
  const snapshot = savePath === undefined
1329
1360
  ? { report: await (options.inspectMcpToolCatalogImpl ?? inspectMcpToolCatalog)(url, catalogOptions), tools: null, cache: null }
1330
1361
  : await (options.captureMcpToolCatalogImpl ?? captureMcpToolCatalog)(url, catalogOptions);
@@ -1355,7 +1386,7 @@ export async function runCli(args, io = defaultIo, options = {}) {
1355
1386
  }
1356
1387
  if (maybePath === "discover") {
1357
1388
  if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1358
- io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.`);
1389
+ io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.\n${bearerTokenHelp}`);
1359
1390
  return 0;
1360
1391
  }
1361
1392
  const [url, ...flags] = remainingArgs;
@@ -1364,19 +1395,47 @@ export async function runCli(args, io = defaultIo, options = {}) {
1364
1395
  return 2;
1365
1396
  }
1366
1397
  const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1367
- if (flags.some((flag) => !allowedFlags.has(flag)) || new Set(flags).size !== flags.length) {
1398
+ const switches = [];
1399
+ let tokenEnv;
1400
+ let invalid = false;
1401
+ for (let index = 0; index < flags.length; index += 1) {
1402
+ const flag = flags[index];
1403
+ if (flag === "--bearer-token-env" && tokenEnv === undefined) {
1404
+ const value = flags[index + 1];
1405
+ if (!value || value.startsWith("-")) {
1406
+ invalid = true;
1407
+ break;
1408
+ }
1409
+ tokenEnv = value;
1410
+ index += 1;
1411
+ }
1412
+ else if (allowedFlags.has(flag) && !switches.includes(flag)) {
1413
+ switches.push(flag);
1414
+ }
1415
+ else {
1416
+ invalid = true;
1417
+ break;
1418
+ }
1419
+ }
1420
+ if (invalid) {
1368
1421
  io.writeStderr(`Invalid or duplicate discovery arguments. ${discoveryUsage}`);
1369
1422
  return 2;
1370
1423
  }
1371
- if (!flags.includes("--allow-network")) {
1424
+ if (!switches.includes("--allow-network")) {
1372
1425
  io.writeStderr("doctor discover requires explicit --allow-network consent.");
1373
1426
  return 2;
1374
1427
  }
1428
+ const bearer = resolveBearerToken(tokenEnv, terminalContext.env);
1429
+ if ("error" in bearer) {
1430
+ io.writeStderr(bearer.error);
1431
+ return 2;
1432
+ }
1375
1433
  const report = await (options.discoverMcpServerImpl ?? discoverMcpServer)(url, {
1376
1434
  allowNetwork: true,
1377
- allowLocalNetwork: flags.includes("--allow-local-network")
1435
+ allowLocalNetwork: switches.includes("--allow-local-network"),
1436
+ ...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
1378
1437
  });
1379
- io.writeStdout(flags.includes("--json")
1438
+ io.writeStdout(switches.includes("--json")
1380
1439
  ? JSON.stringify(report, null, 2)
1381
1440
  : renderMcpDiscoveryReport(report));
1382
1441
  return report.status === "discovered" ? 0 : report.status === "unsupported" ? 1 : 2;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.68.0",
3
+ "version": "1.69.1",
4
4
  "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",