diff --git a/scripts/check_marketplace_pins.py b/scripts/check_marketplace_pins.py index ac418aa8..d5210bbe 100644 --- a/scripts/check_marketplace_pins.py +++ b/scripts/check_marketplace_pins.py @@ -119,6 +119,7 @@ from marketplace_pins_registry import ( # noqa: E402 check_registry_surface, check_registry_version, + check_server_json_schema, ) from marketplace_pins_self import FROZEN_PINS, check_self_pin # noqa: E402 from marketplace_pins_semver import ( # noqa: E402 @@ -135,6 +136,7 @@ "check_root_manifests", "check_registry_surface", "check_registry_version", + "check_server_json_schema", "check_self_pin", "latest_local_tag", "main", diff --git a/scripts/marketplace_pins_registry.py b/scripts/marketplace_pins_registry.py index 82bfc922..42901fac 100644 --- a/scripts/marketplace_pins_registry.py +++ b/scripts/marketplace_pins_registry.py @@ -146,17 +146,69 @@ def check_registry_version( ) +# source: https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json +# definitions.ServerDetail.properties.description.{minLength,maxLength} — +# fetched and read directly 2026-08-10, not copied from a report of it. +# Real incident: ai-architect-mcp-codebase attempted to publish v0.9.1 with a +# 144-char description and got a live 422 from the registry — the tag that +# carries the violation can never be published; only a NEW tag with a +# shortened description is a way out (the fix existed on `main` but not in +# the tagged tree, so it published nothing). This offline check exists to +# catch the same defect before a tag is ever cut, not after. +SERVER_JSON_DESCRIPTION_MIN_LENGTH = 1 +SERVER_JSON_DESCRIPTION_MAX_LENGTH = 100 + + +def check_server_json_schema(root: Path) -> list[str]: + """Offline, schema-derived validity checks on server.json's own fields + — distinct from check_registry_version's cross-repo VERSION check. + Catches the class of defect that produces a 422 at publish time (schema + violation), which a version-only comparison cannot see: a server.json + can have the exactly-correct version and still be unpublishable. + + Reads whatever server.json is on disk for the ref this gate is running + against (the PR branch, or `main` on push/cron) — the tree a FUTURE tag + will be cut from, which is the only tree this offline check can affect. + An already-tagged historical commit is immutable; this cannot and does + not claim to fix one, only to stop the next one from repeating it. + """ + server_json = root / "server.json" + if not server_json.is_file(): + return [] + description = json.loads(server_json.read_text()).get("description", "") + length = len(description) + if length > SERVER_JSON_DESCRIPTION_MAX_LENGTH: + return [ + f"SERVER_JSON_DESCRIPTION_TOO_LONG: server.json description is " + f"{length} chars; the MCP registry schema caps it at " + f"{SERVER_JSON_DESCRIPTION_MAX_LENGTH} — a tag cut from this " + f"tree cannot be published (422) and there is no way to publish " + f"an already-tagged violation after the fact, only a new tag" + ] + if length < SERVER_JSON_DESCRIPTION_MIN_LENGTH: + return [ + f"SERVER_JSON_DESCRIPTION_TOO_SHORT: server.json description is " + f"empty; the MCP registry schema requires at least " + f"{SERVER_JSON_DESCRIPTION_MIN_LENGTH} char" + ] + return [] + + def check_registry_surface(root: Path, primary_pin: str): """Cross-check server.json's own registry `name` against what the - public MCP registry actually serves. Returns (failures, notices). - Absent server.json or a missing `name` field is not a failure — not - every repo on this marketplace publishes to the MCP registry. + public MCP registry actually serves, AND server.json's own schema + validity (description length). Returns (failures, notices). Absent + server.json or a missing `name` field skips the version check — not + every repo on this marketplace publishes to the MCP registry — but the + schema check still runs whenever server.json exists, name or not. """ server_json = root / "server.json" if not server_json.is_file(): return [], [] + schema_failures = check_server_json_schema(root) registry_name = json.loads(server_json.read_text()).get("name", "") if not registry_name: - return [], [] + return schema_failures, [] failure, notice = check_registry_version(registry_name, primary_pin) - return ([failure] if failure else []), ([notice] if notice else []) + version_failures = [failure] if failure else [] + return schema_failures + version_failures, ([notice] if notice else []) diff --git a/tests_py/scripts/test_check_marketplace_pins_registry.py b/tests_py/scripts/test_check_marketplace_pins_registry.py index 9606b81c..9ea5639d 100644 --- a/tests_py/scripts/test_check_marketplace_pins_registry.py +++ b/tests_py/scripts/test_check_marketplace_pins_registry.py @@ -7,8 +7,11 @@ from __future__ import annotations +import json import unittest import urllib.error +from pathlib import Path +from tempfile import TemporaryDirectory from tests_py.scripts._marketplace_pins_test_loader import gate @@ -102,22 +105,89 @@ def down(_search): class TestRegistrySurface(unittest.TestCase): def test_absent_server_json_is_not_a_failure(self): - from pathlib import Path - from tempfile import TemporaryDirectory - with TemporaryDirectory() as d: self.assertEqual(gate.check_registry_surface(Path(d), "1.0.0"), ([], [])) - def test_missing_name_field_is_not_a_failure(self): - import json - from pathlib import Path - from tempfile import TemporaryDirectory - + def test_missing_name_field_skips_version_check_not_schema_check(self): with TemporaryDirectory() as d: root = Path(d) - (root / "server.json").write_text(json.dumps({"version": "1.0.0"})) + (root / "server.json").write_text( + json.dumps({"version": "1.0.0", "description": "A valid description."}) + ) self.assertEqual(gate.check_registry_surface(root, "1.0.0"), ([], [])) +class TestServerJsonSchema(unittest.TestCase): + """SERVER_JSON_DESCRIPTION_TOO_LONG/TOO_SHORT — offline, schema-derived + (maxLength=100, minLength=1, both read live from + https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json + 2026-08-10). Distinct defect class from REGISTRY_VERSION_STALE: a + server.json can have the exactly-correct version and still be + unpublishable (422) because of an unrelated schema field. + """ + + def test_absent_server_json_is_not_a_failure(self): + with TemporaryDirectory() as d: + self.assertEqual(gate.check_server_json_schema(Path(d)), []) + + def test_compliant_description_passes(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"description": "x" * 100})) + self.assertEqual(gate.check_server_json_schema(root), []) + + def test_incident_replay_144_char_description_never_publishable(self): + """The neighboring incident this check exists to close: a 422 at + publish time (ai-architect-mcp-codebase v0.9.1, 144-char + description) that could never be recovered from once tagged — the + fix landed on `main` only, not in the tagged tree. This test + proves the check fires BEFORE a tag would ever be cut. + """ + with TemporaryDirectory() as d: + root = Path(d) + description = "x" * 144 + (root / "server.json").write_text(json.dumps({"description": description})) + failures = gate.check_server_json_schema(root) + self.assertEqual(len(failures), 1) + self.assertIn("SERVER_JSON_DESCRIPTION_TOO_LONG", failures[0]) + self.assertIn("144", failures[0]) + self.assertIn("100", failures[0]) + + def test_off_by_one_boundary(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"description": "x" * 101})) + failures = gate.check_server_json_schema(root) + self.assertEqual(len(failures), 1) + self.assertIn("TOO_LONG", failures[0]) + + def test_empty_description_fails(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"description": ""})) + failures = gate.check_server_json_schema(root) + self.assertEqual(len(failures), 1) + self.assertIn("SERVER_JSON_DESCRIPTION_TOO_SHORT", failures[0]) + + def test_missing_description_key_fails(self): + with TemporaryDirectory() as d: + root = Path(d) + (root / "server.json").write_text(json.dumps({"name": "x"})) + failures = gate.check_server_json_schema(root) + self.assertEqual(len(failures), 1) + self.assertIn("SERVER_JSON_DESCRIPTION_TOO_SHORT", failures[0]) + + def test_real_current_server_json_is_compliant(self): + """Not a fixture — the actual committed server.json this PR ships. + Direct verification requested by review: fetched-at-tag v4.17.2 was + ALREADY compliant (95 chars, pre-dating this PR); this asserts the + working-tree copy this PR carries (98 chars) also is, which is what + the NEXT tag will publish. + """ + repo_root = Path(__file__).resolve().parents[2] + failures = gate.check_server_json_schema(repo_root) + self.assertEqual(failures, [], "server.json must stay registry-publishable") + + if __name__ == "__main__": unittest.main()