codex-plugin-doctor 1.60.0 → 1.61.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
@@ -81,6 +81,21 @@ 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
99
  ### Remote MCP Readiness
85
100
 
86
101
  Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
@@ -499,9 +514,9 @@ jobs:
499
514
  runs-on: ubuntu-latest
500
515
  steps:
501
516
  - uses: actions/checkout@v5
502
- - uses: Esquetta/CodexPluginDoctor@v1.60.0
517
+ - uses: Esquetta/CodexPluginDoctor@v1.61.0
503
518
  with:
504
- version: "1.60.0"
519
+ version: "1.61.0"
505
520
  path: .
506
521
  runtime: "true"
507
522
  policy: codex-publish
@@ -8,6 +8,7 @@ const allowedRequestHeaders = new Set([
8
8
  "accept",
9
9
  "content-type",
10
10
  "last-event-id",
11
+ "mcp-method",
11
12
  "mcp-protocol-version",
12
13
  "mcp-session-id",
13
14
  "user-agent"
@@ -0,0 +1,23 @@
1
+ import { type RemoteLookup } from "./remote-network-policy.js";
2
+ import type { RemoteMcpRequest } from "./remote-mcp-probe.js";
3
+ import type { Finding } from "../domain/types.js";
4
+ export interface McpDiscoveryOptions {
5
+ allowNetwork?: boolean;
6
+ allowLocalNetwork?: boolean;
7
+ requestTimeoutMs?: number;
8
+ lookup?: RemoteLookup;
9
+ request?: RemoteMcpRequest;
10
+ }
11
+ export interface McpDiscoveryReport {
12
+ schemaVersion: 1;
13
+ requestedVersion: "2026-07-28";
14
+ scope: "discovery-only";
15
+ status: "discovered" | "unsupported" | "blocked" | "failed";
16
+ supportedVersions: string[];
17
+ findings: Finding[];
18
+ coverage: {
19
+ discovery: "pass" | "fail" | "skipped";
20
+ runtime: "not-tested";
21
+ };
22
+ }
23
+ export declare function discoverMcpServer(rawUrl: string, options?: McpDiscoveryOptions): Promise<McpDiscoveryReport>;
@@ -0,0 +1,148 @@
1
+ import { BoundedHttpError, requestBoundedHttp } from "./bounded-http-client.js";
2
+ import { RemoteNetworkPolicyError } from "./remote-network-policy.js";
3
+ import { inspectRemoteMcpUrl } from "./remote-url-policy.js";
4
+ import { packageVersion } from "../version.js";
5
+ const REQUESTED_VERSION = "2026-07-28";
6
+ const REQUEST_ID = "discover-1";
7
+ function finding(id, severity, message, impact, suggestedFix) {
8
+ return { id, severity, message, impact, suggestedFix };
9
+ }
10
+ function report(status, discovery, findings = [], supportedVersions = []) {
11
+ return { schemaVersion: 1, requestedVersion: REQUESTED_VERSION, scope: "discovery-only", status, supportedVersions, findings, coverage: { discovery, runtime: "not-tested" } };
12
+ }
13
+ function isPlainObject(value) {
14
+ return typeof value === "object" && value !== null && !Array.isArray(value);
15
+ }
16
+ function parseJsonObject(source) {
17
+ try {
18
+ const parsed = JSON.parse(source);
19
+ return isPlainObject(parsed) ? parsed : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function mediaType(response) {
26
+ const contentType = response.headers["content-type"];
27
+ const value = Array.isArray(contentType) ? contentType[0] : contentType;
28
+ return typeof value === "string" ? value.split(";", 1)[0]?.trim().toLowerCase() ?? null : null;
29
+ }
30
+ function findSseResponse(body) {
31
+ const text = body.toString("utf8").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
32
+ let offset = 0;
33
+ while (offset < text.length) {
34
+ const boundary = text.indexOf("\n\n", offset);
35
+ if (boundary === -1)
36
+ return null;
37
+ const data = text.slice(offset, boundary).split("\n")
38
+ .filter((line) => line.startsWith("data:"))
39
+ .map((line) => line.slice(5).replace(/^ /, ""));
40
+ const candidate = data.length === 0 ? null : parseJsonObject(data.join("\n"));
41
+ if (candidate?.id === REQUEST_ID)
42
+ return candidate;
43
+ offset = boundary + 2;
44
+ }
45
+ return null;
46
+ }
47
+ function parseResponse(response) {
48
+ const contentType = mediaType(response);
49
+ return contentType === "application/json" ? parseJsonObject(response.body.toString("utf8"))
50
+ : contentType === "text/event-stream" ? findSseResponse(response.body)
51
+ : null;
52
+ }
53
+ function isValidVersion(value) {
54
+ if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
55
+ return false;
56
+ const [year, month, day] = value.split("-").map(Number);
57
+ const date = new Date(Date.UTC(year, month - 1, day));
58
+ return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
59
+ }
60
+ function validVersions(value) {
61
+ return Array.isArray(value) && value.length > 0 && value.length <= 32 && value.every(isValidVersion) && new Set(value).size === value.length;
62
+ }
63
+ function hasMatchingResponseEnvelope(message) {
64
+ return message.jsonrpc === "2.0" && message.id === REQUEST_ID
65
+ && Object.hasOwn(message, "result") !== Object.hasOwn(message, "error");
66
+ }
67
+ function validDiscoverResult(value) {
68
+ return isPlainObject(value) && value.resultType === "complete" && validVersions(value.supportedVersions)
69
+ && validCapabilities(value.capabilities) && typeof value.ttlMs === "number" && Number.isFinite(value.ttlMs) && value.ttlMs >= 0
70
+ && (value.cacheScope === "public" || value.cacheScope === "private") && validOptionalServerInfo(value);
71
+ }
72
+ function validCapabilities(value) {
73
+ return isPlainObject(value) && ["tools", "resources", "prompts", "completions", "logging", "tasks", "extensions"]
74
+ .every((name) => !Object.hasOwn(value, name) || isPlainObject(value[name]));
75
+ }
76
+ function validOptionalServerInfo(value) {
77
+ if (!Object.hasOwn(value, "_meta"))
78
+ return true;
79
+ if (!isPlainObject(value._meta))
80
+ return false;
81
+ const serverInfo = value._meta["io.modelcontextprotocol/serverInfo"];
82
+ return serverInfo === undefined || (isPlainObject(serverInfo) && typeof serverInfo.name === "string" && typeof serverInfo.version === "string");
83
+ }
84
+ function validError(value) {
85
+ return isPlainObject(value) && typeof value.code === "number" && Number.isFinite(value.code) && typeof value.message === "string";
86
+ }
87
+ function unsupportedVersions(error) {
88
+ if (!validError(error) || error.code !== -32022 || !isPlainObject(error.data))
89
+ return null;
90
+ return error.data.requested === REQUESTED_VERSION && validVersions(error.data.supported) ? error.data.supported : null;
91
+ }
92
+ function failureReport(id, message) {
93
+ 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
+ }
95
+ function unsupportedReport(id, message, versions = []) {
96
+ return report("unsupported", "skipped", [finding(id, "warn", message, "Discovery-only probing cannot establish support for the requested MCP protocol version.", "Configure a server that supports the requested protocol version, then run discovery again.")], versions);
97
+ }
98
+ export async function discoverMcpServer(rawUrl, options = {}) {
99
+ if (!options.allowNetwork) {
100
+ return report("blocked", "skipped", [finding("plugin.discovery.network_not_approved", "warn", "Remote MCP discovery was not contacted because network access is not approved.", "Discovery would create outbound network traffic without explicit approval.", "Enable network access only after reviewing the endpoint.")]);
101
+ }
102
+ if (inspectRemoteMcpUrl(rawUrl).issues.length > 0) {
103
+ 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.")]);
104
+ }
105
+ const request = options.request ?? requestBoundedHttp;
106
+ const body = JSON.stringify({ jsonrpc: "2.0", id: REQUEST_ID, 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
+ let response;
112
+ try {
113
+ response = await request(rawUrl, {
114
+ allowLocalNetwork: options.allowLocalNetwork, lookup: options.lookup, timeoutMs: options.requestTimeoutMs,
115
+ method: "POST", body,
116
+ headers: { Accept: "application/json, text/event-stream", "Content-Type": "application/json", "MCP-Protocol-Version": REQUESTED_VERSION, "Mcp-Method": "server/discover" },
117
+ stopAfter: (received) => findSseResponse(received) !== null
118
+ });
119
+ }
120
+ catch (error) {
121
+ if (error instanceof RemoteNetworkPolicyError) {
122
+ 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.")]);
123
+ }
124
+ 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.");
125
+ }
126
+ if (response.statusCode === 401 || response.statusCode === 403) {
127
+ return unsupportedReport("plugin.discovery.authorization.required", "The MCP endpoint requires authorization before discovery can be assessed.");
128
+ }
129
+ const message = parseResponse(response);
130
+ if (!message || !hasMatchingResponseEnvelope(message))
131
+ return failureReport("plugin.discovery.response.invalid", "The MCP endpoint returned an invalid discovery response.");
132
+ if (Object.hasOwn(message, "error")) {
133
+ if (!validError(message.error))
134
+ return failureReport("plugin.discovery.response.error", "The MCP endpoint returned an invalid discovery error.");
135
+ const versions = unsupportedVersions(message.error);
136
+ if (versions !== null && response.statusCode === 400)
137
+ return unsupportedReport("plugin.discovery.protocol_version.unsupported", "The MCP endpoint does not support the requested protocol version.", versions);
138
+ if (message.error.code === -32601 && (response.statusCode === 200 || response.statusCode === 404))
139
+ return unsupportedReport("plugin.discovery.method.unsupported", "The MCP endpoint does not implement server discovery for this protocol version.");
140
+ return failureReport("plugin.discovery.response.error", "The MCP endpoint returned an unrecognized discovery error.");
141
+ }
142
+ if (response.statusCode !== 200 || !validDiscoverResult(message.result))
143
+ return failureReport("plugin.discovery.result.invalid", "The MCP endpoint returned an invalid discovery result.");
144
+ const versions = message.result.supportedVersions;
145
+ if (!versions.includes(REQUESTED_VERSION))
146
+ return unsupportedReport("plugin.discovery.protocol_version.inconsistent", "The MCP endpoint completed discovery without listing the requested protocol version.", versions);
147
+ return report("discovered", "pass", [], versions);
148
+ }
@@ -0,0 +1,2 @@
1
+ import type { McpDiscoveryReport } from "../core/mcp-discovery.js";
2
+ export declare function renderMcpDiscoveryReport(report: McpDiscoveryReport): string;
@@ -0,0 +1,14 @@
1
+ export function renderMcpDiscoveryReport(report) {
2
+ return [
3
+ "MCP Discovery",
4
+ "=============",
5
+ `Status: ${report.status}`,
6
+ `Scope: ${report.scope}`,
7
+ `Requested protocol: ${report.requestedVersion}`,
8
+ `Advertised versions: ${report.supportedVersions.join(", ") || "not available"}`,
9
+ `Discovery response: ${report.coverage.discovery}`,
10
+ `Runtime: ${report.coverage.runtime}`,
11
+ "Discovery does not establish full protocol conformance or client compatibility.",
12
+ ...report.findings.map((finding) => `${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}\n ${finding.suggestedFix}`)
13
+ ].join("\n");
14
+ }
package/dist/run-cli.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { discoverMcpServer } from "./core/mcp-discovery.js";
1
2
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport } from "./core/corpus-quality-metrics.js";
2
3
  import { runCheck } from "./index.js";
3
4
  import { writeRawDoctorConfig } from "./index.js";
@@ -13,6 +14,7 @@ export interface CliTerminalContext {
13
14
  platform?: NodeJS.Platform;
14
15
  }
15
16
  export interface RunCliOptions {
17
+ discoverMcpServerImpl?: typeof discoverMcpServer;
16
18
  terminalContext?: CliTerminalContext;
17
19
  runCheckImpl?: typeof runCheck;
18
20
  writeRawDoctorConfigImpl?: typeof writeRawDoctorConfig;
package/dist/run-cli.js CHANGED
@@ -21,6 +21,8 @@ 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 { discoverMcpServer } from "./core/mcp-discovery.js";
25
+ import { renderMcpDiscoveryReport } from "./reporting/render-mcp-discovery-report.js";
24
26
  import { buildDoctorValidationCorpusReport, renderDoctorValidationCorpusJson, renderDoctorValidationCorpusReport } from "./core/validation-corpus.js";
25
27
  import { buildExternalValidationCorpusReport, ExternalCorpusManifestError } from "./core/external-validation-corpus.js";
26
28
  import { buildCorpusQualityMetricsDiffReport, buildCorpusQualityMetricsReport, CorpusMetricsDiffError, CorpusMetricsManifestError, renderCorpusQualityMetricsDiffJson, renderCorpusQualityMetricsDiffMarkdown, renderCorpusQualityMetricsDiffText, renderCorpusQualityMetricsJson, renderCorpusQualityMetricsMarkdown, renderCorpusQualityMetricsText } from "./core/corpus-quality-metrics.js";
@@ -99,6 +101,7 @@ function writeExactStdout(io, message) {
99
101
  }
100
102
  class CliUsageError extends Error {
101
103
  }
104
+ const discoveryUsage = "Usage: codex-plugin-doctor doctor discover <url> --allow-network [--allow-local-network] [--json]";
102
105
  function parseRuntimeSandbox(flags, options = { requireRuntime: true }) {
103
106
  const index = flags.indexOf("--sandbox");
104
107
  if (index === -1) {
@@ -181,6 +184,7 @@ function parseRemoteNetworkFlags(flags, runtime) {
181
184
  }
182
185
  function printUsage(io) {
183
186
  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
+ io.writeStderr(discoveryUsage);
184
188
  io.writeStderr("Registry readiness: codex-plugin-doctor registry check <server.json|directory> [--json] [--output <path>] [--require-registry-readiness]\n"
185
189
  + " codex-plugin-doctor registry inspect <server-name> --allow-network [--json] [--output <path>] [--require-registry-readiness]\n"
186
190
  + "Registry publication preflight: codex-plugin-doctor registry preflight <server.json|directory> [--allow-network] [--json] [--output <path>] [--require-publish-ready]");
@@ -1191,6 +1195,34 @@ export async function runCli(args, io = defaultIo, options = {}) {
1191
1195
  return 0;
1192
1196
  }
1193
1197
  if (command === "doctor") {
1198
+ if (maybePath === "discover") {
1199
+ if (remainingArgs.length === 1 && remainingArgs[0] === "--help") {
1200
+ io.writeStdout(`${discoveryUsage}\nHTTP discovery only; runtime behavior is not tested.`);
1201
+ return 0;
1202
+ }
1203
+ const [url, ...flags] = remainingArgs;
1204
+ if (!url || url.startsWith("-")) {
1205
+ io.writeStderr(discoveryUsage);
1206
+ return 2;
1207
+ }
1208
+ const allowedFlags = new Set(["--allow-network", "--allow-local-network", "--json"]);
1209
+ if (flags.some((flag) => !allowedFlags.has(flag)) || new Set(flags).size !== flags.length) {
1210
+ io.writeStderr(`Invalid or duplicate discovery arguments. ${discoveryUsage}`);
1211
+ return 2;
1212
+ }
1213
+ if (!flags.includes("--allow-network")) {
1214
+ io.writeStderr("doctor discover requires explicit --allow-network consent.");
1215
+ return 2;
1216
+ }
1217
+ const report = await (options.discoverMcpServerImpl ?? discoverMcpServer)(url, {
1218
+ allowNetwork: true,
1219
+ allowLocalNetwork: flags.includes("--allow-local-network")
1220
+ });
1221
+ io.writeStdout(flags.includes("--json")
1222
+ ? JSON.stringify(report, null, 2)
1223
+ : renderMcpDiscoveryReport(report));
1224
+ return report.status === "discovered" ? 0 : report.status === "unsupported" ? 1 : 2;
1225
+ }
1194
1226
  const doctorFlags = maybePath?.startsWith("--")
1195
1227
  ? [maybePath, ...remainingArgs]
1196
1228
  : remainingArgs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-plugin-doctor",
3
- "version": "1.60.0",
3
+ "version": "1.61.0",
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",