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 +9 -6
- 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 +1 -0
- package/dist/core/mcp-discovery.js +7 -2
- package/dist/core/mcp-tool-catalog.d.ts +1 -0
- package/dist/core/mcp-tool-catalog.js +14 -7
- package/dist/core/mcp-tool-impact.js +10 -6
- 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.js +75 -16
- package/package.json +1 -1
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.
|
|
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
|
+
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.
|
|
571
|
+
- uses: Esquetta/CodexPluginDoctor@v1.69.1
|
|
569
572
|
with:
|
|
570
|
-
version: "1.
|
|
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.");
|
|
@@ -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
|
|
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))
|
|
@@ -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
|
-
|
|
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 === "
|
|
209
|
-
? "
|
|
210
|
-
: error instanceof BoundedHttpError && error.code === "
|
|
211
|
-
? "
|
|
212
|
-
: "
|
|
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
|
|
201
|
-
// declared property can reject values callers send (input) or
|
|
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 (
|
|
205
|
-
const governed = isJsonObject(before.additionalProperties)
|
|
206
|
-
|
|
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,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
|
|
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" &&
|
|
1224
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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:
|
|
1435
|
+
allowLocalNetwork: switches.includes("--allow-local-network"),
|
|
1436
|
+
...(bearer.token === undefined ? {} : { bearerToken: bearer.token })
|
|
1378
1437
|
});
|
|
1379
|
-
io.writeStdout(
|
|
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