From c1ebdd3904217632ad0f505f8f32d704cbf333d8 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 18:36:10 +0300 Subject: [PATCH 01/16] docs: design Registry publication preflight --- docs/README.md | 1 + .../mcp-registry-publication-preflight.md | 192 ++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 docs/architecture/mcp-registry-publication-preflight.md diff --git a/docs/README.md b/docs/README.md index 50fc1e5..fc4c0e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ This directory contains public documentation for users, contributors, and securi - [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) +- [MCP Registry Publication Preflight](architecture/mcp-registry-publication-preflight.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-publication-preflight.md b/docs/architecture/mcp-registry-publication-preflight.md new file mode 100644 index 0000000..fffb18f --- /dev/null +++ b/docs/architecture/mcp-registry-publication-preflight.md @@ -0,0 +1,192 @@ +# MCP Registry Publication Preflight + +## Purpose + +Publication Preflight determines whether an MCP server is ready for the official +Registry publication sequence before a maintainer invokes `mcp-publisher`. +It combines local metadata validation with optional, bounded checks against the +public npm and MCP registries. + +The preflight is advisory and read-only. It does not authenticate, publish, +update, deprecate, or delete Registry records. + +## Command + +Run the offline checks: + +```bash +codex-plugin-doctor registry preflight ./server.json +``` + +Add published-package and Registry version checks: + +```bash +codex-plugin-doctor registry preflight . --allow-network +``` + +Require a fully publish-ready result in automation: + +```bash +codex-plugin-doctor registry preflight . \ + --allow-network \ + --require-publish-ready \ + --json \ + --output registry-publication-preflight.json +``` + +`--allow-network` is explicit consent for the bounded public metadata requests +described below. It does not grant permission to execute package scripts, start +an MCP server, download a package tarball, or invoke the publisher. + +## Scope + +The first release supports npm-backed Registry package declarations. Local +Registry metadata containing another package type or only a remote transport +continues to receive the existing readiness checks, but the package publication +stage is reported as skipped. This prevents the command from claiming evidence +it did not collect. + +The command accepts either a `server.json` path or a directory containing that +file. An adjacent `package.json` is used only for local npm ownership and version +checks. + +## Data Flow + +### Offline phase + +1. Resolve `server.json` within the requested package root. +2. Run the existing Registry readiness validator. +3. Compare each npm declaration with the adjacent `package.json`. +4. Produce a deterministic, non-executing publisher plan. +5. Return a partial preflight report without making a network request. + +The local checks require: + +- `package.json#mcpName` to match `server.json#name` +- the local package version and exact Registry package version to match +- the npm package identifier to match `package.json#name` +- existing metadata, ownership, package integrity, transport, and secret checks + to pass + +### Network phase + +When `--allow-network` is present, the command performs bounded metadata-only +requests: + +1. Query the fixed public npm Registry for the declared package. +2. Select the exact version declared in `server.json`. +3. Compare its package name, version, `mcpName`, and `dist.integrity` metadata. +4. Query the fixed official MCP Registry for the exact server name and version. +5. Classify the version as available for first publication, available as a new + immutable version, or already published. + +The command does not accept alternate Registry base URLs in this release. +Restricting requests to fixed public hosts keeps the network boundary reviewable +and avoids turning a metadata check into an arbitrary URL fetcher. + +## Version Availability + +The Registry version result has these outcomes: + +- `available-first-publication`: the server name is not present +- `available-new-version`: the server exists, but the requested version does not +- `already-published`: the exact immutable version already exists +- `unknown`: the network phase was not approved or reliable evidence was not + available + +`already-published` is blocking because Registry versions are immutable. A +maintainer must increment the version rather than attempting to overwrite it. + +An unavailable npm package version is also blocking during the network phase. +The official Registry stores metadata rather than package artifacts, so an npm +package must be publicly available before publication. + +## Report Contract + +The JSON report uses: + +```text +kind: mcp-registry-publication-preflight +schemaVersion: 1.0.0 +status: pass | warn | fail +localReadiness: pass | warn | fail +packagePublication: pass | fail | skipped | unknown +registryVersionAvailability: + available-first-publication | + available-new-version | + already-published | + unknown +publisherPlan: ordered, non-executing steps +findings: stable identifiers, severity, and redacted messages +``` + +The publisher plan may name required commands and their ordering, but it must not +contain credentials, tokens, absolute local paths, shell interpolation, or a +claim that a command was executed. + +Without `--allow-network`, a locally healthy report is `warn` because package +publication and Registry version availability remain unverified. With network +consent, all required npm evidence and version availability must pass for the +overall status to be `pass`. + +`--require-publish-ready` returns a blocking exit when the overall status is not +`pass`. Without that flag, only a `fail` result is blocking. + +## Network and Security Boundary + +All requests use the shared bounded HTTP client and: + +- target only `https://registry.npmjs.org` and + `https://registry.modelcontextprotocol.io` +- percent-encode package and server identifiers +- use unauthenticated `GET` requests +- enforce response-size and timeout limits +- enforce DNS and connected-peer validation +- reject redirects and embedded credentials +- never follow package, tarball, repository, website, icon, or remote MCP URLs + +Reports omit response bodies, local absolute paths, npm configuration, headers, +environment values, and authentication material. Error messages identify the +failed stage without retaining untrusted server text. + +## Error Handling + +Invalid local input returns a usage or validation error before network access. +Network denial, timeout, malformed JSON, oversized responses, and inconsistent +metadata produce stable findings rather than raw stack traces. + +A Registry not-found response is evidence only when the bounded request +completed successfully and the response shape is valid. Other request failures +produce `unknown`; they are not treated as proof that a name or version is +available. + +## Verification Strategy + +Tests must prove: + +- offline preflight performs zero HTTP requests +- local npm name, `mcpName`, and version mismatches fail +- missing network consent produces a partial warning, not false readiness +- an exact public npm version with matching metadata passes +- a missing or inconsistent npm version fails +- a missing Registry server is classified as first publication +- an existing server with a different version is classified as a new version +- an existing exact version is blocked as immutable +- malformed, redirected, oversized, or untrusted responses fail safely +- JSON and text reports contain no credentials, response bodies, or absolute + local paths +- `--require-publish-ready` applies the documented exit-code contract + +## Non-Goals + +Publication Preflight does not: + +- run `mcp-publisher login` or `mcp-publisher publish` +- handle Registry authentication or GitHub OIDC +- publish the npm package +- download or extract npm tarballs +- execute lifecycle scripts or MCP servers +- support private or user-configured registry hosts +- prove namespace ownership independently of the official publisher +- replace post-publication inspection +- add GitHub Action inputs in the first release From d087c66395dd556b4041ea05f14c994e4798f45d Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 18:46:07 +0300 Subject: [PATCH 02/16] feat: add offline Registry publication preflight --- src/core/mcp-registry-preflight.ts | 172 +++++++++++++++++++++++++++ tests/mcp-registry-preflight.test.ts | 146 +++++++++++++++++++++++ 2 files changed, 318 insertions(+) create mode 100644 src/core/mcp-registry-preflight.ts create mode 100644 tests/mcp-registry-preflight.test.ts diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts new file mode 100644 index 0000000..3eacfe6 --- /dev/null +++ b/src/core/mcp-registry-preflight.ts @@ -0,0 +1,172 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + buildMcpRegistryReadiness, + type McpRegistryFinding +} from "./mcp-registry.js"; +import type { BoundedHttpRequestOptions, BoundedHttpResponse } from "./bounded-http-client.js"; + +type PreflightStatus = "pass" | "warn" | "fail"; +type RegistryRequest = ( + url: string, + options?: BoundedHttpRequestOptions +) => Promise; + +export interface McpRegistryPublicationPreflightFinding { + id: string; + severity: "warn" | "fail"; + message: string; +} + +export interface McpRegistryPublicationPreflightReport { + schemaVersion: "1.0.0"; + kind: "mcp-registry-publication-preflight"; + generatedAt: string; + target: "server.json"; + serverName?: string; + serverVersion?: string; + status: PreflightStatus; + localReadiness: PreflightStatus; + packagePublication: "pass" | "fail" | "skipped" | "unknown"; + registryVersionAvailability: "available-first-publication" | "available-new-version" | "already-published" | "unknown"; + publisherPlan: { + executable: false; + steps: Array<{ + command: "mcp-publisher login github" | "mcp-publisher publish"; + purpose: string; + }>; + }; + findings: McpRegistryPublicationPreflightFinding[]; +} + +export interface BuildMcpRegistryPublicationPreflightOptions { + allowNetwork?: boolean; + request?: RegistryRequest; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function publicFinding(finding: McpRegistryFinding): McpRegistryPublicationPreflightFinding { + return { + id: finding.id, + severity: finding.severity, + message: finding.message + }; +} + +function statusFromFindings(findings: McpRegistryPublicationPreflightFinding[]): PreflightStatus { + if (findings.some((finding) => finding.severity === "fail")) { + return "fail"; + } + return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass"; +} + +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 readJson(pathname: string): Promise | null> { + try { + const parsed: unknown = JSON.parse(await readFile(pathname, "utf8")); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function npmDeclarations(server: Record | null): Array> { + return Array.isArray(server?.packages) + ? server.packages.filter((entry): entry is Record => + isRecord(entry) && entry.registryType === "npm") + : []; +} + +function publisherPlan(): McpRegistryPublicationPreflightReport["publisherPlan"] { + return { + executable: false, + steps: [ + { + command: "mcp-publisher login github", + purpose: "Authenticate with GitHub for Registry publication." + }, + { + command: "mcp-publisher publish", + purpose: "Publish the validated Registry metadata." + } + ] + }; +} + +export async function buildMcpRegistryPublicationPreflight( + targetPath: string, + options: BuildMcpRegistryPublicationPreflightOptions = {} +): Promise { + void options; + + const readiness = await buildMcpRegistryReadiness(targetPath); + const findings = readiness.findings.map(publicFinding); + const serverJsonPath = await resolveServerJsonPath(targetPath); + const server = await readJson(serverJsonPath); + const declarations = npmDeclarations(server); + const packageJson = await readJson(path.join(path.dirname(serverJsonPath), "package.json")); + + if (declarations.length > 1) { + findings.push({ + id: "registry.preflight.package.multiple-npm-declarations", + severity: "fail", + message: "Publication evidence supports exactly one npm package declaration." + }); + } else if (declarations.length === 1 + && typeof declarations[0].identifier === "string" + && packageJson + && packageJson.name !== declarations[0].identifier) { + findings.push({ + id: "registry.preflight.package.local-name-mismatch", + severity: "fail", + message: "package.json name must match the declared npm package identifier." + }); + } + + const localReadiness = statusFromFindings(findings); + const packagePublication = declarations.length === 0 + ? "skipped" + : localReadiness === "fail" ? "fail" : "unknown"; + const registryVersionAvailability = "unknown" as const; + + if (localReadiness !== "fail") { + findings.push({ + id: "registry.preflight.network-unverified", + severity: "warn", + message: "Package publication and Registry version availability require explicit network verification." + }); + } + + return { + schemaVersion: "1.0.0", + kind: "mcp-registry-publication-preflight", + generatedAt: new Date().toISOString(), + target: "server.json", + ...(readiness.serverName ? { serverName: readiness.serverName } : {}), + ...(readiness.serverVersion ? { serverVersion: readiness.serverVersion } : {}), + status: statusFromFindings(findings), + localReadiness, + packagePublication, + registryVersionAvailability, + publisherPlan: publisherPlan(), + findings + }; +} + +export function renderMcpRegistryPublicationPreflightJson( + report: McpRegistryPublicationPreflightReport +): string { + return JSON.stringify(report, null, 2); +} diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts new file mode 100644 index 0000000..9239bbc --- /dev/null +++ b/tests/mcp-registry-preflight.test.ts @@ -0,0 +1,146 @@ +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 { + buildMcpRegistryPublicationPreflight, + renderMcpRegistryPublicationPreflightJson +} from "../src/core/mcp-registry-preflight.js"; + +async function writeServerJson(server: unknown, packageJson?: unknown): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), "codex-plugin-doctor-registry-preflight-")); + 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" } + }] +}; + +const matchingPackageJson = { + name: "@example/weather-mcp", + version: "1.2.3", + mcpName: "io.github.example/weather" +}; + +describe("MCP Registry publication preflight", () => { + it("returns a public-safe partial warning and non-executing publisher plan offline", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report).toMatchObject({ + schemaVersion: "1.0.0", + kind: "mcp-registry-publication-preflight", + generatedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + target: "server.json", + serverName: validServer.name, + serverVersion: validServer.version, + status: "warn", + localReadiness: "pass", + packagePublication: "unknown", + registryVersionAvailability: "unknown", + publisherPlan: { + executable: false, + steps: [ + { + command: "mcp-publisher login github", + purpose: "Authenticate with GitHub for Registry publication." + }, + { + command: "mcp-publisher publish", + purpose: "Publish the validated Registry metadata." + } + ] + } + }); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.network-unverified"); + + const rendered = renderMcpRegistryPublicationPreflightJson(report); + expect(rendered).not.toContain(target); + expect(rendered).not.toContain("\\\\"); + }); + + it("does not issue a request during an offline preflight", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = vi.fn(); + + await buildMcpRegistryPublicationPreflight(target, { request }); + + expect(request).not.toHaveBeenCalled(); + }); + + it("fails when the adjacent package name differs from the sole npm declaration", async () => { + const target = await writeServerJson(validServer, { + ...matchingPackageJson, + name: "@example/other-mcp" + }); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("fail"); + expect(report.localReadiness).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.package.local-name-mismatch"); + }); + + it("fails when publication evidence declares multiple npm packages", async () => { + const target = await writeServerJson({ + ...validServer, + packages: [ + ...validServer.packages, + { + registryType: "npm", + identifier: "@example/weather-cli", + version: "1.2.3", + transport: { type: "stdio" } + } + ] + }, matchingPackageJson); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("fail"); + expect(report.localReadiness).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.package.multiple-npm-declarations"); + }); + + it("skips package publication evidence for remote-only input", async () => { + const target = await writeServerJson({ + $schema: validServer.$schema, + name: validServer.name, + description: validServer.description, + version: validServer.version, + repository: validServer.repository, + remotes: [{ + type: "streamable-http", + url: "https://example.com/mcp" + }] + }); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("warn"); + expect(report.localReadiness).toBe("pass"); + expect(report.packagePublication).toBe("skipped"); + expect(report.registryVersionAvailability).toBe("unknown"); + }); +}); From a77d3a8b8571e1766c065552f6a7a09573acd86a Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 18:52:42 +0300 Subject: [PATCH 03/16] fix: harden offline Registry preflight --- src/core/mcp-registry-preflight.ts | 15 ++++- tests/mcp-registry-preflight.test.ts | 85 +++++++++++++++++++++++++++- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index 3eacfe6..58397bc 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -12,6 +12,8 @@ type RegistryRequest = ( url: string, options?: BoundedHttpRequestOptions ) => Promise; +const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; +const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i; export interface McpRegistryPublicationPreflightFinding { id: string; @@ -64,6 +66,15 @@ function statusFromFindings(findings: McpRegistryPublicationPreflightFinding[]): return findings.some((finding) => finding.severity === "warn") ? "warn" : "pass"; } +function isSafeServerName(value: string | undefined): value is string { + return value !== undefined && value.length >= 3 && value.length <= 200 && SERVER_NAME_PATTERN.test(value); +} + +function isSafeServerVersion(value: string | undefined): value is string { + return value !== undefined && value.length >= 1 && value.length <= 255 + && !/[\\/\u0000-\u001F\u007F]/.test(value) && !VERSION_RANGE_PATTERN.test(value); +} + async function resolveServerJsonPath(targetPath: string): Promise { const resolved = path.resolve(targetPath); try { @@ -154,8 +165,8 @@ export async function buildMcpRegistryPublicationPreflight( kind: "mcp-registry-publication-preflight", generatedAt: new Date().toISOString(), target: "server.json", - ...(readiness.serverName ? { serverName: readiness.serverName } : {}), - ...(readiness.serverVersion ? { serverVersion: readiness.serverVersion } : {}), + ...(isSafeServerName(readiness.serverName) ? { serverName: readiness.serverName } : {}), + ...(isSafeServerVersion(readiness.serverVersion) ? { serverVersion: readiness.serverVersion } : {}), status: statusFromFindings(findings), localReadiness, packagePublication, diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index 9239bbc..3294faa 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -78,13 +78,18 @@ describe("MCP Registry publication preflight", () => { expect(rendered).not.toContain("\\\\"); }); - it("does not issue a request during an offline preflight", async () => { + it("does not issue a request when network consent is present in Task 1", async () => { const target = await writeServerJson(validServer, matchingPackageJson); const request = vi.fn(); - await buildMcpRegistryPublicationPreflight(target, { request }); + const report = await buildMcpRegistryPublicationPreflight(target, { + allowNetwork: true, + request + }); expect(request).not.toHaveBeenCalled(); + expect(report.packagePublication).toBe("unknown"); + expect(report.registryVersionAvailability).toBe("unknown"); }); it("fails when the adjacent package name differs from the sole npm declaration", async () => { @@ -123,6 +128,82 @@ describe("MCP Registry publication preflight", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.package.multiple-npm-declarations"); }); + it("inherits a package mcpName mismatch as a blocking local failure", async () => { + const target = await writeServerJson(validServer, { + ...matchingPackageJson, + mcpName: "io.github.example/other" + }); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("fail"); + expect(report.localReadiness).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.ownership.npm-mcp-name"); + }); + + it("inherits a local package version mismatch as a blocking failure", async () => { + const target = await writeServerJson(validServer, { + ...matchingPackageJson, + version: "1.2.4" + }); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("fail"); + expect(report.localReadiness).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.package.local-version-mismatch"); + }); + + it("keeps inherited Registry readiness failures blocking", async () => { + const target = await writeServerJson({ + ...validServer, + description: "" + }, matchingPackageJson); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.status).toBe("fail"); + expect(report.localReadiness).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.metadata.description"); + }); + + it("redacts invalid path-shaped metadata from the public report", async () => { + const windowsName = "C:\\registry-preflight-name-sentinel"; + const posixVersion = "/registry-preflight-version-sentinel"; + const target = await writeServerJson({ + ...validServer, + name: windowsName, + version: posixVersion + }, matchingPackageJson); + + const report = await buildMcpRegistryPublicationPreflight(target); + const rendered = renderMcpRegistryPublicationPreflightJson(report); + + expect(report.serverName).toBeUndefined(); + expect(report.serverVersion).toBeUndefined(); + expect(rendered).not.toContain(windowsName); + expect(rendered).not.toContain(posixVersion); + }); + + it("skips package publication evidence for non-npm packages", async () => { + const target = await writeServerJson({ + ...validServer, + packages: [{ + registryType: "mcpb", + identifier: "https://github.com/example/weather/releases/download/v1/weather.mcpb", + version: validServer.version, + fileSha256: "a".repeat(64), + transport: { type: "stdio" } + }] + }); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(report.packagePublication).toBe("skipped"); + }); + it("skips package publication evidence for remote-only input", async () => { const target = await writeServerJson({ $schema: validServer.$schema, From 548dae297fdb43b5439860cc691b72f7b6924b4d Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 18:57:53 +0300 Subject: [PATCH 04/16] docs: clarify Registry preflight evidence --- .../mcp-registry-publication-preflight.md | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/docs/architecture/mcp-registry-publication-preflight.md b/docs/architecture/mcp-registry-publication-preflight.md index fffb18f..c83e6d7 100644 --- a/docs/architecture/mcp-registry-publication-preflight.md +++ b/docs/architecture/mcp-registry-publication-preflight.md @@ -40,11 +40,12 @@ an MCP server, download a package tarball, or invoke the publisher. ## Scope -The first release supports npm-backed Registry package declarations. Local -Registry metadata containing another package type or only a remote transport -continues to receive the existing readiness checks, but the package publication -stage is reported as skipped. This prevents the command from claiming evidence -it did not collect. +The first release supports exactly one npm-backed Registry package declaration. +Multiple npm declarations fail preflight because one adjacent `package.json` +cannot prove ownership for multiple packages. Local Registry metadata containing +another package type or only a remote transport continues to receive the +existing readiness checks, but the package publication stage is reported as +skipped. This prevents the command from claiming evidence it did not collect. The command accepts either a `server.json` path or a directory containing that file. An adjacent `package.json` is used only for local npm ownership and version @@ -75,10 +76,15 @@ requests: 1. Query the fixed public npm Registry for the declared package. 2. Select the exact version declared in `server.json`. -3. Compare its package name, version, `mcpName`, and `dist.integrity` metadata. +3. Compare its package name, version, and `mcpName`, and require a syntactically + valid `dist.integrity` value as published metadata evidence. 4. Query the fixed official MCP Registry for the exact server name and version. -5. Classify the version as available for first publication, available as a new - immutable version, or already published. +5. If the exact version returns a valid not-found response, query the fixed + `latest` endpoint for the same exact server name. +6. Classify the version as available for first publication only when both the + exact version and latest record are valid not-found responses, as a new + immutable version when latest returns the same server name, or as already + published when the exact version returns matching metadata. The command does not accept alternate Registry base URLs in this release. Restricting requests to fixed public hosts keeps the network boundary reviewable @@ -99,7 +105,9 @@ maintainer must increment the version rather than attempting to overwrite it. An unavailable npm package version is also blocking during the network phase. The official Registry stores metadata rather than package artifacts, so an npm -package must be publicly available before publication. +package must be publicly available before publication. The presence of +`dist.integrity` proves only that npm returned package integrity metadata; the +preflight does not download the tarball or independently verify its contents. ## Report Contract @@ -156,7 +164,9 @@ Network denial, timeout, malformed JSON, oversized responses, and inconsistent metadata produce stable findings rather than raw stack traces. A Registry not-found response is evidence only when the bounded request -completed successfully and the response shape is valid. Other request failures +completed successfully and the response shape is valid. An exact-version +not-found response is followed by a latest-version lookup so the command can +distinguish a new server from a new immutable version. Other request failures produce `unknown`; they are not treated as proof that a name or version is available. @@ -166,6 +176,7 @@ Tests must prove: - offline preflight performs zero HTTP requests - local npm name, `mcpName`, and version mismatches fail +- multiple npm declarations fail before network access - missing network consent produces a partial warning, not false readiness - an exact public npm version with matching metadata passes - a missing or inconsistent npm version fails @@ -185,6 +196,7 @@ Publication Preflight does not: - handle Registry authentication or GitHub OIDC - publish the npm package - download or extract npm tarballs +- claim that npm integrity metadata proves the downloaded artifact contents - execute lifecycle scripts or MCP servers - support private or user-configured registry hosts - prove namespace ownership independently of the official publisher From ec651b0e927f7b51c9de704f6c44cf8d192024c8 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:07:56 +0300 Subject: [PATCH 05/16] feat: verify Registry publication metadata --- src/core/mcp-registry-preflight.ts | 195 ++++++++++++++++++- tests/mcp-registry-preflight.test.ts | 270 ++++++++++++++++++++++++++- 2 files changed, 455 insertions(+), 10 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index 58397bc..bb161c2 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -5,7 +5,11 @@ import { buildMcpRegistryReadiness, type McpRegistryFinding } from "./mcp-registry.js"; -import type { BoundedHttpRequestOptions, BoundedHttpResponse } from "./bounded-http-client.js"; +import { + requestBoundedHttp, + type BoundedHttpRequestOptions, + type BoundedHttpResponse +} from "./bounded-http-client.js"; type PreflightStatus = "pass" | "warn" | "fail"; type RegistryRequest = ( @@ -14,6 +18,17 @@ type RegistryRequest = ( ) => Promise; const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i; +const NPM_IDENTIFIER_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,213}\/)?[a-z0-9][a-z0-9._-]{0,213}$/; +const INTEGRITY_PATTERN = /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2}$/; +const NPM_REGISTRY = "https://registry.npmjs.org"; +const MCP_REGISTRY = "https://registry.modelcontextprotocol.io"; +const REQUEST_OPTIONS: BoundedHttpRequestOptions = { + method: "GET", + headers: { + accept: "application/json", + "user-agent": "codex-plugin-doctor" + } +}; export interface McpRegistryPublicationPreflightFinding { id: string; @@ -75,6 +90,77 @@ function isSafeServerVersion(value: string | undefined): value is string { && !/[\\/\u0000-\u001F\u007F]/.test(value) && !VERSION_RANGE_PATTERN.test(value); } +function isSafeNpmIdentifier(value: unknown): value is string { + return typeof value === "string" && NPM_IDENTIFIER_PATTERN.test(value); +} + +function isIntegrity(value: unknown): value is string { + return typeof value === "string" && INTEGRITY_PATTERN.test(value); +} + +function responseJson(response: BoundedHttpResponse): unknown | null { + try { + return JSON.parse(response.body.toString("utf8")); + } catch { + return null; + } +} + +function validNotFound(response: BoundedHttpResponse): boolean { + const payload = responseJson(response); + return response.statusCode === 404 + && isRecord(payload) + && typeof payload.error === "string" + && payload.error.trim().length > 0; +} + +function matchingRegistryServer( + response: BoundedHttpResponse, + serverName: string, + serverVersion?: string +): boolean { + const payload = responseJson(response); + if (response.statusCode !== 200 || !isRecord(payload) || !isRecord(payload.server)) { + return false; + } + + const server = payload.server; + return typeof server.$schema === "string" + && typeof server.description === "string" + && (Array.isArray(server.packages) || Array.isArray(server.remotes)) + && server.name === serverName + && isSafeServerVersion(typeof server.version === "string" ? server.version : undefined) + && (serverVersion === undefined || server.version === serverVersion); +} + +function matchingNpmPackument( + response: BoundedHttpResponse, + packageName: string, + packageVersion: string, + serverName: string +): boolean { + const payload = responseJson(response); + if (response.statusCode !== 200 || !isRecord(payload) || payload.name !== packageName || !isRecord(payload.versions)) { + return false; + } + + const version = payload.versions[packageVersion]; + return isRecord(version) + && version.name === packageName + && version.version === packageVersion + && version.mcpName === serverName + && isRecord(version.dist) + && isIntegrity(version.dist.integrity); +} + +function addFinding( + findings: McpRegistryPublicationPreflightFinding[], + id: string, + message: string +): void { + findings.push({ id, severity: "fail", message }); +} + async function resolveServerJsonPath(targetPath: string): Promise { const resolved = path.resolve(targetPath); try { @@ -146,13 +232,36 @@ export async function buildMcpRegistryPublicationPreflight( }); } + if (declarations.length === 1 && !isSafeNpmIdentifier(declarations[0].identifier)) { + findings.push({ + id: "registry.preflight.package.invalid-npm-identifier", + severity: "fail", + message: "The declared npm package identifier is not valid for public registry verification." + }); + } + const localReadiness = statusFromFindings(findings); - const packagePublication = declarations.length === 0 + let packagePublication: McpRegistryPublicationPreflightReport["packagePublication"] = declarations.length === 0 ? "skipped" : localReadiness === "fail" ? "fail" : "unknown"; - const registryVersionAvailability = "unknown" as const; + let registryVersionAvailability: McpRegistryPublicationPreflightReport["registryVersionAvailability"] = "unknown"; + const declaration = declarations.length === 1 ? declarations[0] : undefined; + const serverName = isSafeServerName(readiness.serverName) ? readiness.serverName : undefined; + const serverVersion = isSafeServerVersion(readiness.serverVersion) ? readiness.serverVersion : undefined; + const packageName = isSafeNpmIdentifier(declaration?.identifier) ? declaration.identifier : undefined; + const declarationVersion = typeof declaration?.version === "string" ? declaration.version : undefined; + const packageVersion = isSafeServerVersion(declarationVersion) + ? declarationVersion + : undefined; + const canVerifyNetwork = options.allowNetwork === true + && localReadiness === "pass" + && declaration !== undefined + && serverName !== undefined + && serverVersion !== undefined + && packageName !== undefined + && packageVersion !== undefined; - if (localReadiness !== "fail") { + if (!canVerifyNetwork && localReadiness !== "fail") { findings.push({ id: "registry.preflight.network-unverified", severity: "warn", @@ -160,6 +269,84 @@ export async function buildMcpRegistryPublicationPreflight( }); } + if (canVerifyNetwork && packageName && packageVersion && serverName && serverVersion) { + const request = options.request ?? requestBoundedHttp; + let npmResponse: BoundedHttpResponse; + try { + npmResponse = await request(`${NPM_REGISTRY}/${encodeURIComponent(packageName)}`, REQUEST_OPTIONS); + } catch { + addFinding(findings, "registry.preflight.npm.request", "Public npm metadata could not be verified."); + packagePublication = "unknown"; + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); + } + + if (npmResponse.statusCode !== 200 && npmResponse.statusCode !== 404) { + addFinding(findings, "registry.preflight.npm.response", "Public npm metadata could not prove package publication."); + packagePublication = "unknown"; + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); + } + + if (!matchingNpmPackument(npmResponse, packageName, packageVersion, serverName)) { + addFinding(findings, "registry.preflight.npm.metadata", "Published npm metadata does not match the declared package version."); + packagePublication = "fail"; + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); + } + packagePublication = "pass"; + + let exactResponse: BoundedHttpResponse; + try { + exactResponse = await request( + `${MCP_REGISTRY}/v0.1/servers/${encodeURIComponent(serverName)}/versions/${encodeURIComponent(serverVersion)}`, + REQUEST_OPTIONS + ); + } catch { + addFinding(findings, "registry.preflight.registry.exact-request", "Exact Registry version availability could not be verified."); + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); + } + + if (matchingRegistryServer(exactResponse, serverName, serverVersion)) { + registryVersionAvailability = "already-published"; + addFinding(findings, "registry.preflight.registry.already-published", "The exact Registry version is already published and cannot be overwritten."); + } else if (validNotFound(exactResponse)) { + let latestResponse: BoundedHttpResponse; + try { + latestResponse = await request( + `${MCP_REGISTRY}/v0.1/servers/${encodeURIComponent(serverName)}/versions/latest`, + REQUEST_OPTIONS + ); + } catch { + addFinding(findings, "registry.preflight.registry.latest-request", "Latest Registry version availability could not be verified."); + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); + } + + if (validNotFound(latestResponse)) { + registryVersionAvailability = "available-first-publication"; + } else if (matchingRegistryServer(latestResponse, serverName) && responseJson(latestResponse) !== null) { + const payload = responseJson(latestResponse) as Record; + const latestVersion = (payload.server as Record).version; + if (latestVersion !== serverVersion) { + registryVersionAvailability = "available-new-version"; + } else { + addFinding(findings, "registry.preflight.registry.latest-response", "Latest Registry metadata did not prove a different published version."); + } + } else { + addFinding(findings, "registry.preflight.registry.latest-response", "Latest Registry metadata could not prove version availability."); + } + } else { + addFinding(findings, "registry.preflight.registry.exact-response", "Exact Registry metadata could not prove version availability."); + } + } + + return buildReport(readiness, localReadiness, packagePublication, registryVersionAvailability, findings); +} + +function buildReport( + readiness: Awaited>, + localReadiness: PreflightStatus, + packagePublication: McpRegistryPublicationPreflightReport["packagePublication"], + registryVersionAvailability: McpRegistryPublicationPreflightReport["registryVersionAvailability"], + findings: McpRegistryPublicationPreflightFinding[] +): McpRegistryPublicationPreflightReport { return { schemaVersion: "1.0.0", kind: "mcp-registry-publication-preflight", diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index 3294faa..c2b8e1b 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -40,6 +40,65 @@ const matchingPackageJson = { mcpName: "io.github.example/weather" }; +function response(statusCode: number, payload: unknown, headers: Record = {}) { + return { + statusCode, + headers, + body: Buffer.from(typeof payload === "string" ? payload : JSON.stringify(payload)) + }; +} + +function npmPackument(overrides: Record = {}) { + return { + name: matchingPackageJson.name, + versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: validServer.version, + mcpName: validServer.name, + dist: { integrity: "sha512-aGVsbG8=" } + } + }, + ...overrides + }; +} + +function registryServer(name = validServer.name, version = validServer.version) { + return { + ...validServer, + name, + version + }; +} + +function registryResponse(name = validServer.name, version = validServer.version) { + return response(200, { server: registryServer(name, version) }); +} + +function requestSequence(...responses: Array | Error>) { + return vi.fn(async () => { + const next = responses.shift(); + if (next instanceof Error) { + throw next; + } + if (!next) { + throw new Error("Unexpected registry request."); + } + return next; + }); +} + +const npmUrl = "https://registry.npmjs.org/%40example%2Fweather-mcp"; +const registryExactUrl = "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.example%2Fweather/versions/1.2.3"; +const registryLatestUrl = "https://registry.modelcontextprotocol.io/v0.1/servers/io.github.example%2Fweather/versions/latest"; +const requestOptions = { + method: "GET", + headers: { + accept: "application/json", + "user-agent": "codex-plugin-doctor" + } +}; + describe("MCP Registry publication preflight", () => { it("returns a public-safe partial warning and non-executing publisher plan offline", async () => { const target = await writeServerJson(validServer, matchingPackageJson); @@ -78,18 +137,188 @@ describe("MCP Registry publication preflight", () => { expect(rendered).not.toContain("\\\\"); }); - it("does not issue a request when network consent is present in Task 1", async () => { + it("collects matching public npm metadata and first-publication Registry evidence with fixed bounded requests", async () => { const target = await writeServerJson(validServer, matchingPackageJson); - const request = vi.fn(); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "server not found" }), + response(404, { error: "server not found" }) + ); const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); - expect(request).not.toHaveBeenCalled(); + expect(report.status).toBe("pass"); + expect(report.packagePublication).toBe("pass"); + expect(report.registryVersionAvailability).toBe("available-first-publication"); + expect(request).toHaveBeenNthCalledWith(1, npmUrl, requestOptions); + expect(request).toHaveBeenNthCalledWith(2, registryExactUrl, requestOptions); + expect(request).toHaveBeenNthCalledWith(3, registryLatestUrl, requestOptions); + expect(request).toHaveBeenCalledTimes(3); + }); + + it.each([ + ["missing requested version", npmPackument({ versions: {} })], + ["malformed packument", "not json"], + ["mismatched top-level name", npmPackument({ name: "@example/other-mcp" })], + ["mismatched published package name", npmPackument({ versions: { + [validServer.version]: { + name: "@example/other-mcp", + version: validServer.version, + mcpName: validServer.name, + dist: { integrity: "sha512-aGVsbG8=" } + } + } })], + ["mismatched published package version", npmPackument({ versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: "1.2.4", + mcpName: validServer.name, + dist: { integrity: "sha512-aGVsbG8=" } + } + } })], + ["mismatched published mcpName", npmPackument({ versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: validServer.version, + mcpName: "io.github.example/other", + dist: { integrity: "sha512-aGVsbG8=" } + } + } })], + ["malformed published integrity", npmPackument({ versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: validServer.version, + mcpName: validServer.name, + dist: { integrity: "not-an-integrity-value" } + } + } })] + ])("blocks package publication for %s", async (_caseName, payload) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(200, payload)); + + const report = await buildMcpRegistryPublicationPreflight(target, { + allowNetwork: true, + request + }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(request).toHaveBeenCalledTimes(1); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.metadata"); + }); + + it.each([418, 503])("keeps package publication unknown for unexpected npm HTTP %s", async (statusCode) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(statusCode, { error: "unexpected response" })); + + const report = await buildMcpRegistryPublicationPreflight(target, { + allowNetwork: true, + request + }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("unknown"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(request).toHaveBeenCalledTimes(1); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.response"); + }); + + it("encodes a scoped npm package identifier as one path segment", async () => { + const target = await writeServerJson({ + ...validServer, + packages: [{ + ...validServer.packages[0], + identifier: "@scope/weather-mcp" + }] + }, { + ...matchingPackageJson, + name: "@scope/weather-mcp" + }); + const request = requestSequence(response(404, { error: "package not found" })); + + await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(request).toHaveBeenCalledWith( + "https://registry.npmjs.org/%40scope%2Fweather-mcp", + requestOptions + ); + }); + + it("classifies an existing Registry server at another version as available for a new version", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + registryResponse(validServer.name, "1.2.2") + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("pass"); + expect(report.packagePublication).toBe("pass"); + expect(report.registryVersionAvailability).toBe("available-new-version"); + }); + + it("blocks an already published exact Registry version", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(200, npmPackument()), registryResponse()); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("pass"); + expect(report.registryVersionAvailability).toBe("already-published"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.already-published"); + expect(request).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["a malformed exact not-found response", response(404, { error: "" })], + ["an arbitrary exact client error", response(418, { error: "teapot" })], + ["an arbitrary exact server error", response(503, { error: "unavailable" })], + ["malformed exact JSON", response(200, "not json")], + ["an exact identity mismatch", registryResponse("io.github.attacker/weather")] + ])("keeps Registry version availability unknown for %s", async (_caseName, exactResponse) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(200, npmPackument()), exactResponse); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(request).toHaveBeenCalledTimes(2); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.exact-response"); + }); + + it("keeps Registry version availability unknown when a bounded request throws", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(new Error("registry-preflight-request-sentinel")); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); expect(report.packagePublication).toBe("unknown"); expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.request"); + }); + + it("keeps Registry version availability unknown for a mismatched latest record", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + registryResponse(validServer.name, validServer.version) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); }); it("fails when the adjacent package name differs from the sole npm declaration", async () => { @@ -98,12 +327,14 @@ describe("MCP Registry publication preflight", () => { name: "@example/other-mcp" }); - const report = await buildMcpRegistryPublicationPreflight(target); + const request = vi.fn(); + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); expect(report.status).toBe("fail"); expect(report.localReadiness).toBe("fail"); expect(report.packagePublication).toBe("fail"); expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.package.local-name-mismatch"); + expect(request).not.toHaveBeenCalled(); }); it("fails when publication evidence declares multiple npm packages", async () => { @@ -199,9 +430,11 @@ describe("MCP Registry publication preflight", () => { }] }); - const report = await buildMcpRegistryPublicationPreflight(target); + const request = vi.fn(); + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); expect(report.packagePublication).toBe("skipped"); + expect(request).not.toHaveBeenCalled(); }); it("skips package publication evidence for remote-only input", async () => { @@ -217,11 +450,36 @@ describe("MCP Registry publication preflight", () => { }] }); - const report = await buildMcpRegistryPublicationPreflight(target); + const request = vi.fn(); + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); expect(report.status).toBe("warn"); expect(report.localReadiness).toBe("pass"); expect(report.packagePublication).toBe("skipped"); expect(report.registryVersionAvailability).toBe("unknown"); + expect(request).not.toHaveBeenCalled(); + }); + + it("does not serialize untrusted response or error sentinels", async () => { + const sentinel = "registry-preflight-redaction-sentinel"; + const pathReport = await buildMcpRegistryPublicationPreflight(await writeServerJson({ + ...validServer, + name: `C:\\\\${sentinel}`, + version: `/${sentinel}` + }, matchingPackageJson)); + const responseRequest = requestSequence(response(200, sentinel, { "www-authenticate": sentinel })); + + const responseReport = await buildMcpRegistryPublicationPreflight(await writeServerJson(validServer, matchingPackageJson), { + allowNetwork: true, + request: responseRequest + }); + const errorReport = await buildMcpRegistryPublicationPreflight(await writeServerJson(validServer, matchingPackageJson), { + allowNetwork: true, + request: requestSequence(new Error(sentinel)) + }); + + expect(renderMcpRegistryPublicationPreflightJson(pathReport)).not.toContain(sentinel); + expect(renderMcpRegistryPublicationPreflightJson(responseReport)).not.toContain(sentinel); + expect(renderMcpRegistryPublicationPreflightJson(errorReport)).not.toContain(sentinel); }); }); From 39e7cb865f6e10605cb56b4d270430d4542f1ac3 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:18:34 +0300 Subject: [PATCH 06/16] fix: reject malformed Registry preflight evidence --- src/core/mcp-registry-preflight.ts | 54 +++++++++++++-- tests/mcp-registry-preflight.test.ts | 100 +++++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 4 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index bb161c2..6b23c60 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -18,8 +18,10 @@ type RegistryRequest = ( ) => Promise; const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i; +const OFFICIAL_SCHEMA_PATTERN = /^https:\/\/static\.modelcontextprotocol\.io\/schemas\/\d{4}-\d{2}-\d{2}\/server\.schema\.json$/; const NPM_IDENTIFIER_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,213}\/)?[a-z0-9][a-z0-9._-]{0,213}$/; const INTEGRITY_PATTERN = /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2}$/; +const SHA256_PATTERN = /^[a-f0-9]{64}$/; const NPM_REGISTRY = "https://registry.npmjs.org"; const MCP_REGISTRY = "https://registry.modelcontextprotocol.io"; const REQUEST_OPTIONS: BoundedHttpRequestOptions = { @@ -114,6 +116,53 @@ function validNotFound(response: BoundedHttpResponse): boolean { && payload.error.trim().length > 0; } +function hasValidHttpUrl(value: unknown): boolean { + if (typeof value !== "string" || value.length === 0 || /\s/.test(value)) { + return false; + } + try { + const url = new URL(value.replace(/\{[^{}]+\}/g, "template-value")); + return (url.protocol === "http:" || url.protocol === "https:") && !url.username && !url.password; + } catch { + return false; + } +} + +function hasValidPackageShape(value: unknown): boolean { + if (!isRecord(value) + || typeof value.registryType !== "string" || value.registryType.length === 0 + || typeof value.identifier !== "string" || value.identifier.length === 0 + || !isRecord(value.transport) + || !["stdio", "streamable-http", "sse"].includes(value.transport.type as string)) { + return false; + } + if (/^https?:\/\//i.test(value.identifier) && !hasValidHttpUrl(value.identifier)) { + return false; + } + if (typeof value.version === "string" && VERSION_RANGE_PATTERN.test(value.version)) { + return false; + } + return value.registryType !== "mcpb" || (typeof value.fileSha256 === "string" && SHA256_PATTERN.test(value.fileSha256)); +} + +function hasValidRemoteShape(value: unknown): boolean { + return isRecord(value) + && (value.type === "streamable-http" || value.type === "sse") + && hasValidHttpUrl(value.url); +} + +function hasValidRegistryServerShape(server: Record): boolean { + return typeof server.$schema === "string" + && OFFICIAL_SCHEMA_PATTERN.test(server.$schema) + && isSafeServerName(typeof server.name === "string" ? server.name : undefined) + && isSafeServerVersion(typeof server.version === "string" ? server.version : undefined) + && typeof server.description === "string" + && server.description.length >= 1 + && server.description.length <= 100 + && (server.packages === undefined || (Array.isArray(server.packages) && server.packages.every(hasValidPackageShape))) + && (server.remotes === undefined || (Array.isArray(server.remotes) && server.remotes.every(hasValidRemoteShape))); +} + function matchingRegistryServer( response: BoundedHttpResponse, serverName: string, @@ -125,11 +174,8 @@ function matchingRegistryServer( } const server = payload.server; - return typeof server.$schema === "string" - && typeof server.description === "string" - && (Array.isArray(server.packages) || Array.isArray(server.remotes)) + return hasValidRegistryServerShape(server) && server.name === serverName - && isSafeServerVersion(typeof server.version === "string" ? server.version : undefined) && (serverVersion === undefined || server.version === serverVersion); } diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index c2b8e1b..fc206ce 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -276,6 +276,64 @@ describe("MCP Registry publication preflight", () => { expect(request).toHaveBeenCalledTimes(2); }); + it("keeps Registry version availability unknown for malformed exact Registry metadata", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(200, { server: { ...registryServer(), $schema: "https://example.com/server.schema.json" } }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.exact-response"); + }); + + it("keeps Registry version availability unknown for malformed exact Registry package metadata", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(200, { server: { ...registryServer(), packages: [{ registryType: "npm" }] } }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.exact-response"); + }); + + it("accepts a historical official Registry schema URL", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(200, { server: { + ...registryServer(), + $schema: "https://static.modelcontextprotocol.io/schemas/2025-10-01/server.schema.json" + } }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.registryVersionAvailability).toBe("already-published"); + }); + + it("keeps Registry version availability unknown for malformed latest Registry metadata", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + response(200, { server: { ...registryServer(validServer.name, "1.2.2"), description: "" } }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); + }); + it.each([ ["a malformed exact not-found response", response(404, { error: "" })], ["an arbitrary exact client error", response(418, { error: "teapot" })], @@ -306,6 +364,36 @@ describe("MCP Registry publication preflight", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.request"); }); + it("keeps Registry version availability unknown when the exact Registry request throws after npm succeeds", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(200, npmPackument()), new Error("exact request failed")); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("pass"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.exact-request"); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("keeps Registry version availability unknown when the latest Registry request throws", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + new Error("latest request failed") + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("pass"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-request"); + expect(request).toHaveBeenCalledTimes(3); + }); + it("keeps Registry version availability unknown for a mismatched latest record", async () => { const target = await writeServerJson(validServer, matchingPackageJson); const request = requestSequence( @@ -337,6 +425,18 @@ describe("MCP Registry publication preflight", () => { expect(request).not.toHaveBeenCalled(); }); + it("does not issue requests without explicit network consent", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = vi.fn(); + + const report = await buildMcpRegistryPublicationPreflight(target, { request }); + + expect(report.status).toBe("warn"); + expect(report.packagePublication).toBe("unknown"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(request).not.toHaveBeenCalled(); + }); + it("fails when publication evidence declares multiple npm packages", async () => { const target = await writeServerJson({ ...validServer, From 51046d8c118916b2d479b72df95fb1859b108809 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:22:40 +0300 Subject: [PATCH 07/16] test: cover Registry preflight failure gates --- tests/mcp-registry-preflight.test.ts | 34 +++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index fc206ce..a55b30c 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -394,6 +394,36 @@ describe("MCP Registry publication preflight", () => { expect(request).toHaveBeenCalledTimes(3); }); + it("keeps Registry version availability unknown for an unexpected latest Registry status", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + response(418, { error: "unexpected response" }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); + }); + + it("keeps Registry version availability unknown for a malformed latest Registry not-found body", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + response(404, { error: "" }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); + }); + it("keeps Registry version availability unknown for a mismatched latest record", async () => { const target = await writeServerJson(validServer, matchingPackageJson); const request = requestSequence( @@ -451,12 +481,14 @@ describe("MCP Registry publication preflight", () => { ] }, matchingPackageJson); - const report = await buildMcpRegistryPublicationPreflight(target); + const request = vi.fn(); + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); expect(report.status).toBe("fail"); expect(report.localReadiness).toBe("fail"); expect(report.packagePublication).toBe("fail"); expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.package.multiple-npm-declarations"); + expect(request).not.toHaveBeenCalled(); }); it("inherits a package mcpName mismatch as a blocking local failure", async () => { From 1c1cc260fad39f8bd65b092d1b1307b5630a9d3c Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:30:51 +0300 Subject: [PATCH 08/16] fix: validate npm integrity metadata --- src/core/mcp-registry-preflight.ts | 13 ++++++- tests/mcp-registry-preflight.test.ts | 55 ++++++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index 6b23c60..080817a 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -20,7 +20,7 @@ const SERVER_NAME_PATTERN = /^[a-zA-Z0-9.-]+\/[a-zA-Z0-9._-]+$/; const VERSION_RANGE_PATTERN = /^(?:latest|[~^]|[<>]=?)|(?:\s+\|\|\s+)|(?:^|[.\s])[x*](?:$|[.\s])/i; const OFFICIAL_SCHEMA_PATTERN = /^https:\/\/static\.modelcontextprotocol\.io\/schemas\/\d{4}-\d{2}-\d{2}\/server\.schema\.json$/; const NPM_IDENTIFIER_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]{0,213}\/)?[a-z0-9][a-z0-9._-]{0,213}$/; -const INTEGRITY_PATTERN = /^(?:sha256|sha384|sha512)-[A-Za-z0-9+/]+={0,2}$/; +const INTEGRITY_PATTERN = /^(sha256|sha384|sha512)-([A-Za-z0-9+/]*={0,2})$/; const SHA256_PATTERN = /^[a-f0-9]{64}$/; const NPM_REGISTRY = "https://registry.npmjs.org"; const MCP_REGISTRY = "https://registry.modelcontextprotocol.io"; @@ -97,7 +97,16 @@ function isSafeNpmIdentifier(value: unknown): value is string { } function isIntegrity(value: unknown): value is string { - return typeof value === "string" && INTEGRITY_PATTERN.test(value); + if (typeof value !== "string") { + return false; + } + const match = INTEGRITY_PATTERN.exec(value); + if (!match || match[2].length % 4 !== 0) { + return false; + } + const digest = Buffer.from(match[2], "base64"); + const expectedBytes = match[1] === "sha256" ? 32 : match[1] === "sha384" ? 48 : 64; + return digest.length === expectedBytes && digest.toString("base64") === match[2]; } function responseJson(response: BoundedHttpResponse): unknown | null { diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index a55b30c..1044038 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -39,6 +39,7 @@ const matchingPackageJson = { version: "1.2.3", mcpName: "io.github.example/weather" }; +const validSha512Integrity = `sha512-${"A".repeat(86)}==`; function response(statusCode: number, payload: unknown, headers: Record = {}) { return { @@ -56,13 +57,26 @@ function npmPackument(overrides: Record = {}) { name: matchingPackageJson.name, version: validServer.version, mcpName: validServer.name, - dist: { integrity: "sha512-aGVsbG8=" } + dist: { integrity: validSha512Integrity } } }, ...overrides }; } +function npmPackumentWithIntegrity(integrity: string) { + return npmPackument({ + versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: validServer.version, + mcpName: validServer.name, + dist: { integrity } + } + } + }); +} + function registryServer(name = validServer.name, version = validServer.version) { return { ...validServer, @@ -168,7 +182,7 @@ describe("MCP Registry publication preflight", () => { name: "@example/other-mcp", version: validServer.version, mcpName: validServer.name, - dist: { integrity: "sha512-aGVsbG8=" } + dist: { integrity: validSha512Integrity } } } })], ["mismatched published package version", npmPackument({ versions: { @@ -176,7 +190,7 @@ describe("MCP Registry publication preflight", () => { name: matchingPackageJson.name, version: "1.2.4", mcpName: validServer.name, - dist: { integrity: "sha512-aGVsbG8=" } + dist: { integrity: validSha512Integrity } } } })], ["mismatched published mcpName", npmPackument({ versions: { @@ -184,7 +198,7 @@ describe("MCP Registry publication preflight", () => { name: matchingPackageJson.name, version: validServer.version, mcpName: "io.github.example/other", - dist: { integrity: "sha512-aGVsbG8=" } + dist: { integrity: validSha512Integrity } } } })], ["malformed published integrity", npmPackument({ versions: { @@ -211,6 +225,39 @@ describe("MCP Registry publication preflight", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.metadata"); }); + it.each([ + ["a too-short digest", "sha512-A"], + ["invalid base64 padding", `sha512-${"A".repeat(86)}=`], + ["invalid base64 characters", `sha512-${"A".repeat(85)}!==`] + ])("blocks package publication for %s", async (_caseName, integrity) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence(response(200, npmPackumentWithIntegrity(integrity))); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.packagePublication).toBe("fail"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.npm.metadata"); + }); + + it.each([ + ["sha256", `sha256-${"A".repeat(43)}=`], + ["sha384", `sha384-${"A".repeat(64)}`], + ["sha512", `sha512-${"A".repeat(86)}==`] + ])("accepts a valid %s npm integrity digest", async (_algorithm, integrity) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackumentWithIntegrity(integrity)), + response(404, { error: "server not found" }), + response(404, { error: "server not found" }) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("pass"); + expect(report.packagePublication).toBe("pass"); + }); + it.each([418, 503])("keeps package publication unknown for unexpected npm HTTP %s", async (statusCode) => { const target = await writeServerJson(validServer, matchingPackageJson); const request = requestSequence(response(statusCode, { error: "unexpected response" })); From c1bcf9e3e87963691c16eaa869582e9a3f9b5cf1 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:39:05 +0300 Subject: [PATCH 09/16] feat: expose Registry publication preflight --- src/core/mcp-registry-preflight.ts | 44 +++++++++++-- src/index.ts | 10 +++ src/run-cli.ts | 42 ++++++++++-- tests/registry-command.test.ts | 102 ++++++++++++++++++++++++++++- 4 files changed, 185 insertions(+), 13 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index 080817a..e449ba6 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -11,7 +11,7 @@ import { type BoundedHttpResponse } from "./bounded-http-client.js"; -type PreflightStatus = "pass" | "warn" | "fail"; +export type McpRegistryPublicationPreflightStatus = "pass" | "warn" | "fail"; type RegistryRequest = ( url: string, options?: BoundedHttpRequestOptions @@ -45,8 +45,8 @@ export interface McpRegistryPublicationPreflightReport { target: "server.json"; serverName?: string; serverVersion?: string; - status: PreflightStatus; - localReadiness: PreflightStatus; + status: McpRegistryPublicationPreflightStatus; + localReadiness: McpRegistryPublicationPreflightStatus; packagePublication: "pass" | "fail" | "skipped" | "unknown"; registryVersionAvailability: "available-first-publication" | "available-new-version" | "already-published" | "unknown"; publisherPlan: { @@ -76,7 +76,7 @@ function publicFinding(finding: McpRegistryFinding): McpRegistryPublicationPrefl }; } -function statusFromFindings(findings: McpRegistryPublicationPreflightFinding[]): PreflightStatus { +function statusFromFindings(findings: McpRegistryPublicationPreflightFinding[]): McpRegistryPublicationPreflightStatus { if (findings.some((finding) => finding.severity === "fail")) { return "fail"; } @@ -397,7 +397,7 @@ export async function buildMcpRegistryPublicationPreflight( function buildReport( readiness: Awaited>, - localReadiness: PreflightStatus, + localReadiness: McpRegistryPublicationPreflightStatus, packagePublication: McpRegistryPublicationPreflightReport["packagePublication"], registryVersionAvailability: McpRegistryPublicationPreflightReport["registryVersionAvailability"], findings: McpRegistryPublicationPreflightFinding[] @@ -423,3 +423,37 @@ export function renderMcpRegistryPublicationPreflightJson( ): string { return JSON.stringify(report, null, 2); } + +export function renderMcpRegistryPublicationPreflight( + report: McpRegistryPublicationPreflightReport +): string { + const networkVerification = report.findings.some((finding) => finding.id === "registry.preflight.network-unverified") + ? "NOT REQUESTED" + : report.registryVersionAvailability === "unknown" && report.packagePublication !== "pass" + ? "NOT AVAILABLE" + : "COMPLETED"; + const lines = [ + `Registry publication preflight: ${report.status.toUpperCase()}`, + `Target: ${report.target}`, + report.serverName ? `Server: ${report.serverName}@${report.serverVersion ?? "unknown"}` : null, + `Local readiness: ${report.localReadiness.toUpperCase()}`, + `Package publication: ${report.packagePublication.toUpperCase()}`, + `Registry version availability: ${report.registryVersionAvailability.toUpperCase()}`, + `Network verification: ${networkVerification}` + ].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}`); + } + } + return lines.join("\n"); +} + +export function registryPublicationPreflightExitCode( + report: McpRegistryPublicationPreflightReport, + requirePublishReady = false +): 0 | 1 { + return report.status === "fail" || (requirePublishReady && report.status !== "pass") ? 1 : 0; +} diff --git a/src/index.ts b/src/index.ts index c5a34ce..841762d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -297,6 +297,16 @@ export { type McpRegistryReadinessReport, type McpRegistryScorecard } from "./core/mcp-registry.js"; +export { + buildMcpRegistryPublicationPreflight, + registryPublicationPreflightExitCode, + renderMcpRegistryPublicationPreflight, + renderMcpRegistryPublicationPreflightJson, + type BuildMcpRegistryPublicationPreflightOptions, + type McpRegistryPublicationPreflightFinding, + type McpRegistryPublicationPreflightReport, + type McpRegistryPublicationPreflightStatus +} from "./core/mcp-registry-preflight.js"; export { watchPlugin, type WatchPluginOptions, diff --git a/src/run-cli.ts b/src/run-cli.ts index 8447030..aa90ab4 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -160,6 +160,12 @@ import { renderMcpRegistryReadiness, renderMcpRegistryReadinessJson } from "./core/mcp-registry.js"; +import { + buildMcpRegistryPublicationPreflight, + registryPublicationPreflightExitCode, + renderMcpRegistryPublicationPreflight, + renderMcpRegistryPublicationPreflightJson +} from "./core/mcp-registry-preflight.js"; import { buildDoctorRiskDiffReport, renderDoctorRiskDiffReport, @@ -412,7 +418,8 @@ function printUsage(io: CliIo): void { ); 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]" + + " codex-plugin-doctor registry inspect --allow-network [--json] [--output ] [--require-registry-readiness]\n" + + "Registry publication preflight: codex-plugin-doctor registry preflight [--allow-network] [--json] [--output ] [--require-publish-ready]" ); io.writeStderr( "Corpus quality regression: codex-plugin-doctor doctor corpus metrics diff --before --after [--fail-on-regression] [--json|--markdown] [--output ]" @@ -3305,10 +3312,11 @@ export async function runCli( const subcommand = maybePath; const target = remainingArgs[0]; const flags = remainingArgs.slice(1); - if ((subcommand !== "check" && subcommand !== "inspect") || !target || target.startsWith("--")) { + if ((subcommand !== "check" && subcommand !== "inspect" && subcommand !== "preflight") || !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]" + + " codex-plugin-doctor registry inspect --allow-network [--json] [--output ] [--require-registry-readiness]\n" + + " codex-plugin-doctor registry preflight [--allow-network] [--json] [--output ] [--require-publish-ready]" ); return 2; } @@ -3317,7 +3325,8 @@ export async function runCli( "--json", "--output", "--allow-network", - "--require-registry-readiness" + "--require-registry-readiness", + "--require-publish-ready" ]); let outputPath: string | null = null; for (let index = 0; index < flags.length; index += 1) { @@ -3339,15 +3348,34 @@ export async function runCli( const allowNetwork = flags.includes("--allow-network"); if (subcommand === "check" && allowNetwork) { - io.writeStderr("--allow-network is supported only by registry inspect."); + io.writeStderr("--allow-network is not supported by registry check."); return 2; } if (subcommand === "inspect" && !allowNetwork) { io.writeStderr("registry inspect requires explicit --allow-network consent."); return 2; } + if (subcommand === "preflight" && flags.includes("--require-registry-readiness")) { + io.writeStderr("--require-registry-readiness is supported only by registry check or registry inspect."); + return 2; + } + if (subcommand !== "preflight" && flags.includes("--require-publish-ready")) { + io.writeStderr("--require-publish-ready is supported only by registry preflight."); + return 2; + } try { + if (subcommand === "preflight") { + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork }); + const rendered = flags.includes("--json") + ? renderMcpRegistryPublicationPreflightJson(report) + : renderMcpRegistryPublicationPreflight(report); + if (outputPath) { + await writeFile(outputPath, rendered, "utf8"); + } + io.writeStdout(rendered); + return registryPublicationPreflightExitCode(report, flags.includes("--require-publish-ready")); + } const report = subcommand === "check" ? await buildMcpRegistryReadiness(target) : await inspectMcpRegistryServer(target, { allowNetwork: true }); @@ -3359,8 +3387,8 @@ export async function runCli( } io.writeStdout(rendered); return registryReadinessExitCode(report, flags.includes("--require-registry-readiness")); - } catch (error) { - io.writeStderr(`Registry inspection failed: ${(error as Error).message}`); + } catch { + io.writeStderr("Registry command failed."); return 1; } } diff --git a/tests/registry-command.test.ts b/tests/registry-command.test.ts index b149b98..fd75110 100644 --- a/tests/registry-command.test.ts +++ b/tests/registry-command.test.ts @@ -1,9 +1,16 @@ -import { mkdtemp, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, 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"; +import { + buildMcpRegistryPublicationPreflight, + registryPublicationPreflightExitCode, + renderMcpRegistryPublicationPreflight, + renderMcpRegistryPublicationPreflightJson, + type McpRegistryPublicationPreflightStatus +} from "../src/index.js"; function createIo() { const stdout: string[] = []; @@ -65,4 +72,97 @@ describe("registry command", () => { expect(await runCli(["registry", "check", ".", "--publish"], result.io)).toBe(2); expect(result.stderr.join("")).toContain("Unknown registry flag"); }); + + it("renders an offline publication preflight without using the network", async () => { + const target = await createMetadataOnlyServer(); + const result = createIo(); + + expect(await runCli(["registry", "preflight", target], result.io)).toBe(0); + expect(result.stdout.join("")).toContain("Registry publication preflight: WARN"); + expect(result.stdout.join("")).toContain("Network verification: NOT REQUESTED"); + }); + + it("blocks an offline warning when publication readiness is required", async () => { + const target = await createMetadataOnlyServer(); + const result = createIo(); + + expect(await runCli([ + "registry", "preflight", target, "--require-publish-ready" + ], result.io)).toBe(1); + }); + + it("does not claim network verification completed after a local offline failure", async () => { + const target = await createMetadataOnlyServer(); + await writeFile(path.join(target, "server.json"), JSON.stringify({ + $schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + name: "com.example/metadata-only", + description: "", + version: "1.0.0" + }), "utf8"); + + const report = await buildMcpRegistryPublicationPreflight(target); + + expect(renderMcpRegistryPublicationPreflight(report)).toContain("Network verification: NOT AVAILABLE"); + }); + + it("writes the exact JSON preflight report to --output", async () => { + const target = await createMetadataOnlyServer(); + const outputPath = path.join(target, "preflight.json"); + const result = createIo(); + + expect(await runCli([ + "registry", "preflight", target, "--json", "--output", outputPath + ], result.io)).toBe(0); + expect(await readFile(outputPath, "utf8")).toBe(result.stdout.join("")); + expect(JSON.parse(result.stdout.join(""))).toMatchObject({ + kind: "mcp-registry-publication-preflight", + status: "warn" + }); + }); + + it("rejects Registry flags outside their subcommand", async () => { + const target = await createMetadataOnlyServer(); + const check = createIo(); + const inspect = createIo(); + const preflight = createIo(); + + expect(await runCli(["registry", "check", target, "--allow-network"], check.io)).toBe(2); + expect(check.stderr.join("")).toContain("registry check"); + + expect(await runCli([ + "registry", "inspect", "io.github.example/weather", "--allow-network", "--require-publish-ready" + ], inspect.io)).toBe(2); + expect(inspect.stderr.join("")).toContain("registry preflight"); + + expect(await runCli([ + "registry", "preflight", target, "--require-registry-readiness" + ], preflight.io)).toBe(2); + expect(preflight.stderr.join("")).toContain("registry check or registry inspect"); + }); + + it("rejects missing and unknown preflight flags", async () => { + const target = await createMetadataOnlyServer(); + const missingOutput = createIo(); + const unknown = createIo(); + + expect(await runCli(["registry", "preflight", target, "--output"], missingOutput.io)).toBe(2); + expect(missingOutput.stderr.join("")).toContain("Missing path after --output"); + + expect(await runCli(["registry", "preflight", target, "--publish"], unknown.io)).toBe(2); + expect(unknown.stderr.join("")).toContain("Unknown registry flag"); + }); + + it("exports preflight builders, renderers, status, and exit policy from the barrel", async () => { + const target = await createMetadataOnlyServer(); + const report = await buildMcpRegistryPublicationPreflight(target); + const status: McpRegistryPublicationPreflightStatus = report.status; + + expect(status).toBe("warn"); + expect(renderMcpRegistryPublicationPreflight(report)).toContain("WARN"); + expect(JSON.parse(renderMcpRegistryPublicationPreflightJson(report))).toMatchObject({ + status: "warn" + }); + expect(registryPublicationPreflightExitCode(report)).toBe(0); + expect(registryPublicationPreflightExitCode(report, true)).toBe(1); + }); }); From 7c67fc974e73a27a972b853f254943cec72d31be Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:45:57 +0300 Subject: [PATCH 10/16] test: cover Registry preflight CLI exits --- tests/registry-command.test.ts | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/registry-command.test.ts b/tests/registry-command.test.ts index fd75110..de77420 100644 --- a/tests/registry-command.test.ts +++ b/tests/registry-command.test.ts @@ -105,6 +105,31 @@ describe("registry command", () => { expect(renderMcpRegistryPublicationPreflight(report)).toContain("Network verification: NOT AVAILABLE"); }); + it("returns failure for an offline preflight with blocking local metadata", async () => { + const target = await createMetadataOnlyServer(); + await writeFile(path.join(target, "server.json"), JSON.stringify({ + $schema: "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + name: "com.example/metadata-only", + description: "", + version: "1.0.0" + }), "utf8"); + const result = createIo(); + + expect(await runCli(["registry", "preflight", target], result.io)).toBe(1); + expect(result.stdout.join("")).toContain("Registry publication preflight: FAIL"); + }); + + it("writes the exact text preflight report to --output", async () => { + const target = await createMetadataOnlyServer(); + const outputPath = path.join(target, "preflight.txt"); + const result = createIo(); + + expect(await runCli([ + "registry", "preflight", target, "--output", outputPath + ], result.io)).toBe(0); + expect(await readFile(outputPath, "utf8")).toBe(result.stdout.join("")); + }); + it("writes the exact JSON preflight report to --output", async () => { const target = await createMetadataOnlyServer(); const outputPath = path.join(target, "preflight.json"); @@ -152,6 +177,32 @@ describe("registry command", () => { expect(unknown.stderr.join("")).toContain("Unknown registry flag"); }); + it("returns usage for missing or invalid preflight targets", async () => { + const missingTarget = createIo(); + const invalidTarget = createIo(); + + expect(await runCli(["registry", "preflight"], missingTarget.io)).toBe(2); + expect(missingTarget.stderr.join("")).toContain("Usage:"); + expect(missingTarget.stderr.join("")).toContain("registry preflight"); + + expect(await runCli(["registry", "preflight", "--json"], invalidTarget.io)).toBe(2); + expect(invalidTarget.stderr.join("")).toContain("Usage:"); + expect(invalidTarget.stderr.join("")).toContain("registry preflight"); + }); + + it("hides write failures behind the generic Registry error", async () => { + const target = await createMetadataOnlyServer(); + const outputPath = path.join(target, "missing-parent", "preflight.txt"); + const result = createIo(); + + expect(await runCli([ + "registry", "preflight", target, "--output", outputPath + ], result.io)).toBe(1); + expect(result.stderr.join("")).toBe("Registry command failed."); + expect(result.stderr.join("")).not.toContain(outputPath); + expect(result.stderr.join("")).not.toContain("ENOENT"); + }); + it("exports preflight builders, renderers, status, and exit policy from the barrel", async () => { const target = await createMetadataOnlyServer(); const report = await buildMcpRegistryPublicationPreflight(target); From 2215e5ce722500cc7d84751e0778a460351d3507 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 19:54:07 +0300 Subject: [PATCH 11/16] fix: preserve Registry CLI diagnostics --- src/core/mcp-registry-preflight.ts | 6 ++++-- src/run-cli.ts | 8 ++++++-- tests/mcp-registry-preflight.test.ts | 2 ++ tests/registry-command.test.ts | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index e449ba6..ee100c4 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -429,9 +429,11 @@ export function renderMcpRegistryPublicationPreflight( ): string { const networkVerification = report.findings.some((finding) => finding.id === "registry.preflight.network-unverified") ? "NOT REQUESTED" - : report.registryVersionAvailability === "unknown" && report.packagePublication !== "pass" + : report.packagePublication === "pass" && report.registryVersionAvailability !== "unknown" + ? "COMPLETED" + : report.localReadiness === "fail" ? "NOT AVAILABLE" - : "COMPLETED"; + : "INCOMPLETE"; const lines = [ `Registry publication preflight: ${report.status.toUpperCase()}`, `Target: ${report.target}`, diff --git a/src/run-cli.ts b/src/run-cli.ts index aa90ab4..3bc86fa 100644 --- a/src/run-cli.ts +++ b/src/run-cli.ts @@ -3387,8 +3387,12 @@ export async function runCli( } io.writeStdout(rendered); return registryReadinessExitCode(report, flags.includes("--require-registry-readiness")); - } catch { - io.writeStderr("Registry command failed."); + } catch (error) { + if (subcommand === "preflight") { + io.writeStderr("Registry publication preflight failed."); + } else { + io.writeStderr(`Registry inspection failed: ${(error as Error).message}`); + } return 1; } } diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index 1044038..2372cb4 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import { buildMcpRegistryPublicationPreflight, + renderMcpRegistryPublicationPreflight, renderMcpRegistryPublicationPreflightJson } from "../src/core/mcp-registry-preflight.js"; @@ -421,6 +422,7 @@ describe("MCP Registry publication preflight", () => { expect(report.packagePublication).toBe("pass"); expect(report.registryVersionAvailability).toBe("unknown"); expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.exact-request"); + expect(renderMcpRegistryPublicationPreflight(report)).toContain("Network verification: INCOMPLETE"); expect(request).toHaveBeenCalledTimes(2); }); diff --git a/tests/registry-command.test.ts b/tests/registry-command.test.ts index de77420..7f5bb2a 100644 --- a/tests/registry-command.test.ts +++ b/tests/registry-command.test.ts @@ -190,7 +190,7 @@ describe("registry command", () => { expect(invalidTarget.stderr.join("")).toContain("registry preflight"); }); - it("hides write failures behind the generic Registry error", async () => { + it("hides preflight write failures behind the generic preflight error", async () => { const target = await createMetadataOnlyServer(); const outputPath = path.join(target, "missing-parent", "preflight.txt"); const result = createIo(); @@ -198,11 +198,22 @@ describe("registry command", () => { expect(await runCli([ "registry", "preflight", target, "--output", outputPath ], result.io)).toBe(1); - expect(result.stderr.join("")).toBe("Registry command failed."); + expect(result.stderr.join("")).toBe("Registry publication preflight failed."); expect(result.stderr.join("")).not.toContain(outputPath); expect(result.stderr.join("")).not.toContain("ENOENT"); }); + it("preserves Registry check diagnostics when output writing fails", async () => { + const target = await createMetadataOnlyServer(); + const outputPath = path.join(target, "missing-parent", "check.txt"); + const result = createIo(); + + expect(await runCli([ + "registry", "check", target, "--output", outputPath + ], result.io)).toBe(1); + expect(result.stderr.join("")).toContain("Registry inspection failed:"); + }); + it("exports preflight builders, renderers, status, and exit policy from the barrel", async () => { const target = await createMetadataOnlyServer(); const report = await buildMcpRegistryPublicationPreflight(target); From ea03beea86db594a2b29e45ea500fe5d71e8489c Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 20:01:09 +0300 Subject: [PATCH 12/16] docs: publish Registry preflight contract --- README.md | 26 +++++++ .../mcp-registry-publication-preflight.md | 7 +- docs/architecture/mcp-registry-readiness.md | 3 + src/core/mcp-registry-preflight.ts | 3 + src/core/output-contract.ts | 76 +++++++++++++++++++ tests/contract-command.test.ts | 46 +++++++++++ 6 files changed, 159 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8078f4e..f737e75 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,32 @@ 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). +### MCP Registry Publication Preflight + +Check a publication candidate locally without making a network request: + +```bash +codex-plugin-doctor registry preflight path/to/server.json --json +``` + +Opt in to bounded public npm and MCP Registry metadata checks: + +```bash +codex-plugin-doctor registry preflight path/to/server.json --allow-network --json +``` + +Require a publish-ready result in CI: + +```bash +codex-plugin-doctor registry preflight path/to/server.json --allow-network --require-publish-ready --json +``` + +Preflight checks the exact npm package version, then the exact Registry version +and latest Registry record on fixed public hosts. An existing exact Registry +version is an immutable collision and must be replaced by a version bump. The +command is advisory: it never authenticates, publishes, or changes npm or +Registry records. See [MCP Registry Publication Preflight](./docs/architecture/mcp-registry-publication-preflight.md). + Output formats: - human text output diff --git a/docs/architecture/mcp-registry-publication-preflight.md b/docs/architecture/mcp-registry-publication-preflight.md index c83e6d7..4b9f511 100644 --- a/docs/architecture/mcp-registry-publication-preflight.md +++ b/docs/architecture/mcp-registry-publication-preflight.md @@ -111,7 +111,8 @@ preflight does not download the tarball or independently verify its contents. ## Report Contract -The JSON report uses: +`codex-plugin-doctor doctor contract --json` publishes this stable surface as +`doctor.registry.preflight.json`. The JSON report uses: ```text kind: mcp-registry-publication-preflight @@ -124,7 +125,9 @@ registryVersionAvailability: available-new-version | already-published | unknown -publisherPlan: ordered, non-executing steps +publisherPlan: + executable: false + steps: ordered non-executing steps with order, command, and purpose findings: stable identifiers, severity, and redacted messages ``` diff --git a/docs/architecture/mcp-registry-readiness.md b/docs/architecture/mcp-registry-readiness.md index fafe0bc..e59eba3 100644 --- a/docs/architecture/mcp-registry-readiness.md +++ b/docs/architecture/mcp-registry-readiness.md @@ -6,6 +6,9 @@ The Registry Doctor validates whether MCP `server.json` metadata is structurally 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. +For npm publication evidence and immutable Registry-version availability, use +[MCP Registry Publication Preflight](./mcp-registry-publication-preflight.md). + ## Commands Validate a local file or a directory containing `server.json`: diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index ee100c4..786faa7 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -52,6 +52,7 @@ export interface McpRegistryPublicationPreflightReport { publisherPlan: { executable: false; steps: Array<{ + order: number; command: "mcp-publisher login github" | "mcp-publisher publish"; purpose: string; }>; @@ -246,10 +247,12 @@ function publisherPlan(): McpRegistryPublicationPreflightReport["publisherPlan"] executable: false, steps: [ { + order: 1, command: "mcp-publisher login github", purpose: "Authenticate with GitHub for Registry publication." }, { + order: 2, command: "mcp-publisher publish", purpose: "Publish the validated Registry metadata." } diff --git a/src/core/output-contract.ts b/src/core/output-contract.ts index 7cc90bc..dd8ac3d 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -55,6 +55,52 @@ const remoteTransportReliabilityStatusSchema = { enum: ["pass", "warn", "fail", "skipped"] }; +const registryPublicationPreflightStatusSchema = { + type: "string", + enum: ["pass", "warn", "fail"] +}; + +const registryPackagePublicationSchema = { + type: "string", + enum: ["pass", "fail", "skipped", "unknown"] +}; + +const registryVersionAvailabilitySchema = { + type: "string", + enum: ["available-first-publication", "available-new-version", "already-published", "unknown"] +}; + +const registryPublisherPlanSchema = { + type: "object", + properties: { + executable: { + type: "boolean" + }, + steps: { + type: "array", + items: { + type: "object", + properties: { + order: { + type: "integer", + minimum: 1 + }, + command: { + type: "string" + }, + purpose: { + type: "string" + } + }, + required: ["order", "command", "purpose"], + additionalProperties: false + } + } + }, + required: ["executable", "steps"], + additionalProperties: false +}; + const runtimeConformanceSchema = { type: "object", properties: { @@ -254,6 +300,36 @@ const publicSchemaDefinitions: Array<{ } } }, + { + id: "doctor.registry.preflight.json", + command: "codex-plugin-doctor registry preflight --json", + outputKind: "mcp-registry-publication-preflight", + required: [ + "schemaVersion", + "kind", + "generatedAt", + "target", + "status", + "localReadiness", + "packagePublication", + "registryVersionAvailability", + "publisherPlan", + "findings" + ], + properties: { + target: { + const: "server.json" + }, + status: registryPublicationPreflightStatusSchema, + localReadiness: registryPublicationPreflightStatusSchema, + packagePublication: registryPackagePublicationSchema, + registryVersionAvailability: registryVersionAvailabilitySchema, + publisherPlan: registryPublisherPlanSchema, + 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 4ec22ba..8725672 100644 --- a/tests/contract-command.test.ts +++ b/tests/contract-command.test.ts @@ -81,6 +81,11 @@ describe("doctor contract command", () => { command: "codex-plugin-doctor registry check --json", outputKind: "mcp-registry-readiness" }), + expect.objectContaining({ + id: "doctor.registry.preflight.json", + command: "codex-plugin-doctor registry preflight --json", + outputKind: "mcp-registry-publication-preflight" + }), expect.objectContaining({ id: "doctor.watch.validation.json", command: "codex-plugin-doctor watch --json" @@ -214,6 +219,9 @@ describe("doctor contract command", () => { const mcpSchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.mcp.json" ); + const registryPreflightSchema = output.schemas.find( + (surface: { id: string }) => surface.id === "doctor.registry.preflight.json" + ); const releaseEvidenceSchema = output.schemas.find( (surface: { id: string }) => surface.id === "doctor.release.evidence.json" ); @@ -241,6 +249,44 @@ describe("doctor contract command", () => { "security", "compatibility" ]); + expect(registryPreflightSchema.schema.required).toEqual([ + "schemaVersion", + "kind", + "generatedAt", + "target", + "status", + "localReadiness", + "packagePublication", + "registryVersionAvailability", + "publisherPlan", + "findings" + ]); + expect(registryPreflightSchema.schema.properties).toMatchObject({ + status: { enum: ["pass", "warn", "fail"] }, + localReadiness: { enum: ["pass", "warn", "fail"] }, + packagePublication: { enum: ["pass", "fail", "skipped", "unknown"] }, + registryVersionAvailability: { + enum: [ + "available-first-publication", + "available-new-version", + "already-published", + "unknown" + ] + }, + publisherPlan: { + type: "object", + required: ["executable", "steps"], + properties: { + steps: { + type: "array", + items: { + type: "object", + required: ["order", "command", "purpose"] + } + } + } + } + }); expect(checkSchema.schema.properties.summary.properties.runtimeScorecard.properties.conformance) .toMatchObject({ type: "object", From f6f62b6ca3c09ddddc56112da8250168b4df68c5 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 20:07:33 +0300 Subject: [PATCH 13/16] fix: freeze Registry publisher plan contract --- src/core/output-contract.ts | 52 +++++++++++++++++++++++----------- tests/contract-command.test.ts | 31 +++++++++++++++++--- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/src/core/output-contract.ts b/src/core/output-contract.ts index dd8ac3d..316d2fd 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -74,27 +74,47 @@ const registryPublisherPlanSchema = { type: "object", properties: { executable: { - type: "boolean" + const: false }, steps: { type: "array", - items: { - type: "object", - properties: { - order: { - type: "integer", - minimum: 1 - }, - command: { - type: "string" + minItems: 2, + maxItems: 2, + prefixItems: [ + { + type: "object", + properties: { + order: { + const: 1 + }, + command: { + const: "mcp-publisher login github" + }, + purpose: { + type: "string" + } }, - purpose: { - type: "string" - } + required: ["order", "command", "purpose"], + additionalProperties: false }, - required: ["order", "command", "purpose"], - additionalProperties: false - } + { + type: "object", + properties: { + order: { + const: 2 + }, + command: { + const: "mcp-publisher publish" + }, + purpose: { + type: "string" + } + }, + required: ["order", "command", "purpose"], + additionalProperties: false + } + ], + items: false } }, required: ["executable", "steps"], diff --git a/tests/contract-command.test.ts b/tests/contract-command.test.ts index 8725672..39b4348 100644 --- a/tests/contract-command.test.ts +++ b/tests/contract-command.test.ts @@ -276,13 +276,36 @@ describe("doctor contract command", () => { publisherPlan: { type: "object", required: ["executable", "steps"], + additionalProperties: false, properties: { + executable: { const: false }, steps: { type: "array", - items: { - type: "object", - required: ["order", "command", "purpose"] - } + minItems: 2, + maxItems: 2, + items: false, + prefixItems: [ + { + type: "object", + additionalProperties: false, + required: ["order", "command", "purpose"], + properties: { + order: { const: 1 }, + command: { const: "mcp-publisher login github" }, + purpose: { type: "string" } + } + }, + { + type: "object", + additionalProperties: false, + required: ["order", "command", "purpose"], + properties: { + order: { const: 2 }, + command: { const: "mcp-publisher publish" }, + purpose: { type: "string" } + } + } + ] } } } From 42cae39ac2e31ed9f8c5f46b01375e6c12344212 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 20:14:16 +0300 Subject: [PATCH 14/16] test: freeze Registry publisher step order --- tests/mcp-registry-preflight.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index 2372cb4..fd602ed 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -135,10 +135,12 @@ describe("MCP Registry publication preflight", () => { executable: false, steps: [ { + order: 1, command: "mcp-publisher login github", purpose: "Authenticate with GitHub for Registry publication." }, { + order: 2, command: "mcp-publisher publish", purpose: "Publish the validated Registry metadata." } @@ -148,6 +150,12 @@ describe("MCP Registry publication preflight", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.network-unverified"); const rendered = renderMcpRegistryPublicationPreflightJson(report); + const parsedRendered = JSON.parse(rendered); + + expect(parsedRendered.publisherPlan.steps).toMatchObject([ + { order: 1, command: "mcp-publisher login github" }, + { order: 2, command: "mcp-publisher publish" } + ]); expect(rendered).not.toContain(target); expect(rendered).not.toContain("\\\\"); }); From 0fe5663f8d43fcfc82f48494459ea26566e9ca81 Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 20:28:26 +0300 Subject: [PATCH 15/16] fix: support Registry problem details --- src/core/mcp-registry-preflight.ts | 12 +++-- tests/mcp-registry-preflight.test.ts | 73 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts index 786faa7..8bada3d 100644 --- a/src/core/mcp-registry-preflight.ts +++ b/src/core/mcp-registry-preflight.ts @@ -120,10 +120,14 @@ function responseJson(response: BoundedHttpResponse): unknown | null { function validNotFound(response: BoundedHttpResponse): boolean { const payload = responseJson(response); - return response.statusCode === 404 - && isRecord(payload) - && typeof payload.error === "string" - && payload.error.trim().length > 0; + if (response.statusCode !== 404 || !isRecord(payload)) { + return false; + } + return (typeof payload.error === "string" && payload.error.trim().length > 0) + || (payload.title === "Not Found" + && payload.status === 404 + && typeof payload.detail === "string" + && payload.detail.trim().length > 0); } function hasValidHttpUrl(value: unknown): boolean { diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts index fd602ed..535f6ca 100644 --- a/tests/mcp-registry-preflight.test.ts +++ b/tests/mcp-registry-preflight.test.ts @@ -90,6 +90,14 @@ function registryResponse(name = validServer.name, version = validServer.version return response(200, { server: registryServer(name, version) }); } +function problemNotFound(detail = "Server not found") { + return response(404, { + title: "Not Found", + status: 404, + detail + }, { "content-type": "application/problem+json" }); +} + function requestSequence(...responses: Array | Error>) { return vi.fn(async () => { const next = responses.shift(); @@ -319,6 +327,40 @@ describe("MCP Registry publication preflight", () => { expect(report.registryVersionAvailability).toBe("available-new-version"); }); + it("accepts Registry problem details for an unavailable exact version", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + problemNotFound(), + registryResponse(validServer.name, "1.2.2") + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("pass"); + expect(report.registryVersionAvailability).toBe("available-new-version"); + expect(request).toHaveBeenCalledTimes(3); + }); + + it("accepts Registry problem details for an unavailable latest version without serializing the detail", async () => { + const sentinel = "registry-problem-details-sentinel"; + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + problemNotFound(sentinel) + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("pass"); + expect(report.registryVersionAvailability).toBe("available-first-publication"); + const rendered = renderMcpRegistryPublicationPreflightJson(report); + expect(rendered).not.toContain("Not Found"); + expect(rendered).not.toContain(sentinel); + expect(request).toHaveBeenCalledTimes(3); + }); + it("blocks an already published exact Registry version", async () => { const target = await writeServerJson(validServer, matchingPackageJson); const request = requestSequence(response(200, npmPackument()), registryResponse()); @@ -392,6 +434,13 @@ describe("MCP Registry publication preflight", () => { it.each([ ["a malformed exact not-found response", response(404, { error: "" })], + ["an exact problem response with the wrong title", response(404, { title: "Missing", status: 404, detail: "Server not found" })], + ["an exact problem response with the wrong status", response(404, { title: "Not Found", status: 400, detail: "Server not found" })], + ["an exact problem response with a missing detail", response(404, { title: "Not Found", status: 404 })], + ["an exact problem response with an empty detail", response(404, { title: "Not Found", status: 404, detail: "" })], + ["an exact problem response with an array detail", response(404, { title: "Not Found", status: 404, detail: ["Server not found"] })], + ["an arbitrary exact not-found object", response(404, { code: "NOT_FOUND" })], + ["an exact not-found array", response(404, [{ title: "Not Found", status: 404, detail: "Server not found" }])], ["an arbitrary exact client error", response(418, { error: "teapot" })], ["an arbitrary exact server error", response(503, { error: "unavailable" })], ["malformed exact JSON", response(200, "not json")], @@ -481,6 +530,30 @@ describe("MCP Registry publication preflight", () => { expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); }); + it.each([ + ["a problem response with the wrong title", response(404, { title: "Missing", status: 404, detail: "Server not found" })], + ["a problem response with the wrong status", response(404, { title: "Not Found", status: 400, detail: "Server not found" })], + ["a problem response with a missing detail", response(404, { title: "Not Found", status: 404 })], + ["a problem response with an empty detail", response(404, { title: "Not Found", status: 404, detail: "" })], + ["a problem response with an array detail", response(404, { title: "Not Found", status: 404, detail: ["Server not found"] })], + ["an arbitrary not-found object", response(404, { code: "NOT_FOUND" })], + ["a not-found array", response(404, [{ title: "Not Found", status: 404, detail: "Server not found" }])] + ])("keeps Registry version availability unknown for latest %s", async (_caseName, latestResponse) => { + const target = await writeServerJson(validServer, matchingPackageJson); + const request = requestSequence( + response(200, npmPackument()), + response(404, { error: "version not found" }), + latestResponse + ); + + const report = await buildMcpRegistryPublicationPreflight(target, { allowNetwork: true, request }); + + expect(report.status).toBe("fail"); + expect(report.registryVersionAvailability).toBe("unknown"); + expect(report.findings.map((finding) => finding.id)).toContain("registry.preflight.registry.latest-response"); + expect(request).toHaveBeenCalledTimes(3); + }); + it("keeps Registry version availability unknown for a mismatched latest record", async () => { const target = await writeServerJson(validServer, matchingPackageJson); const request = requestSequence( From f1c4afcc8a09431baedbc51a2eb22c840501322c Mon Sep 17 00:00:00 2001 From: Furkan Date: Thu, 30 Jul 2026 20:37:55 +0300 Subject: [PATCH 16/16] chore: release v1.55.0 --- CHANGELOG.md | 19 ++++++++++++++ README.md | 4 +-- docs/guides/github-action.md | 48 ++++++++++++++++++------------------ package-lock.json | 4 +-- package.json | 2 +- 5 files changed, 48 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab3f157..9a757e9 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.55.0] - 2026-07-30 + +### Added + +- added `registry preflight` for offline publication planning and opt-in public npm and official MCP Registry metadata verification +- added immutable Registry version availability classification for first publications, new versions, and already-published versions +- added the additive `doctor.registry.preflight.json` output contract with a fixed, non-executing publisher plan + +### Changed + +- distinguish first publication from a new immutable version through bounded exact-version and latest-version lookups +- accept both legacy Registry not-found responses and the official Registry's problem-details response without retaining response content + +### Security + +- keep preflight offline by default and require explicit `--allow-network` consent for fixed-host metadata requests +- reject ambiguous multiple npm declarations, mismatched package identity, malformed SRI metadata, and untrusted Registry response shapes +- never authenticate, publish, download tarballs, execute package scripts, or expose local paths and remote response content + ## [1.54.0] - 2026-07-28 ### Added diff --git a/README.md b/README.md index f737e75..795c96c 100644 --- a/README.md +++ b/README.md @@ -480,9 +480,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.54.0 + - uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.0" path: . runtime: "true" policy: codex-publish diff --git a/docs/guides/github-action.md b/docs/guides/github-action.md index 03a9891..6289c40 100644 --- a/docs/guides/github-action.md +++ b/docs/guides/github-action.md @@ -27,9 +27,9 @@ The Action transfers these boolean inputs through environment-backed shell varia Use local Registry metadata gating when the repository contains a `server.json` intended for publication: ```yaml -- uses: Esquetta/CodexPluginDoctor@v1.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.0" path: . registry-metadata: ./server.json require-registry-readiness: "true" @@ -53,9 +53,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - - uses: Esquetta/CodexPluginDoctor@v1.54.0 + - uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 env: CODEX_PLUGIN_DOCTOR_SIGNING_KEY: ${{ secrets.CODEX_PLUGIN_DOCTOR_SIGNING_KEY }} with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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.54.0 +- uses: Esquetta/CodexPluginDoctor@v1.55.0 with: - version: "1.54.0" + version: "1.55.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 ece8988..a64f696 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-plugin-doctor", - "version": "1.54.0", + "version": "1.55.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-plugin-doctor", - "version": "1.54.0", + "version": "1.55.0", "license": "MIT", "bin": { "codex-plugin-doctor": "dist/cli.js" diff --git a/package.json b/package.json index 611a682..8f66961 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-plugin-doctor", - "version": "1.54.0", + "version": "1.55.0", "description": "CLI-first validator for Codex plugins, skills, and MCP package surfaces with runtime MCP protocol validation.", "type": "module", "main": "./dist/index.js",