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 8078f4e..795c96c 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 @@ -454,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/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..4b9f511 --- /dev/null +++ b/docs/architecture/mcp-registry-publication-preflight.md @@ -0,0 +1,207 @@ +# 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 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 +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, 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. 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 +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. 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 + +`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 +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: + executable: false + steps: ordered non-executing steps with order, command, and purpose +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. 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. + +## Verification Strategy + +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 +- 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 +- 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 +- replace post-publication inspection +- add GitHub Action inputs in the first release 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/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", diff --git a/src/core/mcp-registry-preflight.ts b/src/core/mcp-registry-preflight.ts new file mode 100644 index 0000000..8bada3d --- /dev/null +++ b/src/core/mcp-registry-preflight.ts @@ -0,0 +1,468 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { + buildMcpRegistryReadiness, + type McpRegistryFinding +} from "./mcp-registry.js"; +import { + requestBoundedHttp, + type BoundedHttpRequestOptions, + type BoundedHttpResponse +} from "./bounded-http-client.js"; + +export type McpRegistryPublicationPreflightStatus = "pass" | "warn" | "fail"; +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; +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 = { + method: "GET", + headers: { + accept: "application/json", + "user-agent": "codex-plugin-doctor" + } +}; + +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: McpRegistryPublicationPreflightStatus; + localReadiness: McpRegistryPublicationPreflightStatus; + packagePublication: "pass" | "fail" | "skipped" | "unknown"; + registryVersionAvailability: "available-first-publication" | "available-new-version" | "already-published" | "unknown"; + publisherPlan: { + executable: false; + steps: Array<{ + order: number; + 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[]): McpRegistryPublicationPreflightStatus { + if (findings.some((finding) => finding.severity === "fail")) { + return "fail"; + } + 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); +} + +function isSafeNpmIdentifier(value: unknown): value is string { + return typeof value === "string" && NPM_IDENTIFIER_PATTERN.test(value); +} + +function isIntegrity(value: unknown): value is string { + 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 { + try { + return JSON.parse(response.body.toString("utf8")); + } catch { + return null; + } +} + +function validNotFound(response: BoundedHttpResponse): boolean { + const payload = responseJson(response); + 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 { + 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, + serverVersion?: string +): boolean { + const payload = responseJson(response); + if (response.statusCode !== 200 || !isRecord(payload) || !isRecord(payload.server)) { + return false; + } + + const server = payload.server; + return hasValidRegistryServerShape(server) + && server.name === serverName + && (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 { + 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: [ + { + 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." + } + ] + }; +} + +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." + }); + } + + 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); + let packagePublication: McpRegistryPublicationPreflightReport["packagePublication"] = declarations.length === 0 + ? "skipped" + : localReadiness === "fail" ? "fail" : "unknown"; + 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 (!canVerifyNetwork && localReadiness !== "fail") { + findings.push({ + id: "registry.preflight.network-unverified", + severity: "warn", + message: "Package publication and Registry version availability require explicit network verification." + }); + } + + 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: McpRegistryPublicationPreflightStatus, + packagePublication: McpRegistryPublicationPreflightReport["packagePublication"], + registryVersionAvailability: McpRegistryPublicationPreflightReport["registryVersionAvailability"], + findings: McpRegistryPublicationPreflightFinding[] +): McpRegistryPublicationPreflightReport { + return { + schemaVersion: "1.0.0", + kind: "mcp-registry-publication-preflight", + generatedAt: new Date().toISOString(), + target: "server.json", + ...(isSafeServerName(readiness.serverName) ? { serverName: readiness.serverName } : {}), + ...(isSafeServerVersion(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); +} + +export function renderMcpRegistryPublicationPreflight( + report: McpRegistryPublicationPreflightReport +): string { + const networkVerification = report.findings.some((finding) => finding.id === "registry.preflight.network-unverified") + ? "NOT REQUESTED" + : report.packagePublication === "pass" && report.registryVersionAvailability !== "unknown" + ? "COMPLETED" + : report.localReadiness === "fail" + ? "NOT AVAILABLE" + : "INCOMPLETE"; + 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/core/output-contract.ts b/src/core/output-contract.ts index 7cc90bc..316d2fd 100644 --- a/src/core/output-contract.ts +++ b/src/core/output-contract.ts @@ -55,6 +55,72 @@ 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: { + const: false + }, + steps: { + type: "array", + minItems: 2, + maxItems: 2, + prefixItems: [ + { + type: "object", + properties: { + order: { + const: 1 + }, + command: { + const: "mcp-publisher login github" + }, + purpose: { + type: "string" + } + }, + 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"], + additionalProperties: false +}; + const runtimeConformanceSchema = { type: "object", properties: { @@ -254,6 +320,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/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..3bc86fa 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 }); @@ -3360,7 +3388,11 @@ 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}`); + 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/contract-command.test.ts b/tests/contract-command.test.ts index 4ec22ba..39b4348 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,67 @@ 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"], + additionalProperties: false, + properties: { + executable: { const: false }, + steps: { + type: "array", + 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" } + } + } + ] + } + } + } + }); expect(checkSchema.schema.properties.summary.properties.runtimeScorecard.properties.conformance) .toMatchObject({ type: "object", diff --git a/tests/mcp-registry-preflight.test.ts b/tests/mcp-registry-preflight.test.ts new file mode 100644 index 0000000..535f6ca --- /dev/null +++ b/tests/mcp-registry-preflight.test.ts @@ -0,0 +1,747 @@ +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, + renderMcpRegistryPublicationPreflight, + 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" +}; +const validSha512Integrity = `sha512-${"A".repeat(86)}==`; + +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: 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, + name, + version + }; +} + +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(); + 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); + + 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: [ + { + 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." + } + ] + } + }); + 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("\\\\"); + }); + + it("collects matching public npm metadata and first-publication Registry evidence with fixed bounded requests", async () => { + const target = await writeServerJson(validServer, matchingPackageJson); + 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(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: validSha512Integrity } + } + } })], + ["mismatched published package version", npmPackument({ versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: "1.2.4", + mcpName: validServer.name, + dist: { integrity: validSha512Integrity } + } + } })], + ["mismatched published mcpName", npmPackument({ versions: { + [validServer.version]: { + name: matchingPackageJson.name, + version: validServer.version, + mcpName: "io.github.example/other", + dist: { integrity: validSha512Integrity } + } + } })], + ["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([ + ["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" })); + + 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("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()); + + 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("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 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")], + ["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 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(renderMcpRegistryPublicationPreflight(report)).toContain("Network verification: INCOMPLETE"); + 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 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.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( + 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 () => { + const target = await writeServerJson(validServer, { + ...matchingPackageJson, + name: "@example/other-mcp" + }); + + 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("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, + packages: [ + ...validServer.packages, + { + registryType: "npm", + identifier: "@example/weather-cli", + version: "1.2.3", + transport: { type: "stdio" } + } + ] + }, matchingPackageJson); + + 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 () => { + 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 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 () => { + 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 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); + }); +}); diff --git a/tests/registry-command.test.ts b/tests/registry-command.test.ts index b149b98..7f5bb2a 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,159 @@ 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("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"); + 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("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 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(); + + expect(await runCli([ + "registry", "preflight", target, "--output", outputPath + ], result.io)).toBe(1); + 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); + 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); + }); });