Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 49 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -115,19 +115,60 @@ jobs:
run: uv build
- name: Verify distribution metadata and compatibility entry points
run: uv run --no-sync python -m scripts.check_distribution_artifact
# Was pinned independently of Release.yaml's copy at v1.7.9 (Release.yaml
# had already moved to v1.8.1 with a different checksum — two pins of
# the same binary, already drifted apart). Now installed by the shared
# composite action, whose defaults are the only place the version+
# checksum pin lives — a future bump is one edit to that file, not two.
# v1.8.1 confirmed as the latest release via the registry's GitHub
# Releases API on 2026-08-10; both workflows are aligned to it.
# Was "download mcp-publisher and POST server.json to the live
# registry.modelcontextprotocol.io/v0/validate" (`mcp-publisher
# validate`), as a REQUIRED check. That made a required check depend
# on a third party being reachable from this runner with no retry
# budget: on PR #139 both `test` jobs failed with `dial tcp ...: i/o
# timeout` while 1335 tests passed, and a bare re-run went green.
#
# Reading the registry's own source (tag v1.8.1) shows /v0/validate
# runs `ValidateServerJSON(server, ValidationAll)`: full JSON Schema
# (draft-07) structural validation, against a schema the registry
# binary embeds at build time (`//go:embed schemas/*.json`) rather
# than fetches per-request -- so that half of the check is a pure
# function of server.json + a static schema document, reproducible
# offline with any spec-conformant draft-07 validator. This script
# does exactly that against a vendored copy of the same schema file
# (scripts/schemas/2025-12-11.json) and fails loudly if server.json
# ever moves to a schema version that hasn't been re-vendored.
#
# What is NOT replicated: ValidateServerJSON's Go-only semantic
# checks (version-is-not-a-range/"latest", per-source repository URL
# rules, https-only website/icon URLs, package argument and
# transport-templating rules) have no equivalent expressible in the
# JSON Schema document itself. Those are not silently dropped from
# the pipeline -- the registry's own /v0/publish handler runs the
# same semantic checks authoritatively, over the network, at release
# time (Release.yaml's publish-registry job), and fails the release
# loudly if violated. See scripts/validate_server_manifest.py's
# module docstring for the full source-backed breakdown.
- name: Validate MCP Registry manifest (offline schema check)
run: uv run --no-sync python -m scripts.validate_server_manifest
# Informational only (`continue-on-error`) — the Go-only semantic
# checks the offline step above cannot cover (see its comment). This
# is what used to be the required, flaky step; kept here non-blocking
# so a third-party outage never fails this job again, while a
# reachable registry still surfaces a real semantic problem in the PR
# checks list instead of only at release time. The offline step above
# is the gate; this is early warning, not a second gate.
- name: Install mcp-publisher (checksum-verified)
uses: ./.github/actions/install-mcp-publisher
with:
destination: /tmp
- name: Validate official MCP Registry manifest
- name: Validate MCP Registry manifest (live semantic pre-flight, non-blocking)
continue-on-error: true
run: /tmp/mcp-publisher validate
# A copied file drifts silently -- documenting its provenance
# (scripts/schemas/README.md) is the minimum, not a guarantee.
# scripts/schemas/2025-12-11.json's own $id IS the canonical URL, so
# this fetches it and compares structurally. Non-blocking for the
# same reason as the step above: useful signal when the network is
# reachable, never a required-check failure when it isn't -- the
# offline schema check earlier in this job is the only gate.
- name: Check vendored MCP Registry schema for drift (non-blocking)
continue-on-error: true
run: uv run --no-sync python -m scripts.check_vendored_schema_drift

js-test:
# Required job: the browser UI (ui/, ~25.5k lines) is the product's primary
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ dev = [
# nothing. Bumping it is a deliberate edit with the new findings fixed
# in the same PR — the same contract dependabot already applies to npm.
"ruff>=0.15.0,<0.17.0",
"jsonschema>=4.23,<5",
]

[tool.hatch.build.targets.wheel]
Expand Down
73 changes: 73 additions & 0 deletions scripts/check_vendored_schema_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Detect drift between the vendored MCP Registry schema and its live source.

A copied file drifts silently -- provenance in scripts/schemas/README.md is
the minimum, not a guarantee. This is the automatable check for it:
scripts/schemas/2025-12-11.json's own `$id` field IS the canonical URL
(https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json
-- the same one server.json's `$schema` field points to, the same one a
spec-conformant client would fetch). Fetch it and compare, structurally
(parsed JSON equality, not byte-for-byte, so whitespace/key-order changes
upstream don't produce false alarms) against the vendored copy.

Non-blocking by design (`continue-on-error` at the call site in ci.yml):
this makes the same trade the live semantic pre-flight step makes --
useful signal when the network is reachable, never a required-check
failure when it briefly isn't. Structural validation
(scripts/validate_server_manifest.py) is the actual gate and never depends
on this succeeding.

Real drift was found while building this check (2026-08-10, manual curl +
diff against the three candidate sources -- registry repo tag v1.8.1,
modelcontextprotocol/static main, and the CDN): the vendored copy, first
sourced from the registry repo's v1.8.1 git tag, carried a `maxLength:
255` constraint on `Package.version` that the CDN's currently-served
document does not have. The registry repo's own `sync-schema.yml` is
`workflow_dispatch`-only ("TODO: Add daily schedule later"), so its
embedded copy can silently lag the canonical modelcontextprotocol/static
source it is meant to mirror. The vendored file here was re-pulled from
the CDN itself (the ground truth per the paragraph above, and the URL
this script fetches), not from the registry repo tag, to fix it.
"""

from __future__ import annotations

import json
import sys
import urllib.error
import urllib.request
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SCHEMA_FILE = ROOT / "scripts" / "schemas" / "2025-12-11.json"
TIMEOUT_SECONDS = 15


def main() -> None:
vendored = json.loads(SCHEMA_FILE.read_text())
source_url = vendored["$id"]

try:
with urllib.request.urlopen(source_url, timeout=TIMEOUT_SECONDS) as resp: # noqa: S310
live = json.loads(resp.read())
except (urllib.error.URLError, TimeoutError) as exc:
# Non-blocking by design (see module docstring) -- the caller sets
# continue-on-error, but exit nonzero anyway so the step is visibly
# red rather than silently green on an unreachable network.
print(f"could not reach {source_url}: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

if vendored == live:
print(f"{SCHEMA_FILE} matches {source_url}")
return

print(
f"{SCHEMA_FILE} has DRIFTED from {source_url} — re-vendor per "
"scripts/schemas/README.md.",
file=sys.stderr,
)
raise SystemExit(1)


if __name__ == "__main__":
main()
Loading