From 10636efe415a2398595b695a2221215dba75324f Mon Sep 17 00:00:00 2001 From: Furkan Date: Mon, 27 Jul 2026 19:57:43 +0300 Subject: [PATCH 1/6] feat: add MCP Registry readiness doctor --- src/core/mcp-registry.ts | 615 +++++++++++++++++++++++++++++++++ src/index.ts | 12 + src/run-cli.ts | 75 ++++ tests/mcp-registry.test.ts | 177 ++++++++++ tests/registry-command.test.ts | 68 ++++ 5 files changed, 947 insertions(+) create mode 100644 src/core/mcp-registry.ts create mode 100644 tests/mcp-registry.test.ts create mode 100644 tests/registry-command.test.ts diff --git a/src/core/mcp-registry.ts b/src/core/mcp-registry.ts new file mode 100644 index 0000000..58d1e17 --- /dev/null +++ b/src/core/mcp-registry.ts @@ -0,0 +1,615 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { requestBoundedHttp, type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js"; + +const OFFICIAL_SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json"; +const OFFICIAL_REGISTRY = "https://registry.modelcontextprotocol.io"; +const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; +const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i; +const SECRET_NAME_PATTERN = /(?:api[_-]?key|token|secret|password|credential)/i; + +type RegistryCheckStatus = "pass" | "warn" | "fail" | "skipped"; +type RegistryRequest = ( + url: string, + options?: BoundedHttpRequestOptions +) => Promise; + +export interface McpRegistryFinding { + id: string; + severity: "warn" | "fail"; + message: string; + path?: string; +} + +export interface McpRegistryScorecard { + metadata: RegistryCheckStatus; + ownership: RegistryCheckStatus; + packageIntegrity: RegistryCheckStatus; + transportReadiness: RegistryCheckStatus; + clientInstallability: RegistryCheckStatus; + overall: Exclude; +} + +export interface McpRegistryInstallability { + codex: "ready" | "manual" | "unavailable"; + packageTypes: string[]; + remoteTransports: string[]; + codexPreview?: { + mcpServers: Record; + }; +} + +export interface McpRegistryReadinessReport { + schemaVersion: "1"; + kind: "mcp-registry-readiness"; + source: "file" | "registry"; + target: string; + serverName?: string; + serverVersion?: string; + status: "pass" | "warn" | "fail"; + scorecard: McpRegistryScorecard; + installability: McpRegistryInstallability; + findings: McpRegistryFinding[]; + registry?: { + lifecycleStatus: string; + isLatest?: boolean; + publishedAt?: string; + }; +} + +export interface InspectMcpRegistryOptions { + allowNetwork?: boolean; + request?: RegistryRequest; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function statusFromFindings(findings: McpRegistryFinding[]): "pass" | "warn" | "fail" { + if (findings.some((finding) => finding.severity === "fail")) { + return "fail"; + } + return findings.length > 0 ? "warn" : "pass"; +} + +function areaStatus( + findings: McpRegistryFinding[], + prefix: string, + fallback: RegistryCheckStatus = "pass" +): RegistryCheckStatus { + const matches = findings.filter((finding) => finding.id.startsWith(prefix)); + if (matches.some((finding) => finding.severity === "fail")) { + return "fail"; + } + return matches.length > 0 ? "warn" : fallback; +} + +function addFinding( + findings: McpRegistryFinding[], + id: string, + severity: McpRegistryFinding["severity"], + message: string, + findingPath?: string +): void { + findings.push({ + id, + severity, + message, + ...(findingPath ? { path: findingPath } : {}) + }); +} + +function parseUrlTemplate(rawValue: unknown): URL | null { + if (typeof rawValue !== "string" || rawValue.length === 0 || /\s/.test(rawValue)) { + return null; + } + try { + return new URL(rawValue.replace(/\{[^{}]+\}/g, "template-value")); + } catch { + return null; + } +} + +function inspectUrl( + findings: McpRegistryFinding[], + rawValue: unknown, + findingPath: string, + options: { httpsOnly?: boolean; prefix: string } +): URL | null { + const parsed = parseUrlTemplate(rawValue); + if (!parsed || (parsed.protocol !== "http:" && parsed.protocol !== "https:")) { + addFinding(findings, `${options.prefix}.url-invalid`, "fail", "URL must be an absolute HTTP or HTTPS URL.", findingPath); + return null; + } + if (parsed.username || parsed.password) { + addFinding(findings, `${options.prefix}.credentials`, "fail", "URL must not contain credentials.", findingPath); + } + if (options.httpsOnly && parsed.protocol !== "https:") { + addFinding(findings, `${options.prefix}.http`, "warn", "Public Registry URLs should use HTTPS.", findingPath); + } + return parsed; +} + +function inspectInputSecrets( + findings: McpRegistryFinding[], + values: unknown, + findingPath: string +): void { + if (!Array.isArray(values)) { + return; + } + values.forEach((value, index) => { + if (!isRecord(value)) { + return; + } + const name = typeof value.name === "string" ? value.name : ""; + if (typeof value.value === "string" && value.value.length > 0 + && (value.isSecret === true || SECRET_NAME_PATTERN.test(name))) { + addFinding( + findings, + "registry.secret.embedded-value", + "fail", + "Secret-like Registry inputs must not embed a fixed value.", + `${findingPath}[${index}].value` + ); + } + }); +} + +function inspectArguments( + findings: McpRegistryFinding[], + values: unknown, + findingPath: string +): void { + if (!Array.isArray(values)) { + return; + } + values.forEach((value, index) => { + if (isRecord(value) && typeof value.value === "string" && /[;&|`]/.test(value.value)) { + addFinding( + findings, + "registry.package.argument-shell-risk", + "warn", + "Fixed argument contains shell metacharacters; clients must execute without a shell.", + `${findingPath}[${index}].value` + ); + } + }); +} + +function githubOwnerFromRepository(repository: Record): string | null { + if (repository.source !== "github" || typeof repository.url !== "string") { + return null; + } + try { + const url = new URL(repository.url); + if (url.hostname.toLowerCase() !== "github.com") { + return null; + } + return url.pathname.split("/").filter(Boolean)[0] ?? null; + } catch { + return null; + } +} + +function buildInstallability( + server: Record, + validPackages: Array>, + validRemotes: Array> +): McpRegistryInstallability { + const packageTypes = [...new Set(validPackages + .map((entry) => typeof entry.registryType === "string" ? entry.registryType : "") + .filter(Boolean))]; + const remoteTransports = [...new Set(validRemotes + .map((entry) => typeof entry.type === "string" ? entry.type : "") + .filter(Boolean))]; + const shortName = typeof server.name === "string" + ? server.name.split("/").at(-1) ?? "server" + : "server"; + + const remote = validRemotes.find((entry) => { + const url = parseUrlTemplate(entry.url); + return url?.protocol === "https:" && typeof entry.url === "string" && !entry.url.includes("{"); + }); + if (remote && typeof remote.url === "string") { + return { + codex: "ready", + packageTypes, + remoteTransports, + codexPreview: { + mcpServers: { + [shortName]: { url: remote.url } + } + } + }; + } + + const npmPackage = validPackages.find((entry) => + entry.registryType === "npm" + && typeof entry.identifier === "string" + && typeof entry.version === "string" + && !VERSION_RANGE_PATTERN.test(entry.version) + && isRecord(entry.transport) + && entry.transport.type === "stdio" + ); + if (npmPackage && typeof npmPackage.identifier === "string" && typeof npmPackage.version === "string") { + return { + codex: "ready", + packageTypes, + remoteTransports, + codexPreview: { + mcpServers: { + [shortName]: { + command: "npx", + args: ["-y", `${npmPackage.identifier}@${npmPackage.version}`] + } + } + } + }; + } + + return { + codex: validPackages.length > 0 || validRemotes.length > 0 ? "manual" : "unavailable", + packageTypes, + remoteTransports + }; +} + +async function readOptionalPackageJson(directory: string): Promise | null> { + try { + const parsed: unknown = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +async function resolveServerJsonPath(targetPath: string): Promise { + const resolved = path.resolve(targetPath); + try { + return (await stat(resolved)).isDirectory() ? path.join(resolved, "server.json") : resolved; + } catch { + return resolved.endsWith(".json") ? resolved : path.join(resolved, "server.json"); + } +} + +async function buildReportFromServer( + server: unknown, + target: string, + source: McpRegistryReadinessReport["source"], + packageJson: Record | null = null, + extraFindings: McpRegistryFinding[] = [] +): Promise { + const findings = [...extraFindings]; + const validPackages: Array> = []; + const validRemotes: Array> = []; + let ownershipVerified = false; + + if (!isRecord(server)) { + addFinding(findings, "registry.metadata.object", "fail", "server.json must contain a JSON object."); + return finalizeReport({}, target, source, findings, validPackages, validRemotes, ownershipVerified); + } + + if (server.$schema !== OFFICIAL_SCHEMA) { + addFinding( + findings, + "registry.metadata.schema", + "fail", + `server.json must reference the current official schema: ${OFFICIAL_SCHEMA}.`, + "$schema" + ); + } + if (typeof server.name !== "string" || server.name.length < 3 + || server.name.length > 200 || !SERVER_NAME_PATTERN.test(server.name)) { + addFinding(findings, "registry.metadata.name", "fail", "Server name must use namespace/name format.", "name"); + } + if (typeof server.description !== "string" || server.description.length < 1 || server.description.length > 100) { + addFinding(findings, "registry.metadata.description", "fail", "Description must contain 1 to 100 characters.", "description"); + } + if (typeof server.version !== "string" || server.version.length < 1 || server.version.length > 255 + || VERSION_RANGE_PATTERN.test(server.version)) { + addFinding(findings, "registry.metadata.version", "fail", "Server version must be a specific version string.", "version"); + } + + if (isRecord(server.repository)) { + inspectUrl(findings, server.repository.url, "repository.url", { + httpsOnly: true, + prefix: "registry.repository" + }); + if (typeof server.repository.source !== "string" || server.repository.source.length === 0) { + addFinding(findings, "registry.repository.source", "fail", "Repository source is required when repository metadata is present.", "repository.source"); + } + if (typeof server.name === "string" && server.name.toLowerCase().startsWith("io.github.")) { + const namespaceOwner = server.name.slice("io.github.".length).split("/")[0]?.toLowerCase(); + const repositoryOwner = githubOwnerFromRepository(server.repository)?.toLowerCase(); + if (repositoryOwner && namespaceOwner !== repositoryOwner) { + addFinding( + findings, + "registry.ownership.github-mismatch", + "fail", + "GitHub repository owner does not match the io.github namespace.", + "repository.url" + ); + } + } + } + + if (typeof server.websiteUrl === "string") { + inspectUrl(findings, server.websiteUrl, "websiteUrl", { + httpsOnly: true, + prefix: "registry.website" + }); + } + if (Array.isArray(server.icons)) { + server.icons.forEach((icon, index) => { + if (!isRecord(icon)) { + addFinding(findings, "registry.icon.invalid", "fail", "Icon entry must be an object.", `icons[${index}]`); + return; + } + inspectUrl(findings, icon.src, `icons[${index}].src`, { + httpsOnly: true, + prefix: "registry.icon" + }); + }); + } + + if (server.packages !== undefined && !Array.isArray(server.packages)) { + addFinding(findings, "registry.package.array", "fail", "Packages must be an array.", "packages"); + } else if (Array.isArray(server.packages)) { + server.packages.forEach((entry, index) => { + const packagePath = `packages[${index}]`; + if (!isRecord(entry)) { + addFinding(findings, "registry.package.invalid", "fail", "Package entry must be an object.", packagePath); + return; + } + const type = entry.registryType; + if (typeof type !== "string" || type.length === 0) { + addFinding(findings, "registry.package.type", "fail", "Package registryType is required.", `${packagePath}.registryType`); + } + if (typeof entry.identifier !== "string" || entry.identifier.length === 0) { + addFinding(findings, "registry.package.identifier", "fail", "Package identifier is required.", `${packagePath}.identifier`); + } else if (/^https?:\/\//i.test(entry.identifier)) { + inspectUrl(findings, entry.identifier, `${packagePath}.identifier`, { + httpsOnly: true, + prefix: "registry.package" + }); + } + if (!isRecord(entry.transport) || typeof entry.transport.type !== "string" + || !["stdio", "streamable-http", "sse"].includes(entry.transport.type)) { + addFinding(findings, "registry.package.transport", "fail", "Package transport must be stdio, streamable-http, or sse.", `${packagePath}.transport`); + } + if (typeof entry.version === "string" && VERSION_RANGE_PATTERN.test(entry.version)) { + addFinding(findings, "registry.package.version-range", "fail", "Package version must be exact, not a range or latest.", `${packagePath}.version`); + } + if (type === "mcpb" && (typeof entry.fileSha256 !== "string" || !SHA256_PATTERN.test(entry.fileSha256))) { + addFinding(findings, "registry.package.mcpb-hash-missing", "fail", "MCPB packages require a lowercase SHA-256 digest.", `${packagePath}.fileSha256`); + } + inspectInputSecrets(findings, entry.environmentVariables, `${packagePath}.environmentVariables`); + inspectArguments(findings, entry.packageArguments, `${packagePath}.packageArguments`); + inspectArguments(findings, entry.runtimeArguments, `${packagePath}.runtimeArguments`); + validPackages.push(entry); + }); + } + + if (server.remotes !== undefined && !Array.isArray(server.remotes)) { + addFinding(findings, "registry.remote.array", "fail", "Remotes must be an array.", "remotes"); + } else if (Array.isArray(server.remotes)) { + server.remotes.forEach((entry, index) => { + const remotePath = `remotes[${index}]`; + if (!isRecord(entry)) { + addFinding(findings, "registry.remote.invalid", "fail", "Remote entry must be an object.", remotePath); + return; + } + if (entry.type !== "streamable-http" && entry.type !== "sse") { + addFinding(findings, "registry.remote.transport", "fail", "Remote transport must be streamable-http or sse.", `${remotePath}.type`); + } + inspectUrl(findings, entry.url, `${remotePath}.url`, { + httpsOnly: true, + prefix: "registry.remote" + }); + inspectInputSecrets(findings, entry.headers, `${remotePath}.headers`); + validRemotes.push(entry); + }); + } + + if (validPackages.length === 0 && validRemotes.length === 0) { + addFinding( + findings, + "registry.installability.missing", + "warn", + "Metadata is valid, but no package or remote installation channel is declared." + ); + } + + if (packageJson && typeof server.name === "string") { + const npmPackages = validPackages.filter((entry) => entry.registryType === "npm"); + for (const entry of npmPackages) { + if (packageJson.name === entry.identifier) { + if (packageJson.mcpName !== server.name) { + addFinding(findings, "registry.ownership.npm-mcp-name", "fail", "package.json mcpName must match server.json name.", "package.json#mcpName"); + } else { + ownershipVerified = true; + } + if (typeof entry.version === "string" && packageJson.version !== entry.version) { + addFinding(findings, "registry.package.local-version-mismatch", "fail", "Local npm package version does not match server.json.", "package.json#version"); + } + } + } + } else if (validPackages.some((entry) => entry.registryType === "npm")) { + addFinding( + findings, + "registry.ownership.npm-unverified", + "warn", + "No adjacent package.json was available to verify npm mcpName ownership." + ); + } + + return finalizeReport(server, target, source, findings, validPackages, validRemotes, ownershipVerified); +} + +function finalizeReport( + server: Record, + target: string, + source: McpRegistryReadinessReport["source"], + findings: McpRegistryFinding[], + validPackages: Array>, + validRemotes: Array>, + ownershipVerified = false +): McpRegistryReadinessReport { + const status = statusFromFindings(findings); + const hasTransport = validPackages.length > 0 || validRemotes.length > 0; + const installability = buildInstallability(server, validPackages, validRemotes); + return { + schemaVersion: "1", + kind: "mcp-registry-readiness", + source, + target, + ...(typeof server.name === "string" ? { serverName: server.name } : {}), + ...(typeof server.version === "string" ? { serverVersion: server.version } : {}), + status, + scorecard: { + metadata: areaStatus(findings, "registry.metadata."), + ownership: areaStatus(findings, "registry.ownership.", ownershipVerified ? "pass" : "skipped"), + packageIntegrity: validPackages.length > 0 + ? areaStatus(findings, "registry.package.") + : "skipped", + transportReadiness: hasTransport + ? (areaStatus(findings, "registry.remote.") === "fail" + || areaStatus(findings, "registry.package.") === "fail" ? "fail" : "pass") + : "skipped", + clientInstallability: installability.codex === "ready" + ? "pass" + : installability.codex === "manual" ? "warn" : "skipped", + overall: status + }, + installability, + findings + }; +} + +export async function buildMcpRegistryReadiness(targetPath: string): Promise { + const serverJsonPath = await resolveServerJsonPath(targetPath); + let server: unknown; + try { + server = JSON.parse(await readFile(serverJsonPath, "utf8")); + } catch { + return buildReportFromServer({}, serverJsonPath, "file", null, [{ + id: "registry.metadata.read", + severity: "fail", + message: "Unable to read a valid server.json file.", + path: serverJsonPath + }]); + } + const packageJson = await readOptionalPackageJson(path.dirname(serverJsonPath)); + return buildReportFromServer(server, serverJsonPath, "file", packageJson); +} + +export async function inspectMcpRegistryServer( + serverName: string, + options: InspectMcpRegistryOptions = {} +): Promise { + if (!options.allowNetwork) { + throw new Error("Registry inspection requires explicit --allow-network consent."); + } + if (!SERVER_NAME_PATTERN.test(serverName)) { + throw new Error("Registry server name must use namespace/name format."); + } + + const url = `${OFFICIAL_REGISTRY}/v0.1/servers/${encodeURIComponent(serverName)}/versions/latest`; + const response = await (options.request ?? requestBoundedHttp)(url, { + method: "GET", + headers: { + accept: "application/json", + "user-agent": "codex-plugin-doctor" + } + }); + const findings: McpRegistryFinding[] = []; + if (response.statusCode !== 200) { + addFinding(findings, "registry.lookup.http", "fail", `Registry lookup returned HTTP ${response.statusCode}.`); + return buildReportFromServer({}, serverName, "registry", null, findings); + } + + let payload: unknown; + try { + payload = JSON.parse(response.body.toString("utf8")); + } catch { + addFinding(findings, "registry.lookup.json", "fail", "Registry returned invalid JSON."); + return buildReportFromServer({}, serverName, "registry", null, findings); + } + if (!isRecord(payload) || !isRecord(payload.server)) { + addFinding(findings, "registry.lookup.response", "fail", "Registry response is missing server metadata."); + return buildReportFromServer({}, serverName, "registry", null, findings); + } + if (payload.server.name !== serverName) { + addFinding(findings, "registry.lookup.name-mismatch", "fail", "Registry response server name does not match the requested exact name."); + } + + const report = await buildReportFromServer(payload.server, serverName, "registry", null, findings); + const officialMeta = isRecord(payload._meta) + && isRecord(payload._meta["io.modelcontextprotocol.registry/official"]) + ? payload._meta["io.modelcontextprotocol.registry/official"] + : null; + const lifecycleStatus = officialMeta && typeof officialMeta.status === "string" + ? officialMeta.status + : "unknown"; + + if (lifecycleStatus === "deprecated") { + addFinding(report.findings, "registry.lookup.deprecated", "warn", "Registry entry is deprecated."); + } else if (lifecycleStatus !== "active") { + addFinding(report.findings, "registry.lookup.lifecycle", "fail", "Registry entry is not active."); + } + report.registry = { + lifecycleStatus, + ...(officialMeta && typeof officialMeta.isLatest === "boolean" ? { isLatest: officialMeta.isLatest } : {}), + ...(officialMeta && typeof officialMeta.publishedAt === "string" ? { publishedAt: officialMeta.publishedAt } : {}) + }; + report.status = statusFromFindings(report.findings); + report.scorecard.overall = report.status; + return report; +} + +export function registryReadinessExitCode( + report: McpRegistryReadinessReport, + requireReadiness = false +): 0 | 1 { + return report.status === "fail" || (requireReadiness && report.status !== "pass") ? 1 : 0; +} + +export function renderMcpRegistryReadinessJson(report: McpRegistryReadinessReport): string { + return JSON.stringify(report, null, 2); +} + +export function renderMcpRegistryReadiness(report: McpRegistryReadinessReport): string { + const lines = [ + `Registry readiness: ${report.status.toUpperCase()}`, + `Source: ${report.source}`, + `Target: ${report.target}`, + report.serverName ? `Server: ${report.serverName}@${report.serverVersion ?? "unknown"}` : null, + "", + "Scorecard", + "---------", + `Metadata: ${report.scorecard.metadata}`, + `Ownership: ${report.scorecard.ownership}`, + `Package integrity: ${report.scorecard.packageIntegrity}`, + `Transport readiness: ${report.scorecard.transportReadiness}`, + `Codex installability: ${report.scorecard.clientInstallability}`, + `Overall: ${report.scorecard.overall}`, + "", + `Codex: ${report.installability.codex}` + ].filter((line): line is string => line !== null); + + if (report.findings.length > 0) { + lines.push("", "Findings", "--------"); + for (const finding of report.findings) { + lines.push(`${finding.severity.toUpperCase()} ${finding.id}: ${finding.message}${finding.path ? ` (${finding.path})` : ""}`); + } + } + return lines.join("\n"); +} diff --git a/src/index.ts b/src/index.ts index f8fb585..c5a34ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -285,6 +285,18 @@ export { renderGenericMcpDoctorJson, type GenericMcpDoctorReport } from "./mcp/generic-mcp-doctor.js"; +export { + buildMcpRegistryReadiness, + inspectMcpRegistryServer, + registryReadinessExitCode, + renderMcpRegistryReadiness, + renderMcpRegistryReadinessJson, + type InspectMcpRegistryOptions, + type McpRegistryFinding, + type McpRegistryInstallability, + type McpRegistryReadinessReport, + type McpRegistryScorecard +} from "./core/mcp-registry.js"; export { watchPlugin, type WatchPluginOptions, diff --git a/src/run-cli.ts b/src/run-cli.ts index d059fdb..8447030 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -153,6 +153,13 @@ import { renderDoctorNpmPackageReport, renderDoctorNpmPackageReportJson } from "./core/npm-package-doctor.js"; +import { + buildMcpRegistryReadiness, + inspectMcpRegistryServer, + registryReadinessExitCode, + renderMcpRegistryReadiness, + renderMcpRegistryReadinessJson +} from "./core/mcp-registry.js"; import { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, @@ -403,6 +410,10 @@ function printUsage(io: CliIo): void { io.writeStderr( "Usage: codex-plugin-doctor check [filter] [--policy codex-publish|mcp-strict|security] [--compat] [--json|--markdown|--badge-json|--badge-markdown] [--output ] [--history ] [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--require-runtime-approval --runtime-approval-digest ] [--verbose-runtime] [--explain] [--no-animations] [--ascii] [--changed-since ]\n codex-plugin-doctor audit --installed [filter] [--policy codex-publish|mcp-strict|security] [--security] [--compat] [--json] [--output ] [--cache] [--changed]\n codex-plugin-doctor audit deps [--policy codex-publish|mcp-strict|security] [--recommend] [--json|--sarif] [--output ]\n codex-plugin-doctor mcp [--runtime [--allow-network [--allow-local-network]]] [--json] [--output ]\n codex-plugin-doctor security [--policy security] [--json|--scorecard]\n codex-plugin-doctor compat [--all|--client ] [--json] [--scorecard] [--output ] [--install-preview|--apply --backup]\n codex-plugin-doctor suppress add [--fingerprint --reason --expires-at YYYY-MM-DD] [--config ] [--json]\n codex-plugin-doctor suppress list [--config ] [--json]\n codex-plugin-doctor suppress remove [--fingerprint |--index ] [--config ] [--json]\n codex-plugin-doctor fix (--dry-run|--interactive --backup|--apply --backup)\n codex-plugin-doctor history [--json] [--fail-on-regression]\n codex-plugin-doctor watch [--runtime] [--json] [--output ] [--debounce-ms ] [--max-iterations ] [--fail-fast] [--accumulate-json ]\n codex-plugin-doctor doctor [npm |contract|corpus [--manifest ] [--json] [--output ]|corpus metrics --manifest [--json|--markdown] [--output ] [--min-precision <0..1>] [--min-recall <0..1>] [--max-false-positive-rate <0..1>]|runtime-plan [--sandbox docker] [--json|--markdown] [--output ]|runtime-policy [--sandbox docker] [--json] [--output ]|review-bundle --output --sign-key-env NAME [--json] [--allow-dirty] [--allow-untagged]|review-bundle verify --target --sign-key-env NAME [--json] [--output ] [--failures-only]|review-bundle diff --before --after [--json]|attest [--sign-key-env NAME]|attest verify --target --sign-key-env NAME|release-evidence --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest ]|release-evidence verify --target --sign-key-env NAME|release-evidence asset --tag --output --sign-key-env NAME [--runtime [--allow-network [--allow-local-network]]] [--sandbox docker] [--allow-dirty] [--allow-untagged] [--require-runtime-approval --runtime-approval-digest ] [--upload]|mcp [--runtime [--allow-network [--allow-local-network]]]|inspector |diff --before --after |recommend |trust |perf [--max-total-ms ] [--max-stage-ms stage=ms]|export --bundle |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 [--json]\n codex-plugin-doctor release check [--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 \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" ); + io.writeStderr( + "Registry readiness: codex-plugin-doctor registry check [--json] [--output ] [--require-registry-readiness]\n" + + " codex-plugin-doctor registry inspect --allow-network [--json] [--output ] [--require-registry-readiness]" + ); io.writeStderr( "Corpus quality regression: codex-plugin-doctor doctor corpus metrics diff --before --after [--fail-on-regression] [--json|--markdown] [--output ]" ); @@ -3290,6 +3301,70 @@ export async function runCli( return 0; } + if (command === "registry") { + const subcommand = maybePath; + const target = remainingArgs[0]; + const flags = remainingArgs.slice(1); + if ((subcommand !== "check" && subcommand !== "inspect") || !target || target.startsWith("--")) { + io.writeStderr( + "Usage: codex-plugin-doctor registry check [--json] [--output ] [--require-registry-readiness]\n" + + " codex-plugin-doctor registry inspect --allow-network [--json] [--output ] [--require-registry-readiness]" + ); + return 2; + } + + const allowedFlags = new Set([ + "--json", + "--output", + "--allow-network", + "--require-registry-readiness" + ]); + let outputPath: string | null = null; + for (let index = 0; index < flags.length; index += 1) { + const flag = flags[index]; + if (!allowedFlags.has(flag)) { + io.writeStderr(`Unknown registry flag: ${flag}.`); + return 2; + } + if (flag === "--output") { + const value = flags[index + 1]; + if (!value || value.startsWith("--")) { + io.writeStderr("Missing path after --output."); + return 2; + } + outputPath = value; + index += 1; + } + } + + const allowNetwork = flags.includes("--allow-network"); + if (subcommand === "check" && allowNetwork) { + io.writeStderr("--allow-network is supported only by registry inspect."); + return 2; + } + if (subcommand === "inspect" && !allowNetwork) { + io.writeStderr("registry inspect requires explicit --allow-network consent."); + return 2; + } + + try { + const report = subcommand === "check" + ? await buildMcpRegistryReadiness(target) + : await inspectMcpRegistryServer(target, { allowNetwork: true }); + const rendered = flags.includes("--json") + ? renderMcpRegistryReadinessJson(report) + : renderMcpRegistryReadiness(report); + if (outputPath) { + await writeFile(outputPath, rendered, "utf8"); + } + io.writeStdout(rendered); + return registryReadinessExitCode(report, flags.includes("--require-registry-readiness")); + } catch (error) { + io.writeStderr(`Registry inspection failed: ${(error as Error).message}`); + return 1; + } + } + if (command === "security") { if (!maybePath || maybePath.startsWith("--")) { io.writeStderr("Missing target path. Usage: codex-plugin-doctor security [--json|--scorecard]"); diff --git a/tests/mcp-registry.test.ts b/tests/mcp-registry.test.ts new file mode 100644 index 0000000..36ee881 --- /dev/null +++ b/tests/mcp-registry.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +import { + buildMcpRegistryReadiness, + inspectMcpRegistryServer, + renderMcpRegistryReadiness, + renderMcpRegistryReadinessJson +} from "../src/core/mcp-registry.js"; + +async function writeServerJson(server: unknown, packageJson?: unknown): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-registry-")); + await writeFile(path.join(directory, "server.json"), JSON.stringify(server), "utf8"); + if (packageJson) { + await writeFile(path.join(directory, "package.json"), JSON.stringify(packageJson), "utf8"); + } + return directory; +} + +const validServer = { + $schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + name: "io.github.example/weather", + description: "Weather tools.", + version: "1.2.3", + repository: { + url: "https://github.com/example/weather", + source: "github" + }, + packages: [{ + registryType: "npm", + identifier: "@example/weather-mcp", + version: "1.2.3", + transport: { type: "stdio" } + }] +}; + +describe("MCP Registry readiness", () => { + it("passes matching npm metadata and produces a non-mutating Codex preview", async () => { + const target = await writeServerJson(validServer, { + name: "@example/weather-mcp", + version: "1.2.3", + mcpName: "io.github.example/weather" + }); + + const report = await buildMcpRegistryReadiness(target); + + expect(report.status).toBe("pass"); + expect(report.scorecard).toMatchObject({ + metadata: "pass", + ownership: "pass", + packageIntegrity: "pass", + transportReadiness: "pass", + clientInstallability: "pass", + overall: "pass" + }); + expect(report.installability.codex).toBe("ready"); + expect(report.installability.codexPreview).toEqual({ + mcpServers: { + weather: { + command: "npx", + args: ["-y", "@example/weather-mcp@1.2.3"] + } + } + }); + expect(renderMcpRegistryReadiness(report)).toContain("Registry readiness: PASS"); + expect(JSON.parse(renderMcpRegistryReadinessJson(report))).toMatchObject({ + kind: "mcp-registry-readiness", + status: "pass" + }); + }); + + it("keeps metadata-only entries valid but reports missing installability evidence", async () => { + const target = await writeServerJson({ + $schema: validServer.$schema, + name: "com.example/metadata-only", + description: "Metadata only.", + version: "2026.07" + }); + + const report = await buildMcpRegistryReadiness(target); + + expect(report.status).toBe("warn"); + expect(report.scorecard.metadata).toBe("pass"); + expect(report.scorecard.transportReadiness).toBe("skipped"); + expect(report.installability.codex).toBe("unavailable"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.installability.missing"); + }); + + it("rejects unsafe or inconsistent publication metadata", async () => { + const target = await writeServerJson({ + ...validServer, + name: "io.github.attacker/weather", + repository: { + url: "https://github.com/example/weather", + source: "github" + }, + remotes: [{ + type: "streamable-http", + url: "https://user:secret@example.com/mcp" + }], + packages: [{ + registryType: "mcpb", + identifier: "https://github.com/example/weather/releases/download/v1/weather.mcpb", + transport: { type: "stdio" } + }] + }); + + const report = await buildMcpRegistryReadiness(target); + const ids = report.findings.map((finding) => finding.id); + + expect(report.status).toBe("fail"); + expect(ids).toContain("registry.ownership.github-mismatch"); + expect(ids).toContain("registry.remote.credentials"); + expect(ids).toContain("registry.package.mcpb-hash-missing"); + }); + + it("looks up only the fixed exact-name endpoint when network consent is explicit", async () => { + const request = vi.fn(async () => ({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ + server: validServer, + _meta: { + "io.modelcontextprotocol.registry/official": { + status: "active", + isLatest: true, + publishedAt: "2026-07-01T00:00:00Z" + } + } + })) + })); + + const report = await inspectMcpRegistryServer(validServer.name, { + allowNetwork: true, + request + }); + + expect(request).toHaveBeenCalledWith( + "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.example%2Fweather/versions/latest", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ accept: "application/json" }) + }) + ); + expect(report.source).toBe("registry"); + expect(report.registry).toMatchObject({ lifecycleStatus: "active", isLatest: true }); + }); + + it("requires consent and rejects name substitution or deprecated lifecycle state", async () => { + await expect(inspectMcpRegistryServer(validServer.name)).rejects.toThrow("--allow-network"); + + const wrongName = vi.fn(async () => ({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ + server: { ...validServer, name: "io.github.attacker/weather" }, + _meta: { + "io.modelcontextprotocol.registry/official": { + status: "deprecated", + isLatest: true + } + } + })) + })); + + const report = await inspectMcpRegistryServer(validServer.name, { + allowNetwork: true, + request: wrongName + }); + + expect(report.status).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.lookup.name-mismatch"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.lookup.deprecated"); + }); +}); diff --git a/tests/registry-command.test.ts b/tests/registry-command.test.ts new file mode 100644 index 0000000..b149b98 --- /dev/null +++ b/tests/registry-command.test.ts @@ -0,0 +1,68 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { runCli } from "../src/run-cli.js"; + +function createIo() { + const stdout: string[] = []; + const stderr: string[] = []; + return { + stdout, + stderr, + io: { + writeStdout(message: string) { + stdout.push(message); + }, + writeStderr(message: string) { + stderr.push(message); + } + } + }; +} + +async function createMetadataOnlyServer(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-registry-cli-")); + await writeFile(path.join(directory, "server.json"), JSON.stringify({ + $schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + name: "com.example/metadata-only", + description: "Metadata only.", + version: "1.0.0" + }), "utf8"); + return directory; +} + +describe("registry command", () => { + it("checks local server metadata and exposes the strict readiness gate", async () => { + const target = await createMetadataOnlyServer(); + const normal = createIo(); + const strict = createIo(); + + expect(await runCli(["registry", "check", target, "--json"], normal.io)).toBe(0); + expect(JSON.parse(normal.stdout.join(""))).toMatchObject({ + kind: "mcp-registry-readiness", + status: "warn" + }); + + expect(await runCli([ + "registry", "check", target, "--require-registry-readiness" + ], strict.io)).toBe(1); + }); + + it("requires explicit network consent for Registry inspection", async () => { + const result = createIo(); + + expect(await runCli([ + "registry", "inspect", "io.github.example/weather" + ], result.io)).toBe(2); + expect(result.stderr.join("")).toContain("--allow-network"); + }); + + it("rejects unknown Registry flags", async () => { + const result = createIo(); + + expect(await runCli(["registry", "check", ".", "--publish"], result.io)).toBe(2); + expect(result.stderr.join("")).toContain("Unknown registry flag"); + }); +}); From bd9adf6f9bead4be1bdb89551d98185d4878a9a9 Mon Sep 17 00:00:00 2001 From: Furkan Date: Mon, 27 Jul 2026 19:59:05 +0300 Subject: [PATCH 2/6] feat: gate Registry readiness in GitHub Actions --- action.yml | 31 +++++++++++++++++++++++++++++++ tests/action-metadata.test.ts | 13 +++++++++++++ 2 files changed, 44 insertions(+) diff --git a/action.yml b/action.yml index 4e6d647..07ca8db 100644 --- a/action.yml +++ b/action.yml @@ -30,6 +30,14 @@ inputs: description: Fail the validation result unless every attempted remote MCP reliability scorecard passes; this does not grant network access. required: false default: "false" + registry-metadata: + description: Optional local server.json file or containing directory to validate for MCP Registry readiness. + required: false + default: "" + require-registry-readiness: + description: Fail unless the configured local Registry metadata receives a pass result. + required: false + default: "false" installed: description: Validate plugins from the local Codex plugin cache. required: false @@ -158,6 +166,9 @@ outputs: action-manifest-path: description: Path to the generated GitHub Action artifact manifest. value: ${{ steps.run-doctor.outputs.action-manifest-path }} + registry-report-path: + description: Path to the MCP Registry readiness JSON report when registry-metadata is configured. + value: ${{ steps.run-doctor.outputs.registry-report-path }} review-bundle-path: description: Path to the generated review bundle directory when review-bundle is enabled. value: ${{ steps.run-doctor.outputs.review-bundle-path }} @@ -180,6 +191,8 @@ runs: ALLOW_LOCAL_NETWORK_INPUT: ${{ inputs['allow-local-network'] }} ALLOW_SESSION_LIFECYCLE_INPUT: ${{ inputs['allow-session-lifecycle'] }} REQUIRE_REMOTE_RELIABILITY_INPUT: ${{ inputs['require-remote-reliability'] }} + REGISTRY_METADATA_INPUT: ${{ inputs['registry-metadata'] }} + REQUIRE_REGISTRY_READINESS_INPUT: ${{ inputs['require-registry-readiness'] }} CORPUS_METRICS_MANIFEST_INPUT: ${{ inputs['corpus-metrics-manifest'] }} CORPUS_METRICS_BASELINE_INPUT: ${{ inputs['corpus-metrics-baseline'] }} CORPUS_METRICS_FAIL_ON_REGRESSION_INPUT: ${{ inputs['corpus-metrics-fail-on-regression'] }} @@ -195,6 +208,7 @@ runs: corpus_metrics_diff_path="$report_dir/corpus-metrics-diff.json" output_contract_path="$report_dir/output-contract.json" action_manifest_path="$report_dir/codex-plugin-doctor-action-manifest.json" + registry_report_path="$report_dir/mcp-registry-readiness.json" review_bundle_path="$report_dir/${{ inputs['review-bundle-dir'] }}" review_bundle_verification_path="$report_dir/review-bundle-verification.json" status_file="${RUNNER_TEMP:-.}/codex-plugin-doctor-status" @@ -321,6 +335,19 @@ runs: run_doctor "output contract" doctor contract --json --output "$output_contract_path" fi + if [[ -z "$REGISTRY_METADATA_INPUT" ]]; then + if [[ "$REQUIRE_REGISTRY_READINESS_INPUT" == "true" ]]; then + echo "require-registry-readiness requires registry-metadata." + record_status 2 + fi + else + registry_args=(registry check "$REGISTRY_METADATA_INPUT" --json --output "$registry_report_path") + if [[ "$REQUIRE_REGISTRY_READINESS_INPUT" == "true" ]]; then + registry_args+=(--require-registry-readiness) + fi + run_doctor "MCP Registry readiness" "${registry_args[@]}" + fi + if [[ "${{ inputs['review-bundle'] }}" == "true" ]]; then signing_key_env="${{ inputs['signing-key-env'] }}" @@ -367,6 +394,7 @@ runs: export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS="$([[ -n "$CORPUS_METRICS_MANIFEST_INPUT" ]] && echo true || echo false)" export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF="$([[ -n "$CORPUS_METRICS_BASELINE_INPUT" ]] && echo true || echo false)" export CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT="${{ inputs.contract }}" + export CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY="$([[ -n "$REGISTRY_METADATA_INPUT" ]] && echo true || echo false)" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE="${{ inputs['review-bundle'] }}" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFY="${{ inputs['review-bundle-verify'] }}" export CODEX_PLUGIN_DOCTOR_ACTION_SUMMARY_PATH="$summary_path" @@ -376,6 +404,7 @@ runs: export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_PATH="$corpus_metrics_path" export CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF_PATH="$corpus_metrics_diff_path" export CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT_PATH="$output_contract_path" + export CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY_PATH="$registry_report_path" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_PATH="$review_bundle_path" export CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFICATION_PATH="$review_bundle_verification_path" node <<'NODE' @@ -407,6 +436,7 @@ runs: corpusMetrics: report("corpusMetrics", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_PATH"), corpusMetricsDiff: report("corpusMetricsDiff", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF", "CODEX_PLUGIN_DOCTOR_ACTION_CORPUS_METRICS_DIFF_PATH"), contract: report("contract", "CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT", "CODEX_PLUGIN_DOCTOR_ACTION_CONTRACT_PATH"), + registryReport: report("registryReport", "CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY", "CODEX_PLUGIN_DOCTOR_ACTION_REGISTRY_PATH"), reviewBundle: report("reviewBundle", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_PATH"), reviewBundleVerification: report("reviewBundleVerification", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFY", "CODEX_PLUGIN_DOCTOR_ACTION_REVIEW_BUNDLE_VERIFICATION_PATH") } @@ -432,6 +462,7 @@ runs: echo "corpus-metrics-diff-path=$corpus_metrics_diff_path" echo "output-contract-path=$output_contract_path" echo "action-manifest-path=$action_manifest_path" + echo "registry-report-path=$registry_report_path" echo "review-bundle-path=$review_bundle_path" echo "review-bundle-verification-path=$review_bundle_verification_path" } >> "$GITHUB_OUTPUT" diff --git a/tests/action-metadata.test.ts b/tests/action-metadata.test.ts index af356f7..dd5eebc 100644 --- a/tests/action-metadata.test.ts +++ b/tests/action-metadata.test.ts @@ -113,6 +113,19 @@ describe("GitHub Action metadata", () => { expect(actionMetadata).toContain('args+=(--require-remote-reliability)'); }); + it("supports opt-in local Registry metadata reports and strict readiness gating", async () => { + const actionMetadata = await readFile("action.yml", "utf8"); + + expect(actionMetadata).toMatch(/registry-metadata:[\s\S]*?default: ""/); + expect(actionMetadata).toMatch(/require-registry-readiness:[\s\S]*?default: "false"/); + expect(actionMetadata).toContain('REGISTRY_METADATA_INPUT: ${{ inputs[\'registry-metadata\'] }}'); + expect(actionMetadata).toContain('REQUIRE_REGISTRY_READINESS_INPUT: ${{ inputs[\'require-registry-readiness\'] }}'); + expect(actionMetadata).toContain('registry_args=(registry check "$REGISTRY_METADATA_INPUT" --json --output "$registry_report_path")'); + expect(actionMetadata).toContain('registry_args+=(--require-registry-readiness)'); + expect(actionMetadata).toContain('echo "registry-report-path=$registry_report_path"'); + expect(actionMetadata).toContain("registryReport: report("); + }); + it("documents loopback-only consent without permitting private or reserved ranges", async () => { const actionMetadata = await readFile("action.yml", "utf8"); const actionUsage = await readFile("docs/guides/github-action.md", "utf8"); From 8fd09de8989987fddccf75aaa12644930caeed23 Mon Sep 17 00:00:00 2001 From: Furkan Date: Tue, 28 Jul 2026 11:25:38 +0300 Subject: [PATCH 3/6] fix: support historical Registry metadata --- src/core/mcp-registry.ts | 15 +++++++++++++-- tests/mcp-registry.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/core/mcp-registry.ts b/src/core/mcp-registry.ts index 58d1e17..bb4ac59 100644 --- a/src/core/mcp-registry.ts +++ b/src/core/mcp-registry.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { requestBoundedHttp, type BoundedHttpRequestOptions, type BoundedHttpResponse } from "./bounded-http-client.js"; const OFFICIAL_SCHEMA = "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json"; +const OFFICIAL_SCHEMA_PATTERN = /^https:\/\/static\.modelcontextprotocol\.io\/schemas\/\d{4}-\d{2}-\d{2}\/server\.schema\.json$/; const OFFICIAL_REGISTRY = "https://registry.modelcontextprotocol.io"; const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; @@ -150,7 +151,7 @@ function inspectInputSecrets( return; } const name = typeof value.name === "string" ? value.name : ""; - if (typeof value.value === "string" && value.value.length > 0 + if (typeof value.value === "string" && value.value.length > 0 && !/\{[^{}]+\}/.test(value.value) && (value.isSecret === true || SECRET_NAME_PATTERN.test(name))) { addFinding( findings, @@ -297,7 +298,17 @@ async function buildReportFromServer( return finalizeReport({}, target, source, findings, validPackages, validRemotes, ownershipVerified); } - if (server.$schema !== OFFICIAL_SCHEMA) { + if (typeof server.$schema === "string" + && OFFICIAL_SCHEMA_PATTERN.test(server.$schema) + && server.$schema !== OFFICIAL_SCHEMA) { + addFinding( + findings, + "registry.metadata.schema-outdated", + "warn", + `server.json uses an older official schema; new publications should use ${OFFICIAL_SCHEMA}.`, + "$schema" + ); + } else if (server.$schema !== OFFICIAL_SCHEMA) { addFinding( findings, "registry.metadata.schema", diff --git a/tests/mcp-registry.test.ts b/tests/mcp-registry.test.ts index 36ee881..50cb139 100644 --- a/tests/mcp-registry.test.ts +++ b/tests/mcp-registry.test.ts @@ -88,6 +88,30 @@ describe("MCP Registry readiness", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.installability.missing"); }); + it("warns on older official schemas and does not treat variable templates as embedded secrets", async () => { + const target = await writeServerJson({ + ...validServer, + $schema: "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", + packages: [], + remotes: [{ + type: "streamable-http", + url: "https://example.com/mcp", + headers: [{ + name: "Authorization", + isSecret: true, + value: "Bearer {api_key}" + }] + }] + }); + + const report = await buildMcpRegistryReadiness(target); + const ids = report.findings.map((finding) => finding.id); + + expect(report.status).toBe("warn"); + expect(ids).toContain("registry.metadata.schema-outdated"); + expect(ids).not.toContain("registry.secret.embedded-value"); + }); + it("rejects unsafe or inconsistent publication metadata", async () => { const target = await writeServerJson({ ...validServer, From 61b68576f0300d8190a59a070b1138e2df78b5cd Mon Sep 17 00:00:00 2001 From: Furkan Date: Tue, 28 Jul 2026 11:27:23 +0300 Subject: [PATCH 4/6] feat: publish Registry readiness output contract --- src/core/mcp-registry.ts | 6 +++-- src/core/output-contract.ts | 46 ++++++++++++++++++++++++++++++++++ tests/contract-command.test.ts | 5 ++++ tests/mcp-registry.test.ts | 2 ++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/src/core/mcp-registry.ts b/src/core/mcp-registry.ts index bb4ac59..b1167a0 100644 --- a/src/core/mcp-registry.ts +++ b/src/core/mcp-registry.ts @@ -47,8 +47,9 @@ export interface McpRegistryInstallability { } export interface McpRegistryReadinessReport { - schemaVersion: "1"; + schemaVersion: "1.0.0"; kind: "mcp-registry-readiness"; + generatedAt: string; source: "file" | "registry"; target: string; serverName?: string; @@ -478,8 +479,9 @@ function finalizeReport( const hasTransport = validPackages.length > 0 || validRemotes.length > 0; const installability = buildInstallability(server, validPackages, validRemotes); return { - schemaVersion: "1", + schemaVersion: "1.0.0", kind: "mcp-registry-readiness", + generatedAt: new Date().toISOString(), source, target, ...(typeof server.name === "string" ? { serverName: server.name } : {}), diff --git a/src/core/output-contract.ts b/src/core/output-contract.ts index c5daeac..7cc90bc 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -208,6 +208,52 @@ const publicSchemaDefinitions: Array<{ } } }, + { + id: "doctor.registry.readiness.json", + command: "codex-plugin-doctor registry check --json", + outputKind: "mcp-registry-readiness", + required: [ + "schemaVersion", + "kind", + "generatedAt", + "source", + "target", + "status", + "scorecard", + "installability", + "findings" + ], + properties: { + source: { + type: "string", + enum: ["file", "registry"] + }, + status: { + type: "string", + enum: ["pass", "warn", "fail"] + }, + scorecard: { + type: "object", + required: [ + "metadata", + "ownership", + "packageIntegrity", + "transportReadiness", + "clientInstallability", + "overall" + ], + additionalProperties: false + }, + installability: { + type: "object", + required: ["codex", "packageTypes", "remoteTransports"], + additionalProperties: true + }, + findings: { + type: "array" + } + } + }, { id: "doctor.audit.json", command: "codex-plugin-doctor audit --installed --json", diff --git a/tests/contract-command.test.ts b/tests/contract-command.test.ts index 5071ccf..4ec22ba 100644 --- a/tests/contract-command.test.ts +++ b/tests/contract-command.test.ts @@ -76,6 +76,11 @@ describe("doctor contract command", () => { id: "doctor.audit.deps.json", command: "codex-plugin-doctor audit deps --json" }), + expect.objectContaining({ + id: "doctor.registry.readiness.json", + command: "codex-plugin-doctor registry check --json", + outputKind: "mcp-registry-readiness" + }), expect.objectContaining({ id: "doctor.watch.validation.json", command: "codex-plugin-doctor watch --json" diff --git a/tests/mcp-registry.test.ts b/tests/mcp-registry.test.ts index 50cb139..cebeae5 100644 --- a/tests/mcp-registry.test.ts +++ b/tests/mcp-registry.test.ts @@ -67,6 +67,8 @@ describe("MCP Registry readiness", () => { expect(renderMcpRegistryReadiness(report)).toContain("Registry readiness: PASS"); expect(JSON.parse(renderMcpRegistryReadinessJson(report))).toMatchObject({ kind: "mcp-registry-readiness", + schemaVersion: "1.0.0", + generatedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), status: "pass" }); }); From 74f7867b63396f2d9608f5999a2b1a4d3dcc2402 Mon Sep 17 00:00:00 2001 From: Furkan Date: Tue, 28 Jul 2026 11:29:38 +0300 Subject: [PATCH 5/6] docs: document MCP Registry readiness --- README.md | 12 +++ docs/README.md | 1 + docs/architecture/mcp-registry-readiness.md | 85 +++++++++++++++++++++ docs/guides/github-action.md | 16 ++++ docs/guides/release-gating.md | 11 +++ docs/security/security-architecture.md | 6 ++ tests/public-readiness.test.ts | 22 ++++++ 7 files changed, 153 insertions(+) create mode 100644 docs/architecture/mcp-registry-readiness.md diff --git a/README.md b/README.md index 11bb8c4..5127b7f 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,18 @@ Remote transport reliability adds one bounded SSE GET after initialization. HTTP `--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). +### MCP Registry Readiness + +Validate official MCP Registry publication metadata without publishing or installing anything: + +```bash +codex-plugin-doctor registry check path/to/server.json +codex-plugin-doctor registry check path/to/server.json --json --require-registry-readiness +codex-plugin-doctor registry inspect io.github.example/weather --allow-network +``` + +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). + Output formats: - human text output diff --git a/docs/README.md b/docs/README.md index 81704a9..50fc1e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,6 +11,7 @@ This directory contains public documentation for users, contributors, and securi - [MCP 2025-11 Conformance](architecture/mcp-2025-11-conformance.md) - [Remote MCP Readiness](architecture/remote-mcp-readiness.md) - [Remote MCP Transport Reliability](architecture/remote-mcp-transport-reliability.md) +- [MCP Registry Readiness](architecture/mcp-registry-readiness.md) - [Real-World Corpus Quality Metrics](architecture/real-world-corpus-quality-metrics.md) - [Corpus Metrics Regression Diff](architecture/corpus-metrics-regression-diff.md) diff --git a/docs/architecture/mcp-registry-readiness.md b/docs/architecture/mcp-registry-readiness.md new file mode 100644 index 0000000..fafe0bc --- /dev/null +++ b/docs/architecture/mcp-registry-readiness.md @@ -0,0 +1,85 @@ +# MCP Registry Readiness + +## Purpose + +The Registry Doctor validates whether MCP `server.json` metadata is structurally consistent, safe to consume, and useful for installation before publication. It also supports a bounded read-only lookup of an exact server name in the official MCP Registry. + +This is a metadata readiness check. A passing Registry record does not prove that the referenced code or remote server is trustworthy, available, or safe to execute. + +## Commands + +Validate a local file or a directory containing `server.json`: + +```bash +codex-plugin-doctor registry check ./server.json +codex-plugin-doctor registry check . --json +codex-plugin-doctor registry check . --require-registry-readiness +``` + +Inspect the latest published record for an exact server name: + +```bash +codex-plugin-doctor registry inspect io.github.example/weather --allow-network +``` + +`registry check` never uses the network. `registry inspect` fails before making a request unless `--allow-network` is explicit. + +## Scorecard + +The report keeps these dimensions separate: + +- metadata: required schema, name, description, and exact version shape +- ownership: local npm `mcpName` and GitHub namespace/repository consistency +- package integrity: exact versions, MCPB SHA-256, transport shape, and embedded secret checks +- transport readiness: declared package and remote transport validity +- client installability: whether a safe Codex configuration preview can be derived + +The official Registry permits metadata-only records without `packages` or `remotes`. Those records remain valid but receive a warning because no installation channel can be derived. The default command exits successfully for warning-only reports; `--require-registry-readiness` turns any non-pass result into a blocking exit. + +## Installation Preview + +The JSON report may contain a `codexPreview` for: + +- an exact-version npm package using `stdio` +- a fixed HTTPS remote URL without template variables + +The preview is informational. The command never edits Codex configuration, downloads packages, starts a process, or contacts an advertised remote endpoint. + +## Network Boundary + +Registry inspection: + +- sends one unauthenticated `GET` to the fixed official Registry host +- percent-encodes the exact server name +- uses the versioned `/v0.1` latest-version endpoint +- applies the shared timeout, response-size, DNS, peer, redirect, and SSRF controls +- does not follow package, icon, repository, website, or remote MCP URLs +- does not authenticate, publish, update, deprecate, or delete Registry data + +The Registry is currently a preview service. Historical active records may reference an older official schema; they are reported as warnings rather than treated as malformed. New local publication metadata should use the current official schema. + +## Security Findings + +The readiness report fails on: + +- URL-embedded credentials +- literal secret values in secret-like inputs +- mismatched `io.github` namespace and GitHub repository owner +- mismatched local npm `mcpName` or package version +- range or `latest` package versions +- MCPB packages without a valid lowercase SHA-256 digest +- invalid package or remote transport declarations + +Variable templates such as `Bearer {api_key}` are not treated as literal embedded secrets. + +## Non-Goals + +Registry Doctor does not: + +- prove namespace ownership independently of Registry publication +- download or inspect package artifacts +- validate an MCPB file against its declared hash +- execute generated installation commands +- probe advertised MCP endpoints +- claim that a listed server is secure or endorsed +- publish metadata to the Registry diff --git a/docs/guides/github-action.md b/docs/guides/github-action.md index 0a20578..4ddc380 100644 --- a/docs/guides/github-action.md +++ b/docs/guides/github-action.md @@ -22,6 +22,21 @@ Remote MCP checks are off by default. Set `runtime: "true"` and give explicit ne The Action transfers these boolean inputs through environment-backed shell variables and a Bash argument array. `require-remote-reliability` is a strict result gate, not network consent: it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. Keep `allow-session-lifecycle: "false"` (the default) unless the workflow explicitly authorizes one bounded, state-changing session `DELETE` after a valid session is issued. Remote probes redact diagnostics and retain no raw remote content; see [Remote MCP Readiness](../architecture/remote-mcp-readiness.md) and [Remote MCP Transport Reliability](../architecture/remote-mcp-transport-reliability.md) for SSRF, OAuth metadata-discovery, SSE, and lifecycle boundaries. +## MCP Registry Readiness + +Use local Registry metadata gating when the repository contains a `server.json` intended for publication: + +```yaml +- uses: Esquetta/CodexPluginDoctor@v1.54.0 + with: + version: "1.54.0" + path: . + registry-metadata: ./server.json + require-registry-readiness: "true" +``` + +The Action writes `mcp-registry-readiness.json` and exposes `registry-report-path`. This path is checked locally; the Action does not inspect the live Registry and does not grant network access. `require-registry-readiness` requires `registry-metadata` and blocks warnings as well as failures. + ## Recommended Workflow ```yaml @@ -110,6 +125,7 @@ The action also exposes these workflow outputs for follow-up steps: - `corpus-metrics-diff-path` - `output-contract-path` - `action-manifest-path` +- `registry-report-path` - `review-bundle-path` - `review-bundle-verification-path` diff --git a/docs/guides/release-gating.md b/docs/guides/release-gating.md index 83d703c..d5c460f 100644 --- a/docs/guides/release-gating.md +++ b/docs/guides/release-gating.md @@ -68,6 +68,17 @@ node dist/cli.js check ./path/to/plugin --runtime --allow-network --require-remo `--require-remote-reliability` is a strict result gate and grants no network consent; it fails unless every attempted remote reliability scorecard passes. Local-only runs are unaffected. Keep `--allow-session-lifecycle` off for ordinary release gates. That opt-in is state-changing and permits one bounded session `DELETE` only when initialization supplied a valid session identifier. +### MCP Registry Metadata + +Repositories that publish `server.json` can keep metadata validation advisory or make every warning blocking: + +```bash +codex-plugin-doctor registry check ./server.json --json --output mcp-registry-readiness.json +codex-plugin-doctor registry check ./server.json --require-registry-readiness +``` + +The local check performs no network access. Use the strict gate for publication branches after ownership and installability evidence are complete. + Docker mode currently supports Node.js stdio MCP servers. It uses a read-only package mount and container filesystem, no network, an unprivileged user, dropped capabilities, bounded resources, and a limited writable `/tmp`. It fails closed and does not fall back to native execution. ### Release Readiness diff --git a/docs/security/security-architecture.md b/docs/security/security-architecture.md index f1b78d9..bf2e719 100644 --- a/docs/security/security-architecture.md +++ b/docs/security/security-architecture.md @@ -52,6 +52,12 @@ Remote probing requires explicit network consent and separately requires `--allo DNS and IP classification cannot eliminate arbitrary network-specific NAT64 Pref64 mappings. Use runner or host egress controls to limit the destinations that a CI job or workstation can reach. +### MCP Registry Boundary + +Local `registry check` is offline. Live `registry inspect` requires `--allow-network` and sends one unauthenticated bounded GET to the fixed official Registry hostname and exact-name endpoint. It never follows URLs found in Registry metadata, downloads packages or icons, executes install previews, authenticates, or mutates Registry state. + +Registry publication proves namespace control under Registry policy; it does not prove that listed code is safe. Reports therefore describe metadata and installability readiness rather than endorsement or runtime trust. + ### Secret Hygiene - redact values that look like tokens in reports diff --git a/tests/public-readiness.test.ts b/tests/public-readiness.test.ts index d2e4c51..1de2e98 100644 --- a/tests/public-readiness.test.ts +++ b/tests/public-readiness.test.ts @@ -139,4 +139,26 @@ describe("public repository readiness", () => { expect(security).toContain("runner or host egress controls"); expect(readiness).not.toMatch(/internal (implementation )?plan/i); }); + + it("publishes the MCP Registry readiness and non-execution boundary", async () => { + const readme = await readText("README.md"); + const docsReadme = await readText("docs/README.md"); + const registry = await readText("docs/architecture/mcp-registry-readiness.md"); + const actionGuide = await readText("docs/guides/github-action.md"); + const releaseGating = await readText("docs/guides/release-gating.md"); + const security = await readText("docs/security/security-architecture.md"); + + expect(readme).toContain("MCP Registry Readiness"); + expect(docsReadme).toContain("MCP Registry Readiness"); + expect(registry).toContain("registry inspect"); + expect(registry).toContain("--allow-network"); + expect(registry).toContain("does not prove that the referenced code"); + expect(registry).toContain("never edits Codex configuration"); + expect(registry).toContain("does not follow package, icon, repository, website, or remote MCP URLs"); + expect(actionGuide).toContain("registry-metadata: ./server.json"); + expect(actionGuide).toContain('require-registry-readiness: "true"'); + expect(actionGuide).toContain("registry-report-path"); + expect(releaseGating).toContain("--require-registry-readiness"); + expect(security).toContain("Registry publication proves namespace control"); + }); }); From 654d33bf9a088a41750138955c050ca719e6c3fe Mon Sep 17 00:00:00 2001 From: Furkan Date: Tue, 28 Jul 2026 12:11:43 +0300 Subject: [PATCH 6/6] chore: release v1.54.0 --- CHANGELOG.md | 19 ++++++++++++++++ README.md | 4 ++-- docs/guides/github-action.md | 44 ++++++++++++++++++------------------ package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 46 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cf235..ab3f157 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to `codex-plugin-doctor` are documented here. This changelog groups the shipped work into product-level release blocks instead of repeating every low-level git diff in isolation. +## [1.54.0] - 2026-07-28 + +### Added + +- added offline MCP Registry readiness checks for metadata, ownership, package integrity, transport readiness, and client installability +- added opt-in exact-name lookup against the official MCP Registry with lifecycle validation and a non-executing Codex configuration preview +- added optional GitHub Action Registry readiness gating and the `mcp-registry-readiness.json` artifact + +### Changed + +- extended the public output contract with the additive `doctor.registry.readiness.json` schema surface +- accept historical official MCP Registry schema URLs with a compatibility warning while retaining strict metadata validation + +### Security + +- keep local Registry checks offline and require explicit `--allow-network` consent for live inspection +- constrain live requests to the fixed official Registry endpoint through the existing bounded HTTP client without following metadata URLs +- reject literal embedded secrets and unsafe package or transport declarations while preserving supported variable templates + ## [1.53.0] - 2026-07-26 ### Added diff --git a/README.md b/README.md index 5127b7f..8078f4e 100644 --- a/README.md +++ b/README.md @@ -454,9 +454,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.53.0 + - uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . runtime: "true" policy: codex-publish diff --git a/docs/guides/github-action.md b/docs/guides/github-action.md index 4ddc380..03a9891 100644 --- a/docs/guides/github-action.md +++ b/docs/guides/github-action.md @@ -53,9 +53,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.53.0 + - uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . runtime: "true" policy: codex-publish @@ -82,9 +82,9 @@ Every action run also writes `codex-plugin-doctor-action-manifest.json`. The man Use SARIF when repository security tooling should ingest validation findings. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . sarif: "true" ``` @@ -96,9 +96,9 @@ The action writes `codex-plugin-doctor.sarif` into `output-dir`. Uploading it to Use artifact and summary controls when the workflow needs custom retention or wants to disable generated report uploads. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . output-dir: doctor-ci-reports artifact-name: codex-plugin-doctor-reports @@ -134,11 +134,11 @@ The action also exposes these workflow outputs for follow-up steps: Use review bundle artifacts when a pull request or release workflow should preserve signed runtime approval, runtime policy, attestation, and release evidence handoff files. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 env: CODEX_PLUGIN_DOCTOR_SIGNING_KEY: ${{ secrets.CODEX_PLUGIN_DOCTOR_SIGNING_KEY }} with: - version: "1.53.0" + version: "1.54.0" path: . review-bundle: "true" review-bundle-verify: "true" @@ -169,9 +169,9 @@ The CLI can produce badge output for release notes, README automation, or a stat Use a private corpus metrics manifest to measure reviewed precision, recall, and false-positive share in CI. The action writes only the public-safe metrics report into its artifact directory; snapshots, manifest contents, local paths, and review notes are not copied. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json ``` @@ -179,9 +179,9 @@ Use a private corpus metrics manifest to measure reviewed precision, recall, and This writes `corpus-metrics.json`. To compare the result with a retained report and fail the job on regression: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . corpus-metrics-manifest: ../private-corpus/metrics.json corpus-metrics-baseline: .doctor-baselines/corpus-metrics.json @@ -210,9 +210,9 @@ The history file is newline-delimited JSON. Store it as an artifact, cache, or r The composite action can also append history directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . runtime: "true" history: validation-history.jsonl @@ -232,9 +232,9 @@ Use profiles when a consuming workflow needs a named validation policy instead o The composite action can pass profiles directly: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . profile: publish ``` @@ -244,9 +244,9 @@ The composite action can pass profiles directly: Use policy presets when a workflow should apply one of the opinionated release gates without adding a local `.codex-doctor.json`. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" path: . policy: codex-publish ``` @@ -258,9 +258,9 @@ Supported policy values are `codex-publish`, `mcp-strict`, and `security`. The C Use installed-cache mode only in environments where Codex plugins are already available on the runner. ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" installed: "true" filter: github runtime: "false" @@ -271,9 +271,9 @@ Use installed-cache mode only in environments where Codex plugins are already av Pin both the action ref and npm package version for reproducible CI: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.53.0 +- uses: Esquetta/CodexPluginDoctor@v1.54.0 with: - version: "1.53.0" + version: "1.54.0" ``` Use `version: "latest"` only when the consuming repository intentionally wants automatic CLI upgrades. diff --git a/package-lock.json b/package-lock.json index 7572338..ece8988 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-plugin-doctor", - "version": "1.53.0", + "version": "1.54.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plugin-doctor", - "version": "1.53.0", + "version": "1.54.0", "license": "MIT", "bin": { "codex-plugin-doctor": "dist/cli.js" diff --git a/package.json b/package.json index 6f67f53..611a682 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin-doctor", - "version": "1.53.0", + "version": "1.54.0", "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.", "type": "module", "main": "./dist/index.js",