codex-plugin-doctor 1.53.0 → 1.54.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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 Furkan Tasci
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Furkan Tasci
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -83,18 +83,30 @@ Runtime MCP validation with `--runtime`:
83
83
 
84
84
  ### Remote MCP Readiness
85
85
 
86
- Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
86
+ Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
87
87
 
88
88
  ```bash
89
89
  codex-plugin-doctor check ./remote-mcp --runtime --allow-network
90
90
  codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
91
91
  ```
92
92
 
93
- The probe makes only bounded protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects.
94
-
95
- Remote transport reliability adds one bounded SSE GET after initialization. HTTP `200` must be `text/event-stream`; HTTP `405` is compliant when the endpoint does not offer server-to-client SSE. At most one SSE resume and one session restart are attempted, and no remote response content is retained. This is a bounded readiness check, not a live interoperability, delivery-guarantee, or load-test claim.
96
-
97
- `--allow-session-lifecycle` is disabled by default and is state-changing: only after a valid `MCP-Session-Id` it permits one bounded session `DELETE`. `--require-remote-reliability` is a strict result gate: it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. It grants no network consent, so `--runtime --allow-network` (and loopback consent when applicable) remain required. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md) and [Remote MCP Transport Reliability](./docs/architecture/remote-mcp-transport-reliability.md).
93
+ The probe makes only bounded protocol and OAuth metadata-discovery requests, redacts report output, and applies SSRF controls. It does not authenticate or follow redirects.
94
+
95
+ Remote transport reliability adds one bounded SSE GET after initialization. HTTP `200` must be `text/event-stream`; HTTP `405` is compliant when the endpoint does not offer server-to-client SSE. At most one SSE resume and one session restart are attempted, and no remote response content is retained. This is a bounded readiness check, not a live interoperability, delivery-guarantee, or load-test claim.
96
+
97
+ `--allow-session-lifecycle` is disabled by default and is state-changing: only after a valid `MCP-Session-Id` it permits one bounded session `DELETE`. `--require-remote-reliability` is a strict result gate: it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. It grants no network consent, so `--runtime --allow-network` (and loopback consent when applicable) remain required. See [Remote MCP Readiness](./docs/architecture/remote-mcp-readiness.md) and [Remote MCP Transport Reliability](./docs/architecture/remote-mcp-transport-reliability.md).
98
+
99
+ ### MCP Registry Readiness
100
+
101
+ Validate official MCP Registry publication metadata without publishing or installing anything:
102
+
103
+ ```bash
104
+ codex-plugin-doctor registry check path/to/server.json
105
+ codex-plugin-doctor registry check path/to/server.json --json --require-registry-readiness
106
+ codex-plugin-doctor registry inspect io.github.example/weather --allow-network
107
+ ```
108
+
109
+ Local checks validate metadata, ownership consistency, package integrity, transports, and Codex installability. Exact Registry lookup is read-only, requires explicit network consent, and contacts only the fixed official Registry endpoint. It never follows advertised package, icon, repository, or remote MCP URLs. See [MCP Registry Readiness](./docs/architecture/mcp-registry-readiness.md).
98
110
 
99
111
  Output formats:
100
112
 
@@ -442,9 +454,9 @@ jobs:
442
454
  runs-on: ubuntu-latest
443
455
  steps:
444
456
  - uses: actions/checkout@v5
445
- - uses: Esquetta/CodexPluginDoctor@v1.53.0
457
+ - uses: Esquetta/CodexPluginDoctor@v1.54.0
446
458
  with:
447
- version: "1.53.0"
459
+ version: "1.54.0"
448
460
  path: .
449
461
  runtime: "true"
450
462
  policy: codex-publish
@@ -0,0 +1,57 @@
1
+ import { type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js";
2
+ type RegistryCheckStatus = "pass" | "warn" | "fail" | "skipped";
3
+ type RegistryRequest = (url: string, options?: BoundedHttpRequestOptions) => Promise<BoundedHttpResponse>;
4
+ export interface McpRegistryFinding {
5
+ id: string;
6
+ severity: "warn" | "fail";
7
+ message: string;
8
+ path?: string;
9
+ }
10
+ export interface McpRegistryScorecard {
11
+ metadata: RegistryCheckStatus;
12
+ ownership: RegistryCheckStatus;
13
+ packageIntegrity: RegistryCheckStatus;
14
+ transportReadiness: RegistryCheckStatus;
15
+ clientInstallability: RegistryCheckStatus;
16
+ overall: Exclude<RegistryCheckStatus, "skipped">;
17
+ }
18
+ export interface McpRegistryInstallability {
19
+ codex: "ready" | "manual" | "unavailable";
20
+ packageTypes: string[];
21
+ remoteTransports: string[];
22
+ codexPreview?: {
23
+ mcpServers: Record<string, {
24
+ command?: string;
25
+ args?: string[];
26
+ url?: string;
27
+ }>;
28
+ };
29
+ }
30
+ export interface McpRegistryReadinessReport {
31
+ schemaVersion: "1.0.0";
32
+ kind: "mcp-registry-readiness";
33
+ generatedAt: string;
34
+ source: "file" | "registry";
35
+ target: string;
36
+ serverName?: string;
37
+ serverVersion?: string;
38
+ status: "pass" | "warn" | "fail";
39
+ scorecard: McpRegistryScorecard;
40
+ installability: McpRegistryInstallability;
41
+ findings: McpRegistryFinding[];
42
+ registry?: {
43
+ lifecycleStatus: string;
44
+ isLatest?: boolean;
45
+ publishedAt?: string;
46
+ };
47
+ }
48
+ export interface InspectMcpRegistryOptions {
49
+ allowNetwork?: boolean;
50
+ request?: RegistryRequest;
51
+ }
52
+ export declare function buildMcpRegistryReadiness(targetPath: string): Promise<McpRegistryReadinessReport>;
53
+ export declare function inspectMcpRegistryServer(serverName: string, options?: InspectMcpRegistryOptions): Promise<McpRegistryReadinessReport>;
54
+ export declare function registryReadinessExitCode(report: McpRegistryReadinessReport, requireReadiness?: boolean): 0 | 1;
55
+ export declare function renderMcpRegistryReadinessJson(report: McpRegistryReadinessReport): string;
56
+ export declare function renderMcpRegistryReadiness(report: McpRegistryReadinessReport): string;
57
+ export {};
@@ -0,0 +1,455 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { requestBoundedHttp } from "./bounded-http-client.js";
4
+ const OFFICIAL_SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json";
5
+ const OFFICIAL_SCHEMA_PATTERN = /^https:\/\/static\.modelcontextprotocol\.io\/schemas\/\d{4}-\d{2}-\d{2}\/server\.schema\.json$/;
6
+ const OFFICIAL_REGISTRY = "https://registry.modelcontextprotocol.io";
7
+ const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/;
8
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/;
9
+ const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i;
10
+ const SECRET_NAME_PATTERN = /(?:api[_-]?key|token|secret|password|credential)/i;
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null && !Array.isArray(value);
13
+ }
14
+ function statusFromFindings(findings) {
15
+ if (findings.some((finding) => finding.severity === "fail")) {
16
+ return "fail";
17
+ }
18
+ return findings.length > 0 ? "warn" : "pass";
19
+ }
20
+ function areaStatus(findings, prefix, fallback = "pass") {
21
+ const matches = findings.filter((finding) => finding.id.startsWith(prefix));
22
+ if (matches.some((finding) => finding.severity === "fail")) {
23
+ return "fail";
24
+ }
25
+ return matches.length > 0 ? "warn" : fallback;
26
+ }
27
+ function addFinding(findings, id, severity, message, findingPath) {
28
+ findings.push({
29
+ id,
30
+ severity,
31
+ message,
32
+ ...(findingPath ? { path: findingPath } : {})
33
+ });
34
+ }
35
+ function parseUrlTemplate(rawValue) {
36
+ if (typeof rawValue !== "string" || rawValue.length === 0 || /\s/.test(rawValue)) {
37
+ return null;
38
+ }
39
+ try {
40
+ return new URL(rawValue.replace(/\{[^{}]+\}/g, "template-value"));
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function inspectUrl(findings, rawValue, findingPath, options) {
47
+ const parsed = parseUrlTemplate(rawValue);
48
+ if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) {
49
+ addFinding(findings, `${options.prefix}.url-invalid`, "fail", "URL must be an absolute HTTP or HTTPS URL.", findingPath);
50
+ return null;
51
+ }
52
+ if (parsed.username || parsed.password) {
53
+ addFinding(findings, `${options.prefix}.credentials`, "fail", "URL must not contain credentials.", findingPath);
54
+ }
55
+ if (options.httpsOnly && parsed.protocol !== "https:") {
56
+ addFinding(findings, `${options.prefix}.http`, "warn", "Public Registry URLs should use HTTPS.", findingPath);
57
+ }
58
+ return parsed;
59
+ }
60
+ function inspectInputSecrets(findings, values, findingPath) {
61
+ if (!Array.isArray(values)) {
62
+ return;
63
+ }
64
+ values.forEach((value, index) => {
65
+ if (!isRecord(value)) {
66
+ return;
67
+ }
68
+ const name = typeof value.name === "string" ? value.name : "";
69
+ if (typeof value.value === "string" && value.value.length > 0 && !/\{[^{}]+\}/.test(value.value)
70
+ && (value.isSecret === true || SECRET_NAME_PATTERN.test(name))) {
71
+ addFinding(findings, "registry.secret.embedded-value", "fail", "Secret-like Registry inputs must not embed a fixed value.", `${findingPath}[${index}].value`);
72
+ }
73
+ });
74
+ }
75
+ function inspectArguments(findings, values, findingPath) {
76
+ if (!Array.isArray(values)) {
77
+ return;
78
+ }
79
+ values.forEach((value, index) => {
80
+ if (isRecord(value) && typeof value.value === "string" && /[;&|`]/.test(value.value)) {
81
+ addFinding(findings, "registry.package.argument-shell-risk", "warn", "Fixed argument contains shell metacharacters; clients must execute without a shell.", `${findingPath}[${index}].value`);
82
+ }
83
+ });
84
+ }
85
+ function githubOwnerFromRepository(repository) {
86
+ if (repository.source !== "github" || typeof repository.url !== "string") {
87
+ return null;
88
+ }
89
+ try {
90
+ const url = new URL(repository.url);
91
+ if (url.hostname.toLowerCase() !== "github.com") {
92
+ return null;
93
+ }
94
+ return url.pathname.split("/").filter(Boolean)[0] ?? null;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ function buildInstallability(server, validPackages, validRemotes) {
101
+ const packageTypes = [...new Set(validPackages
102
+ .map((entry) => typeof entry.registryType === "string" ? entry.registryType : "")
103
+ .filter(Boolean))];
104
+ const remoteTransports = [...new Set(validRemotes
105
+ .map((entry) => typeof entry.type === "string" ? entry.type : "")
106
+ .filter(Boolean))];
107
+ const shortName = typeof server.name === "string"
108
+ ? server.name.split("/").at(-1) ?? "server"
109
+ : "server";
110
+ const remote = validRemotes.find((entry) => {
111
+ const url = parseUrlTemplate(entry.url);
112
+ return url?.protocol === "https:" && typeof entry.url === "string" && !entry.url.includes("{");
113
+ });
114
+ if (remote && typeof remote.url === "string") {
115
+ return {
116
+ codex: "ready",
117
+ packageTypes,
118
+ remoteTransports,
119
+ codexPreview: {
120
+ mcpServers: {
121
+ [shortName]: { url: remote.url }
122
+ }
123
+ }
124
+ };
125
+ }
126
+ const npmPackage = validPackages.find((entry) => entry.registryType === "npm"
127
+ && typeof entry.identifier === "string"
128
+ && typeof entry.version === "string"
129
+ && !VERSION_RANGE_PATTERN.test(entry.version)
130
+ && isRecord(entry.transport)
131
+ && entry.transport.type === "stdio");
132
+ if (npmPackage && typeof npmPackage.identifier === "string" && typeof npmPackage.version === "string") {
133
+ return {
134
+ codex: "ready",
135
+ packageTypes,
136
+ remoteTransports,
137
+ codexPreview: {
138
+ mcpServers: {
139
+ [shortName]: {
140
+ command: "npx",
141
+ args: ["-y", `${npmPackage.identifier}@${npmPackage.version}`]
142
+ }
143
+ }
144
+ }
145
+ };
146
+ }
147
+ return {
148
+ codex: validPackages.length > 0 || validRemotes.length > 0 ? "manual" : "unavailable",
149
+ packageTypes,
150
+ remoteTransports
151
+ };
152
+ }
153
+ async function readOptionalPackageJson(directory) {
154
+ try {
155
+ const parsed = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
156
+ return isRecord(parsed) ? parsed : null;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ async function resolveServerJsonPath(targetPath) {
163
+ const resolved = path.resolve(targetPath);
164
+ try {
165
+ return (await stat(resolved)).isDirectory() ? path.join(resolved, "server.json") : resolved;
166
+ }
167
+ catch {
168
+ return resolved.endsWith(".json") ? resolved : path.join(resolved, "server.json");
169
+ }
170
+ }
171
+ async function buildReportFromServer(server, target, source, packageJson = null, extraFindings = []) {
172
+ const findings = [...extraFindings];
173
+ const validPackages = [];
174
+ const validRemotes = [];
175
+ let ownershipVerified = false;
176
+ if (!isRecord(server)) {
177
+ addFinding(findings, "registry.metadata.object", "fail", "server.json must contain a JSON object.");
178
+ return finalizeReport({}, target, source, findings, validPackages, validRemotes, ownershipVerified);
179
+ }
180
+ if (typeof server.$schema === "string"
181
+ && OFFICIAL_SCHEMA_PATTERN.test(server.$schema)
182
+ && server.$schema !== OFFICIAL_SCHEMA) {
183
+ addFinding(findings, "registry.metadata.schema-outdated", "warn", `server.json uses an older official schema; new publications should use ${OFFICIAL_SCHEMA}.`, "$schema");
184
+ }
185
+ else if (server.$schema !== OFFICIAL_SCHEMA) {
186
+ addFinding(findings, "registry.metadata.schema", "fail", `server.json must reference the current official schema: ${OFFICIAL_SCHEMA}.`, "$schema");
187
+ }
188
+ if (typeof server.name !== "string" || server.name.length < 3
189
+ || server.name.length > 200 || !SERVER_NAME_PATTERN.test(server.name)) {
190
+ addFinding(findings, "registry.metadata.name", "fail", "Server name must use namespace/name format.", "name");
191
+ }
192
+ if (typeof server.description !== "string" || server.description.length < 1 || server.description.length > 100) {
193
+ addFinding(findings, "registry.metadata.description", "fail", "Description must contain 1 to 100 characters.", "description");
194
+ }
195
+ if (typeof server.version !== "string" || server.version.length < 1 || server.version.length > 255
196
+ || VERSION_RANGE_PATTERN.test(server.version)) {
197
+ addFinding(findings, "registry.metadata.version", "fail", "Server version must be a specific version string.", "version");
198
+ }
199
+ if (isRecord(server.repository)) {
200
+ inspectUrl(findings, server.repository.url, "repository.url", {
201
+ httpsOnly: true,
202
+ prefix: "registry.repository"
203
+ });
204
+ if (typeof server.repository.source !== "string" || server.repository.source.length === 0) {
205
+ addFinding(findings, "registry.repository.source", "fail", "Repository source is required when repository metadata is present.", "repository.source");
206
+ }
207
+ if (typeof server.name === "string" && server.name.toLowerCase().startsWith("io.github.")) {
208
+ const namespaceOwner = server.name.slice("io.github.".length).split("/")[0]?.toLowerCase();
209
+ const repositoryOwner = githubOwnerFromRepository(server.repository)?.toLowerCase();
210
+ if (repositoryOwner && namespaceOwner !== repositoryOwner) {
211
+ addFinding(findings, "registry.ownership.github-mismatch", "fail", "GitHub repository owner does not match the io.github namespace.", "repository.url");
212
+ }
213
+ }
214
+ }
215
+ if (typeof server.websiteUrl === "string") {
216
+ inspectUrl(findings, server.websiteUrl, "websiteUrl", {
217
+ httpsOnly: true,
218
+ prefix: "registry.website"
219
+ });
220
+ }
221
+ if (Array.isArray(server.icons)) {
222
+ server.icons.forEach((icon, index) => {
223
+ if (!isRecord(icon)) {
224
+ addFinding(findings, "registry.icon.invalid", "fail", "Icon entry must be an object.", `icons[${index}]`);
225
+ return;
226
+ }
227
+ inspectUrl(findings, icon.src, `icons[${index}].src`, {
228
+ httpsOnly: true,
229
+ prefix: "registry.icon"
230
+ });
231
+ });
232
+ }
233
+ if (server.packages !== undefined && !Array.isArray(server.packages)) {
234
+ addFinding(findings, "registry.package.array", "fail", "Packages must be an array.", "packages");
235
+ }
236
+ else if (Array.isArray(server.packages)) {
237
+ server.packages.forEach((entry, index) => {
238
+ const packagePath = `packages[${index}]`;
239
+ if (!isRecord(entry)) {
240
+ addFinding(findings, "registry.package.invalid", "fail", "Package entry must be an object.", packagePath);
241
+ return;
242
+ }
243
+ const type = entry.registryType;
244
+ if (typeof type !== "string" || type.length === 0) {
245
+ addFinding(findings, "registry.package.type", "fail", "Package registryType is required.", `${packagePath}.registryType`);
246
+ }
247
+ if (typeof entry.identifier !== "string" || entry.identifier.length === 0) {
248
+ addFinding(findings, "registry.package.identifier", "fail", "Package identifier is required.", `${packagePath}.identifier`);
249
+ }
250
+ else if (/^https?:\/\//i.test(entry.identifier)) {
251
+ inspectUrl(findings, entry.identifier, `${packagePath}.identifier`, {
252
+ httpsOnly: true,
253
+ prefix: "registry.package"
254
+ });
255
+ }
256
+ if (!isRecord(entry.transport) || typeof entry.transport.type !== "string"
257
+ || !["stdio", "streamable-http", "sse"].includes(entry.transport.type)) {
258
+ addFinding(findings, "registry.package.transport", "fail", "Package transport must be stdio, streamable-http, or sse.", `${packagePath}.transport`);
259
+ }
260
+ if (typeof entry.version === "string" && VERSION_RANGE_PATTERN.test(entry.version)) {
261
+ addFinding(findings, "registry.package.version-range", "fail", "Package version must be exact, not a range or latest.", `${packagePath}.version`);
262
+ }
263
+ if (type === "mcpb" && (typeof entry.fileSha256 !== "string" || !SHA256_PATTERN.test(entry.fileSha256))) {
264
+ addFinding(findings, "registry.package.mcpb-hash-missing", "fail", "MCPB packages require a lowercase SHA-256 digest.", `${packagePath}.fileSha256`);
265
+ }
266
+ inspectInputSecrets(findings, entry.environmentVariables, `${packagePath}.environmentVariables`);
267
+ inspectArguments(findings, entry.packageArguments, `${packagePath}.packageArguments`);
268
+ inspectArguments(findings, entry.runtimeArguments, `${packagePath}.runtimeArguments`);
269
+ validPackages.push(entry);
270
+ });
271
+ }
272
+ if (server.remotes !== undefined && !Array.isArray(server.remotes)) {
273
+ addFinding(findings, "registry.remote.array", "fail", "Remotes must be an array.", "remotes");
274
+ }
275
+ else if (Array.isArray(server.remotes)) {
276
+ server.remotes.forEach((entry, index) => {
277
+ const remotePath = `remotes[${index}]`;
278
+ if (!isRecord(entry)) {
279
+ addFinding(findings, "registry.remote.invalid", "fail", "Remote entry must be an object.", remotePath);
280
+ return;
281
+ }
282
+ if (entry.type !== "streamable-http" && entry.type !== "sse") {
283
+ addFinding(findings, "registry.remote.transport", "fail", "Remote transport must be streamable-http or sse.", `${remotePath}.type`);
284
+ }
285
+ inspectUrl(findings, entry.url, `${remotePath}.url`, {
286
+ httpsOnly: true,
287
+ prefix: "registry.remote"
288
+ });
289
+ inspectInputSecrets(findings, entry.headers, `${remotePath}.headers`);
290
+ validRemotes.push(entry);
291
+ });
292
+ }
293
+ if (validPackages.length === 0 && validRemotes.length === 0) {
294
+ addFinding(findings, "registry.installability.missing", "warn", "Metadata is valid, but no package or remote installation channel is declared.");
295
+ }
296
+ if (packageJson && typeof server.name === "string") {
297
+ const npmPackages = validPackages.filter((entry) => entry.registryType === "npm");
298
+ for (const entry of npmPackages) {
299
+ if (packageJson.name === entry.identifier) {
300
+ if (packageJson.mcpName !== server.name) {
301
+ addFinding(findings, "registry.ownership.npm-mcp-name", "fail", "package.json mcpName must match server.json name.", "package.json#mcpName");
302
+ }
303
+ else {
304
+ ownershipVerified = true;
305
+ }
306
+ if (typeof entry.version === "string" && packageJson.version !== entry.version) {
307
+ addFinding(findings, "registry.package.local-version-mismatch", "fail", "Local npm package version does not match server.json.", "package.json#version");
308
+ }
309
+ }
310
+ }
311
+ }
312
+ else if (validPackages.some((entry) => entry.registryType === "npm")) {
313
+ addFinding(findings, "registry.ownership.npm-unverified", "warn", "No adjacent package.json was available to verify npm mcpName ownership.");
314
+ }
315
+ return finalizeReport(server, target, source, findings, validPackages, validRemotes, ownershipVerified);
316
+ }
317
+ function finalizeReport(server, target, source, findings, validPackages, validRemotes, ownershipVerified = false) {
318
+ const status = statusFromFindings(findings);
319
+ const hasTransport = validPackages.length > 0 || validRemotes.length > 0;
320
+ const installability = buildInstallability(server, validPackages, validRemotes);
321
+ return {
322
+ schemaVersion: "1.0.0",
323
+ kind: "mcp-registry-readiness",
324
+ generatedAt: new Date().toISOString(),
325
+ source,
326
+ target,
327
+ ...(typeof server.name === "string" ? { serverName: server.name } : {}),
328
+ ...(typeof server.version === "string" ? { serverVersion: server.version } : {}),
329
+ status,
330
+ scorecard: {
331
+ metadata: areaStatus(findings, "registry.metadata."),
332
+ ownership: areaStatus(findings, "registry.ownership.", ownershipVerified ? "pass" : "skipped"),
333
+ packageIntegrity: validPackages.length > 0
334
+ ? areaStatus(findings, "registry.package.")
335
+ : "skipped",
336
+ transportReadiness: hasTransport
337
+ ? (areaStatus(findings, "registry.remote.") === "fail"
338
+ || areaStatus(findings, "registry.package.") === "fail" ? "fail" : "pass")
339
+ : "skipped",
340
+ clientInstallability: installability.codex === "ready"
341
+ ? "pass"
342
+ : installability.codex === "manual" ? "warn" : "skipped",
343
+ overall: status
344
+ },
345
+ installability,
346
+ findings
347
+ };
348
+ }
349
+ export async function buildMcpRegistryReadiness(targetPath) {
350
+ const serverJsonPath = await resolveServerJsonPath(targetPath);
351
+ let server;
352
+ try {
353
+ server = JSON.parse(await readFile(serverJsonPath, "utf8"));
354
+ }
355
+ catch {
356
+ return buildReportFromServer({}, serverJsonPath, "file", null, [{
357
+ id: "registry.metadata.read",
358
+ severity: "fail",
359
+ message: "Unable to read a valid server.json file.",
360
+ path: serverJsonPath
361
+ }]);
362
+ }
363
+ const packageJson = await readOptionalPackageJson(path.dirname(serverJsonPath));
364
+ return buildReportFromServer(server, serverJsonPath, "file", packageJson);
365
+ }
366
+ export async function inspectMcpRegistryServer(serverName, options = {}) {
367
+ if (!options.allowNetwork) {
368
+ throw new Error("Registry inspection requires explicit --allow-network consent.");
369
+ }
370
+ if (!SERVER_NAME_PATTERN.test(serverName)) {
371
+ throw new Error("Registry server name must use namespace/name format.");
372
+ }
373
+ const url = `${OFFICIAL_REGISTRY}/v0.1/servers/${encodeURIComponent(serverName)}/versions/latest`;
374
+ const response = await (options.request ?? requestBoundedHttp)(url, {
375
+ method: "GET",
376
+ headers: {
377
+ accept: "application/json",
378
+ "user-agent": "codex-plugin-doctor"
379
+ }
380
+ });
381
+ const findings = [];
382
+ if (response.statusCode !== 200) {
383
+ addFinding(findings, "registry.lookup.http", "fail", `Registry lookup returned HTTP ${response.statusCode}.`);
384
+ return buildReportFromServer({}, serverName, "registry", null, findings);
385
+ }
386
+ let payload;
387
+ try {
388
+ payload = JSON.parse(response.body.toString("utf8"));
389
+ }
390
+ catch {
391
+ addFinding(findings, "registry.lookup.json", "fail", "Registry returned invalid JSON.");
392
+ return buildReportFromServer({}, serverName, "registry", null, findings);
393
+ }
394
+ if (!isRecord(payload) || !isRecord(payload.server)) {
395
+ addFinding(findings, "registry.lookup.response", "fail", "Registry response is missing server metadata.");
396
+ return buildReportFromServer({}, serverName, "registry", null, findings);
397
+ }
398
+ if (payload.server.name !== serverName) {
399
+ addFinding(findings, "registry.lookup.name-mismatch", "fail", "Registry response server name does not match the requested exact name.");
400
+ }
401
+ const report = await buildReportFromServer(payload.server, serverName, "registry", null, findings);
402
+ const officialMeta = isRecord(payload._meta)
403
+ && isRecord(payload._meta["io.modelcontextprotocol.registry/official"])
404
+ ? payload._meta["io.modelcontextprotocol.registry/official"]
405
+ : null;
406
+ const lifecycleStatus = officialMeta && typeof officialMeta.status === "string"
407
+ ? officialMeta.status
408
+ : "unknown";
409
+ if (lifecycleStatus === "deprecated") {
410
+ addFinding(report.findings, "registry.lookup.deprecated", "warn", "Registry entry is deprecated.");
411
+ }
412
+ else if (lifecycleStatus !== "active") {
413
+ addFinding(report.findings, "registry.lookup.lifecycle", "fail", "Registry entry is not active.");
414
+ }
415
+ report.registry = {
416
+ lifecycleStatus,
417
+ ...(officialMeta && typeof officialMeta.isLatest === "boolean" ? { isLatest: officialMeta.isLatest } : {}),
418
+ ...(officialMeta && typeof officialMeta.publishedAt === "string" ? { publishedAt: officialMeta.publishedAt } : {})
419
+ };
420
+ report.status = statusFromFindings(report.findings);
421
+ report.scorecard.overall = report.status;
422
+ return report;
423
+ }
424
+ export function registryReadinessExitCode(report, requireReadiness = false) {
425
+ return report.status === "fail" || (requireReadiness && report.status !== "pass") ? 1 : 0;
426
+ }
427
+ export function renderMcpRegistryReadinessJson(report) {
428
+ return JSON.stringify(report, null, 2);
429
+ }
430
+ export function renderMcpRegistryReadiness(report) {
431
+ const lines = [
432
+ `Registry readiness: ${report.status.toUpperCase()}`,
433
+ `Source: ${report.source}`,
434
+ `Target: ${report.target}`,
435
+ report.serverName ? `Server: ${report.serverName}@${report.serverVersion ?? "unknown"}` : null,
436
+ "",
437
+ "Scorecard",
438
+ "---------",
439
+ `Metadata: ${report.scorecard.metadata}`,
440
+ `Ownership: ${report.scorecard.ownership}`,
441
+ `Package integrity: ${report.scorecard.packageIntegrity}`,
442
+ `Transport readiness: ${report.scorecard.transportReadiness}`,
443
+ `Codex installability: ${report.scorecard.clientInstallability}`,
444
+ `Overall: ${report.scorecard.overall}`,
445
+ "",
446
+ `Codex: ${report.installability.codex}`
447
+ ].filter((line) => line !== null);
448
+ if (report.findings.length > 0) {
449
+ lines.push("", "Findings", "--------");
450
+ for (const finding of report.findings) {
451
+ lines.push(`${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}${finding.path ? ` (${finding.path})` : ""}`);
452
+ }
453
+ }
454
+ return lines.join("\n");
455
+ }
@@ -152,6 +152,52 @@ const publicSchemaDefinitions = [
152
152
  }
153
153
  }
154
154
  },
155
+ {
156
+ id: "doctor.registry.readiness.json",
157
+ command: "codex-plugin-doctor registry check <server.json|directory> --json",
158
+ outputKind: "mcp-registry-readiness",
159
+ required: [
160
+ "schemaVersion",
161
+ "kind",
162
+ "generatedAt",
163
+ "source",
164
+ "target",
165
+ "status",
166
+ "scorecard",
167
+ "installability",
168
+ "findings"
169
+ ],
170
+ properties: {
171
+ source: {
172
+ type: "string",
173
+ enum: ["file", "registry"]
174
+ },
175
+ status: {
176
+ type: "string",
177
+ enum: ["pass", "warn", "fail"]
178
+ },
179
+ scorecard: {
180
+ type: "object",
181
+ required: [
182
+ "metadata",
183
+ "ownership",
184
+ "packageIntegrity",
185
+ "transportReadiness",
186
+ "clientInstallability",
187
+ "overall"
188
+ ],
189
+ additionalProperties: false
190
+ },
191
+ installability: {
192
+ type: "object",
193
+ required: ["codex", "packageTypes", "remoteTransports"],
194
+ additionalProperties: true
195
+ },
196
+ findings: {
197
+ type: "array"
198
+ }
199
+ }
200
+ },
155
201
  {
156
202
  id: "doctor.audit.json",
157
203
  command: "codex-plugin-doctor audit --installed --json",
package/dist/index.d.ts CHANGED
@@ -30,6 +30,7 @@ export { buildDoctorInspectorReport, renderDoctorInspectorReport, renderDoctorIn
30
30
  export { buildEcosystemAudit, renderEcosystemAudit, renderEcosystemAuditJson, type EcosystemAuditReport } from "./audit/ecosystem-audit.js";
31
31
  export { applyPolicyToDepAudit, applyPolicyToDoctorConfig, applyPolicyToSecurityAudit, parsePolicyPack, policyEnablesRuntime, policyFailsOnWarnings, policyPackNames, type PolicyPackName } from "./policy/policy-packs.js";
32
32
  export { buildGenericMcpDoctor, renderGenericMcpDoctor, renderGenericMcpDoctorJson, type GenericMcpDoctorReport } from "./mcp/generic-mcp-doctor.js";
33
+ export { buildMcpRegistryReadiness, inspectMcpRegistryServer, registryReadinessExitCode, renderMcpRegistryReadiness, renderMcpRegistryReadinessJson, type InspectMcpRegistryOptions, type McpRegistryFinding, type McpRegistryInstallability, type McpRegistryReadinessReport, type McpRegistryScorecard } from "./core/mcp-registry.js";
33
34
  export { watchPlugin, type WatchPluginOptions, type WatchPluginResult } from "./core/watch-plugin.js";
34
35
  export { buildDepAudit, buildDepAuditRecommendations, renderDepAudit, renderDepAuditJson, renderDepAuditSarif, type DepAuditRecommendation, type DepAuditReport, type DepAuditVulnerability } from "./core/dep-audit.js";
35
36
  export { initGitHooks, removeGitHooks, type InitGitHooksResult, type RemoveGitHooksResult } from "./core/init-git-hooks.js";