diff --git a/.github/actions/install-mcp-publisher/action.yml b/.github/actions/install-mcp-publisher/action.yml new file mode 100644 index 0000000..c77a314 --- /dev/null +++ b/.github/actions/install-mcp-publisher/action.yml @@ -0,0 +1,31 @@ +name: Install mcp-publisher +description: Download a pinned release of mcp-publisher from the official MCP registry repo + +inputs: + version: + description: "mcp-publisher release tag (e.g. v1.7.6). Bump when a new release is needed." + required: false + # v1.7.6 is required: the registry server in v1.7.6 binds GitHub OIDC + # token exchange to a per-deployment audience + # (registry PR #1229, deployed prod 2026-04-30). Older mcp-publisher + # versions send audience `mcp-registry`, which the new registry rejects + # with a 401: `invalid audience: expected + # https://registry.modelcontextprotocol.io, got [mcp-registry]`. v0.5.1 + # release ran with the previous v1.5.0 pin and got bitten — PyPI + # published cleanly but the registry leg failed. v1.7.6 sends the new + # audience and authenticates against the current prod deployment. + default: v1.7.6 + +runs: + using: composite + steps: + - name: Download and extract mcp-publisher + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + os=$(uname -s | tr '[:upper:]' '[:lower:]') + arch=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/download/${VERSION}/mcp-publisher_${os}_${arch}.tar.gz" \ + | tar xz mcp-publisher + ./mcp-publisher --version diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7526c4a..bcf1ae5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,3 +93,30 @@ jobs: env: MCP_CLIPBOARD_BACKEND: x11 run: xvfb-run -a uv run pytest tests/test_integration_x11.py -m integration -v + + version-sync: + # Closes #114. server.json carries two version fields (top-level and + # packages[0]) that have to match pyproject.toml. The sync script is + # the single source of truth; this gate fails PRs that bump + # pyproject.toml without re-running it. Stdlib-only, no `uv sync`. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Verify server.json matches pyproject.toml + run: python scripts/sync-server-json.py --check + + validate-server-json: + # Closes #114. Validate server.json against the MCP registry schema + # using the same pinned mcp-publisher version that release-time uses. + # Catches schema drift (new required fields, type changes) at PR time + # rather than at tag-push time, preventing the half-published failure + # mode where PyPI accepts a release but the registry leg fails. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/install-mcp-publisher + - name: Validate server.json against registry schema + run: ./mcp-publisher validate server.json diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 211cc87..bede81b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -29,8 +29,21 @@ jobs: name: dist path: dist/ - publish: - needs: build + validate-server-json: + # Closes #114. Release-time backstop in case the registry schema or + # publisher version changes between PR-merge and tag-push. ci.yml runs + # the same check on every PR. Gating publish-pypi on this prevents the + # half-publish failure mode where PyPI accepts a release (irreversible + # on a per-version basis) but the registry leg fails on schema drift. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: ./.github/actions/install-mcp-publisher + - name: Validate server.json against registry schema + run: ./mcp-publisher validate server.json + + publish-pypi: + needs: [build, validate-server-json] runs-on: ubuntu-latest environment: pypi permissions: @@ -44,3 +57,44 @@ jobs: - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + publish-registry: + # Publish (or update) the server entry in the official MCP registry. + # Runs after publish-pypi because the registry validates that the + # referenced PyPI package+version exists before accepting the entry. + needs: publish-pypi + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: ./.github/actions/install-mcp-publisher + + - name: Authenticate to MCP registry (GitHub OIDC) + run: ./mcp-publisher login github-oidc + + - name: Publish server.json to MCP registry + env: + TAG: ${{ github.ref_name }} + run: | + # Idempotent: the registry rejects re-publishing an existing + # version (anchored to upstream ErrInvalidVersion in + # internal/database/database.go). Re-running a partially-failed + # tag should treat duplicate-version as a no-op rather than crash. + # If the upstream error text changes, this falls through to + # `exit $status` and fails loudly — preferable to silently + # swallowing a real publish error. + set +e + output=$(./mcp-publisher publish 2>&1) + status=$? + echo "$output" + if [ $status -eq 0 ]; then + exit 0 + fi + if echo "$output" | grep -qF 'cannot publish duplicate version'; then + echo "::warning::Version $TAG already exists in MCP registry — treating as no-op" + exit 0 + fi + exit $status diff --git a/CHANGELOG.md b/CHANGELOG.md index d21e421..bf112c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented here. ## [Unreleased] +### Added +- Register with the MCP Server registry at + `registry.modelcontextprotocol.io`. New `server.json` (root) carries + the registry manifest; `scripts/sync-server-json.py` is the + single-source-of-truth sync from `pyproject.toml`'s `[project].version` + to `server.json`'s two version fields, with a `--check` mode that CI + uses to fail PRs that drift. New composite action + `.github/actions/install-mcp-publisher` pins `mcp-publisher` to the + v1.7.6+ audience binding (the registry rolled out a new OIDC audience + on 2026-04-30 that older `mcp-publisher` releases fail authentication + against). `ci.yml` gains `version-sync` and `validate-server-json` + jobs; `publish.yml` gains a release-time `validate-server-json` gate + (now a `needs:` of the renamed `publish-pypi` job) and a new + `publish-registry` job that runs after `publish-pypi` (the registry + validates the referenced PyPI package+version before accepting the + entry). The registry publish is idempotent — duplicate-version errors + from rerunning a partially-failed tag are treated as a no-op. (#120) - + closes #114. + ## [2.4.0] - 2026-05-05 ### Fixed diff --git a/scripts/sync-server-json.py b/scripts/sync-server-json.py new file mode 100755 index 0000000..d5f5384 --- /dev/null +++ b/scripts/sync-server-json.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Sync server.json version fields from pyproject.toml. + +`pyproject.toml` is the single source of truth for the project version. +`server.json` (used by the MCP Registry and Glama.ai) carries two +independent version fields that have to match — the top-level `version` +and `packages[0].version`. Before this script, those were maintained by +hand and drifted in lockstep with releases. + +Usage: + python scripts/sync-server-json.py # rewrite server.json in place + python scripts/sync-server-json.py --check # exit 1 if drift, 0 otherwise + +CI calls --check to fail PRs that would ship a version mismatch. Release +flow calls the script without --check after bumping pyproject.toml. + +Stdlib only — runs without `uv sync`, so CI can use it before any +project install. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = REPO_ROOT / "pyproject.toml" +SERVER_JSON = REPO_ROOT / "server.json" + +ServerJson = dict[str, Any] + + +def read_pyproject_version(path: Path = PYPROJECT) -> str: + try: + with path.open("rb") as f: + data = tomllib.load(f) + except FileNotFoundError: + raise SystemExit(f"error: {path} not found") from None + except tomllib.TOMLDecodeError as exc: + raise SystemExit(f"error: {path} is not valid TOML: {exc}") from None + project = data.get("project") + if not isinstance(project, dict): + raise SystemExit(f"error: [project] section missing from {path}") + version = project.get("version") + if not isinstance(version, str): + raise SystemExit(f"error: [project].version not found or not a string in {path}") + return version + + +def load_server_json(path: Path = SERVER_JSON) -> ServerJson: + try: + with path.open("r", encoding="utf-8") as f: + data: Any = json.load(f) + except FileNotFoundError: + raise SystemExit(f"error: {path} not found") from None + except json.JSONDecodeError as exc: + raise SystemExit(f"error: {path} is not valid JSON: {exc}") from None + if not isinstance(data, dict): + raise SystemExit(f"error: {path} top-level value must be a JSON object") + return data + + +def collect_versions(server: ServerJson) -> dict[str, str]: + """Return the version fields server.json currently advertises.""" + out: dict[str, str] = {"top_level": str(server.get("version", ""))} + packages = server.get("packages") or [] + if isinstance(packages, list): + for i, pkg in enumerate(packages): + if isinstance(pkg, dict): + out[f"packages[{i}]"] = str(pkg.get("version", "")) + return out + + +def apply_version(server: ServerJson, version: str) -> ServerJson: + """Return a new dict with all version fields set to `version`.""" + updated: ServerJson = dict(server) + updated["version"] = version + packages = updated.get("packages") + if isinstance(packages, list): + updated["packages"] = [ + {**pkg, "version": version} if isinstance(pkg, dict) else pkg for pkg in packages + ] + return updated + + +def serialize(server: ServerJson) -> str: + # Match the existing 2-space indent and trailing newline so diffs stay + # quiet when only the version changed. + return json.dumps(server, indent=2, ensure_ascii=False) + "\n" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero if server.json is out of sync. Do not modify files.", + ) + args = parser.parse_args() + + pyproject_version = read_pyproject_version(PYPROJECT) + server = load_server_json(SERVER_JSON) + current = collect_versions(server) + drifted = {field: v for field, v in current.items() if v != pyproject_version} + + if args.check: + if drifted: + print( + f"server.json version drift detected. pyproject.toml = {pyproject_version}", + file=sys.stderr, + ) + for field, value in drifted.items(): + print(f" {field}: {value!r}", file=sys.stderr) + print( + "Run `python scripts/sync-server-json.py` to fix.", + file=sys.stderr, + ) + return 1 + print(f"server.json in sync with pyproject.toml ({pyproject_version})") + return 0 + + if not drifted: + print(f"server.json already in sync ({pyproject_version}) — no changes") + return 0 + + updated = apply_version(server, pyproject_version) + SERVER_JSON.write_text(serialize(updated), encoding="utf-8") + try: + display_path = SERVER_JSON.relative_to(REPO_ROOT) + except ValueError: + display_path = SERVER_JSON + print(f"Updated {display_path} to {pyproject_version}") + for field, old in drifted.items(): + print(f" {field}: {old!r} -> {pyproject_version!r}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server.json b/server.json new file mode 100644 index 0000000..26921ba --- /dev/null +++ b/server.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.cmeans/mcp-clipboard", + "title": "Clipboard", + "description": "Read and write the system clipboard — tables, text, code, JSON, URLs, images, and more.", + "version": "2.4.0", + "repository": { + "url": "https://github.com/cmeans/mcp-clipboard", + "source": "github" + }, + "packages": [ + { + "registryType": "pypi", + "identifier": "mcp-clipboard", + "version": "2.4.0", + "transport": { + "type": "stdio" + } + } + ] +}