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
31 changes: 31 additions & 0 deletions .github/actions/install-mcp-publisher/action.yml
Original file line number Diff line number Diff line change
@@ -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
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
58 changes: 56 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions scripts/sync-server-json.py
Original file line number Diff line number Diff line change
@@ -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())
21 changes: 21 additions & 0 deletions server.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}