codex-plugin-doctor 1.52.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 +21 -21
- package/README.md +33 -17
- package/dist/core/bounded-http-client.js +2 -1
- package/dist/core/doctor-export-bundle.js +3 -1
- package/dist/core/mcp-registry.d.ts +57 -0
- package/dist/core/mcp-registry.js +455 -0
- package/dist/core/output-contract.js +73 -0
- package/dist/core/release-check.d.ts +2 -0
- package/dist/core/release-check.js +2 -0
- package/dist/core/release-evidence.d.ts +2 -0
- package/dist/core/release-evidence.js +2 -0
- package/dist/core/remote-mcp-probe.d.ts +1 -0
- package/dist/core/remote-mcp-probe.js +59 -1
- package/dist/core/remote-transport-reliability.d.ts +18 -0
- package/dist/core/remote-transport-reliability.js +243 -0
- package/dist/core/runtime-probe.d.ts +3 -1
- package/dist/core/runtime-probe.js +18 -1
- package/dist/core/sse-observation.d.ts +7 -0
- package/dist/core/sse-observation.js +67 -0
- package/dist/core/validate-plugin.js +4 -3
- package/dist/domain/types.d.ts +13 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/mcp/generic-mcp-doctor.d.ts +2 -0
- package/dist/mcp/generic-mcp-doctor.js +6 -3
- package/dist/reporting/render-markdown-report.js +13 -0
- package/dist/reporting/render-text-report.js +11 -0
- package/dist/rules/rule-catalog.js +108 -0
- package/dist/run-cli.js +143 -7
- package/examples/README.md +94 -94
- package/examples/codex-doctor-generic-mcp/.mcp.json +10 -10
- package/examples/codex-doctor-generic-mcp/server.js +1 -1
- package/examples/codex-doctor-risky/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-risky/.mcp.json +12 -12
- package/examples/codex-doctor-risky/mock-server.js +1 -1
- package/examples/codex-doctor-runtime/.codex-plugin/plugin.json +8 -8
- package/examples/codex-doctor-runtime/.mcp.json +9 -9
- package/examples/codex-doctor-runtime/mock-server.js +237 -237
- package/examples/codex-doctor-runtime/skills/context-check/SKILL.md +7 -7
- package/examples/codex-doctor-starter/.codex-plugin/plugin.json +7 -7
- package/examples/codex-doctor-starter/skills/repo-scan/SKILL.md +7 -7
- package/package.json +1 -1
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
|
@@ -78,21 +78,37 @@ Runtime MCP validation with `--runtime`:
|
|
|
78
78
|
- paginated list responses
|
|
79
79
|
- runtime capability scorecard
|
|
80
80
|
- redacted verbose transcript with `--verbose-runtime`
|
|
81
|
-
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
-
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
-
|
|
84
|
-
### Remote MCP Readiness
|
|
85
|
-
|
|
86
|
-
Remote MCP runtime probing is disabled until you explicitly pass `--allow-network`.
|
|
87
|
-
|
|
88
|
-
```bash
|
|
89
|
-
codex-plugin-doctor check ./remote-mcp --runtime --allow-network
|
|
90
|
-
codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The probe makes only bounded
|
|
94
|
-
|
|
95
|
-
|
|
81
|
+
- optional runtime approval gating with a precomputed `doctor runtime-plan` digest
|
|
82
|
+
- optional Docker isolation for local Node.js stdio servers with `--sandbox docker`
|
|
83
|
+
|
|
84
|
+
### Remote MCP Readiness
|
|
85
|
+
|
|
86
|
+
Remote MCP runtime probing is disabled until you explicitly pass `--runtime --allow-network`. Loopback endpoints also require `--allow-local-network`:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network
|
|
90
|
+
codex-plugin-doctor check ./remote-mcp --runtime --allow-network --allow-local-network
|
|
91
|
+
```
|
|
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).
|
|
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).
|
|
110
|
+
|
|
111
|
+
Output formats:
|
|
96
112
|
|
|
97
113
|
- human text output
|
|
98
114
|
- JSON reports
|
|
@@ -438,9 +454,9 @@ jobs:
|
|
|
438
454
|
runs-on: ubuntu-latest
|
|
439
455
|
steps:
|
|
440
456
|
- uses: actions/checkout@v5
|
|
441
|
-
- uses: Esquetta/CodexPluginDoctor@v1.
|
|
457
|
+
- uses: Esquetta/CodexPluginDoctor@v1.54.0
|
|
442
458
|
with:
|
|
443
|
-
version: "1.
|
|
459
|
+
version: "1.54.0"
|
|
444
460
|
path: .
|
|
445
461
|
runtime: "true"
|
|
446
462
|
policy: codex-publish
|
|
@@ -7,6 +7,7 @@ const DEFAULT_MAX_RESPONSE_BYTES = 1_024 * 1_024;
|
|
|
7
7
|
const allowedRequestHeaders = new Set([
|
|
8
8
|
"accept",
|
|
9
9
|
"content-type",
|
|
10
|
+
"last-event-id",
|
|
10
11
|
"mcp-protocol-version",
|
|
11
12
|
"mcp-session-id",
|
|
12
13
|
"user-agent"
|
|
@@ -137,7 +138,7 @@ export async function requestBoundedHttp(rawUrl, options = {}) {
|
|
|
137
138
|
let response;
|
|
138
139
|
let settled = false;
|
|
139
140
|
const timeout = setTimeout(() => {
|
|
140
|
-
fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out."));
|
|
141
|
+
fail(new BoundedHttpError("REMOTE_HTTP_TIMEOUT", "Remote HTTP request timed out.", response?.statusCode, response ? selectSafeHeaders(response.headers) : undefined));
|
|
141
142
|
}, remainingMs);
|
|
142
143
|
const fail = (error) => {
|
|
143
144
|
if (settled) {
|
|
@@ -16,7 +16,9 @@ export function redactValue(value) {
|
|
|
16
16
|
if (typeof value === "object" && value !== null) {
|
|
17
17
|
return Object.fromEntries(Object.entries(value).map(([key, nestedValue]) => [
|
|
18
18
|
key,
|
|
19
|
-
|
|
19
|
+
/^(?:mcp[-_]?session[-_]?id|session[-_]?id|last[-_]?event[-_]?id|event[-_]?id|retry|(?:sse[-_]?)?data)$/i.test(key)
|
|
20
|
+
? "[REDACTED_SECRET]"
|
|
21
|
+
: redactValue(nestedValue)
|
|
20
22
|
]));
|
|
21
23
|
}
|
|
22
24
|
return value;
|
|
@@ -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
|
+
}
|