From 62b05912a3628e42604f8af3627dc88c416d391c Mon Sep 17 00:00:00 2001 From: Siarhei Dudko Date: Sun, 25 Oct 2026 14:58:28 +0400 Subject: [PATCH] ci: publish server.json to the MCP Registry from CI, check version bumps - publish-mcp.yml: on a push to main that changes server.json, by hand, or on a v tag that must equal server.json. server.json's version is the one published. Already published with the same content -> no-op; same version with different content -> fails with "bump version". DNS auth for the domain derived from the namespace; MCP_PRIVATE_KEY (org secret) accepted as PEM or hex seed and checked against the apex TXT record before login. - validate-server-json.yml: every PR - schema, comparison with the registry, npm release + mcpName for npm-backed servers, and a plugin.json version bump when the plugin changed. Weekly run fails while npm is ahead of server.json. - .github/scripts/: the checks, identical across all MCP setup repos. - .claude/CLAUDE.md: when and how to bump each version, how publishing and the registry key work. Under .claude/ because a CLAUDE.md at the plugin root draws a `claude plugin validate` warning and is never loaded from the plugin. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014kBzcE2sPohiNhWGtyAJ1w --- .claude/CLAUDE.md | 71 ++++++++++++ .github/scripts/plugin-version-check.sh | 48 ++++++++ .github/scripts/registry-status.sh | 109 ++++++++++++++++++ .github/workflows/publish-mcp.yml | 128 +++++++++++++++++++++ .github/workflows/validate-server-json.yml | 52 +++++++++ .gitignore | 4 + 6 files changed, 412 insertions(+) create mode 100644 .claude/CLAUDE.md create mode 100755 .github/scripts/plugin-version-check.sh create mode 100755 .github/scripts/registry-status.sh create mode 100644 .github/workflows/publish-mcp.yml create mode 100644 .github/workflows/validate-server-json.yml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..3070495 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,71 @@ +# Vectorize — MCP setup + +Public repo. Nothing here runs on a server; it is what the registry, the plugin +marketplace and people read: + +| File | Read by | A change reaches them when | +|---|---|---| +| `server.json` | the MCP Registry, as `dev.dudko/vectorize` | CI publishes it on merge to `main` — and only under a **new** `version` | +| `.claude-plugin/plugin.json` | Claude Code users of the `vectorize` plugin | its `version` changes: an installed plugin is pinned to it | +| `.claude-plugin/marketplace.json`, `README.md` | the marketplace listing, people | right away | + +## Bump versions in the same change — always + +Before finishing any change in this repo, check both: + +1. **Touched `server.json`?** Bump its `"version"`. Registry versions are + immutable: new content under an already-published version fails CI + (`cannot publish duplicate version`), and nothing reaches the registry until + the version moves. + The version is ours to choose; it is not the version of the service behind + `https://vectorize.dudko.dev/mcp`. +2. **Touched the plugin?** That is anything in `.claude-plugin/plugin.json` + besides `version`, or `.mcp.json`, `skills/`, `commands/`, `agents/`, + `hooks/`. Bump `"version"` in `plugin.json`, or users who already installed + the plugin keep the old one. + +The two versions are independent — bump each one whose file changed. Don't add a +`version` to the `marketplace.json` entry: `plugin.json` overrides it anyway. +Semver: patch for wording and metadata, minor for a new capability or field, +major for anything breaking (URL, transport, auth). README-only and CI-only +changes need no bump. + +## How publishing works + +- `.github/workflows/publish-mcp.yml` runs on a push to `main` that changes + `server.json`, on a `v` tag (must equal `server.json`'s version), and + by hand (Actions → Publish to MCP Registry → Run workflow). It publishes only a + version the registry doesn't have; on an already-published, identical version + it logs in and stops — so a manual run is also the health check for the key. +- `.github/workflows/validate-server-json.yml` runs on every PR: schema, the + comparison with what the registry serves, and the plugin version. + A weekly run repeats the registry check. +- The logic is in `.github/scripts/`. The same workflow and script files are in + every MCP setup repo (miniaccountant, dudko-dev, ireceipt-pro, nacre-work) — + change them everywhere together. +- Don't publish from a laptop with `mcp-publisher publish`; CI is the one path. + +## Registry auth + +The namespace `dev.dudko` is proven by DNS for `dudko.dev` (GitHub OIDC only grants +`io.github.*`): + +- TXT record on the **apex** `dudko.dev` — not `_mcp-auth.dudko.dev`, the + registry doesn't look there: `v=MCPv1; k=ed25519; p=` +- the private half: organization secret `MCP_PRIVATE_KEY` of `dudko-dev` (PEM or + hex seed), with this repository granted access to it. Every repo under the + `dev.dudko` namespace uses the same key. +- the publish job prints the public key it derived from the secret and checks it + against the TXT record, so a mismatch is reported as such. +- never commit `key.pem` or registry tokens; `.gitignore` covers them. + +Rotating the key: + +```sh +openssl genpkey -algorithm Ed25519 -out key.pem +echo "v=MCPv1; k=ed25519; p=$(openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64)" +# 1. add that as a second TXT record on dudko.dev +# 2. put the contents of key.pem into the org secret MCP_PRIVATE_KEY, rm key.pem +# 3. Actions → Publish to MCP Registry → Run workflow; green = the new key works +# 4. delete the old TXT record +``` diff --git a/.github/scripts/plugin-version-check.sh b/.github/scripts/plugin-version-check.sh new file mode 100755 index 0000000..4b0b596 --- /dev/null +++ b/.github/scripts/plugin-version-check.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Fails a pull request that changes what Claude Code loads from this plugin but +# leaves "version" in .claude-plugin/plugin.json as it was. Claude Code pins an +# installed plugin to its version, so without a bump existing users never get +# the change. +# +# .github/scripts/plugin-version-check.sh e.g. origin/main +# +# What counts as a plugin change: anything in plugin.json other than "version", +# and the component files Claude Code reads (.mcp.json, .lsp.json, settings.json, +# skills/, commands/, agents/, hooks/, output-styles/, monitors/, workflows/, +# themes/, bin/). README, server.json, marketplace.json, icon and CI do not count. +set -euo pipefail + +base=${1:?usage: plugin-version-check.sh } +manifest=.claude-plugin/plugin.json +[ -f "$manifest" ] || { echo "no $manifest - nothing to check"; exit 0; } + +mb=$(git merge-base "$base" HEAD) +if ! git cat-file -e "$mb:$manifest" 2>/dev/null; then + echo "$manifest is new in this branch - nothing to compare against" + exit 0 +fi + +old_version=$(git show "$mb:$manifest" | jq -r '.version // empty') +new_version=$(jq -r '.version // empty' "$manifest") + +changed=() +if ! diff -q <(git show "$mb:$manifest" | jq -S 'del(.version)') <(jq -S 'del(.version)' "$manifest") >/dev/null; then + changed+=("$manifest") +fi +while IFS= read -r f; do + [ -n "$f" ] && changed+=("$f") +done < <(git diff --name-only "$mb" HEAD -- \ + .mcp.json .lsp.json settings.json \ + skills commands agents hooks output-styles monitors workflows themes bin) + +if [ ${#changed[@]} -eq 0 ]; then + echo "plugin unchanged (version $new_version)" + exit 0 +fi + +if [ "$old_version" = "$new_version" ]; then + echo "::error file=$manifest::The plugin changed but \"version\" is still $new_version. Bump it, or users who already installed the plugin will not receive this change. Changed: ${changed[*]}" + exit 1 +fi + +echo "plugin changed (${changed[*]}) and version moved $old_version -> $new_version" diff --git a/.github/scripts/registry-status.sh b/.github/scripts/registry-status.sh new file mode 100755 index 0000000..f5aa2b8 --- /dev/null +++ b/.github/scripts/registry-status.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# Compares server.json with what the MCP Registry already serves, so CI can tell +# "new version, publish it" from "already published" from "you forgot to bump". +# +# .github/scripts/registry-status.sh [path/to/server.json] +# +# Prints key=value lines and, under GitHub Actions, appends them to $GITHUB_OUTPUT: +# name server name, e.g. dev.dudko/tabellum +# version server.json "version" +# domain the domain that owns the namespace (dev.dudko -> dudko.dev); DNS auth +# is done against its apex TXT record +# latest latest version the registry serves for this name ("" if none) +# state new - the registry has no such version: publishing will create it +# published - the registry serves this exact server.json already: no-op +# conflict - same version, different content: the version was not bumped +# +# For npm packages it also checks that packages[].version exists on npm and that +# its package.json carries "mcpName" equal to the server name (the registry refuses +# the publish otherwise), and reports when npm has a newer release than server.json. +# +# Exit status: 0 = fine, 1 = something to fix (conflict, version lower than the +# latest published one, npm release missing or without mcpName, or - with +# DRIFT_IS_ERROR=true - npm ahead of server.json), 2 = registry/npm unreachable. +set -euo pipefail + +file=${1:-server.json} +registry=${MCP_REGISTRY_URL:-https://registry.modelcontextprotocol.io} +npm_registry=${NPM_REGISTRY_URL:-https://registry.npmjs.org} +drift_is_error=${DRIFT_IS_ERROR:-false} + +err() { echo "::error file=$file::$*"; failed=1; } +warn() { echo "::warning file=$file::$*"; } +uri() { jq -rn --arg s "$1" '$s|@uri'; } +# true when $1 is a strictly higher version than $2 +newer() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" ]; } + +failed=0 +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +name=$(jq -er .name "$file") +version=$(jq -er .version "$file") +namespace=${name%%/*} +domain=$(tr . '\n' <<<"$namespace" | tac | paste -sd. -) +enc=$(uri "$name") + +# --- the registry ------------------------------------------------------------- +code=$(curl -sS -o "$tmp/latest.json" -w '%{http_code}' "$registry/v0/servers/$enc/versions/latest") || exit 2 +case $code in + 200) latest=$(jq -r .server.version "$tmp/latest.json") ;; + 404) latest="" ;; + *) echo "::error::$registry answered $code for $name"; exit 2 ;; +esac + +code=$(curl -sS -o "$tmp/this.json" -w '%{http_code}' "$registry/v0/servers/$enc/versions/$(uri "$version")") || exit 2 +case $code in + 200) + if diff <(jq -S .server "$tmp/this.json") <(jq -S . "$file") >"$tmp/diff"; then + state=published + echo "::notice file=$file::$name@$version is already in the registry with exactly this content - nothing to publish." + else + state=conflict + err "$name@$version is already in the registry with different content. Registry versions are immutable: bump \"version\" in server.json (latest published: ${latest:-none})." + echo "--- registry +++ server.json"; cat "$tmp/diff" + fi ;; + 404) + state=new + if [ -n "$latest" ] && newer "$latest" "$version"; then + err "version $version is lower than $latest, the latest one in the registry. Pick a version above $latest." + else + echo "::notice file=$file::$name@$version is not in the registry yet (latest: ${latest:-none}) - a publish will create it." + fi ;; + *) echo "::error::$registry answered $code for $name@$version"; exit 2 ;; +esac + +# --- npm packages ------------------------------------------------------------- +count=$(jq '[.packages[]? | select(.registryType == "npm")] | length' "$file") +for ((i = 0; i < count; i++)); do + id=$(jq -r "[.packages[] | select(.registryType == \"npm\")][$i].identifier" "$file") + pv=$(jq -r "[.packages[] | select(.registryType == \"npm\")][$i].version" "$file") + code=$(curl -sS -o "$tmp/npm.json" -w '%{http_code}' "$npm_registry/$(uri "$id")") || exit 2 + [ "$code" = 200 ] || { echo "::error::$npm_registry answered $code for $id"; exit 2; } + npm_latest=$(jq -r '."dist-tags".latest // empty' "$tmp/npm.json") + if ! jq -e --arg v "$pv" '.versions[$v]' "$tmp/npm.json" >/dev/null; then + err "npm has no $id@$pv. Release the package first, then point server.json at it (npm latest: ${npm_latest:-none})." + continue + fi + mcp_name=$(jq -r --arg v "$pv" '.versions[$v].mcpName // empty' "$tmp/npm.json") + if [ "$mcp_name" != "$name" ]; then + if [ -n "$mcp_name" ]; then has="mcpName \"$mcp_name\""; else has="no mcpName"; fi + err "$id@$pv has $has in its package.json; the registry requires \"mcpName\": \"$name\". Release a version that carries it and point server.json at that." + fi + if [ "$pv" != "$version" ]; then + warn "server.json version $version differs from the npm package version $pv. Keep them equal: the server version is the package release." + fi + if [ -n "$npm_latest" ] && newer "$npm_latest" "$pv"; then + msg="npm has $id@$npm_latest but server.json still points at $pv. Set both \"version\" and packages[].version to $npm_latest so the registry lists the current release." + if [ "$drift_is_error" = true ]; then err "$msg"; else warn "$msg"; fi + fi +done + +out="name=$name +version=$version +domain=$domain +latest=$latest +state=$state" +echo "$out" +[ -n "${GITHUB_OUTPUT:-}" ] && echo "$out" >>"$GITHUB_OUTPUT" +exit "$failed" diff --git a/.github/workflows/publish-mcp.yml b/.github/workflows/publish-mcp.yml new file mode 100644 index 0000000..5dd7e33 --- /dev/null +++ b/.github/workflows/publish-mcp.yml @@ -0,0 +1,128 @@ +name: Publish to MCP Registry + +# The registry does NOT read server.json from this repository. Merging a change to +# that file changes the file and nothing else: the entry at +# registry.modelcontextprotocol.io keeps whatever was last sent with +# `mcp-publisher publish`. This workflow is that send, and the only one we use. +# +# The version published is the "version" in server.json - the file is the single +# source of truth. Registry versions are immutable, so: +# * same version, same content -> nothing to publish, the run succeeds +# * same version, different content -> the run fails: bump "version" +# * a version not in the registry -> published +# +# When it runs: +# * a push to main that changes server.json (i.e. a merged PR that bumped it) +# * Actions -> Publish to MCP Registry -> Run workflow. Safe to re-run any time: +# it always logs in (so it doubles as a check of the key and DNS record) and +# publishes only a version the registry does not have yet +# * a tag v; it must equal server.json's version or the run fails +# +# Auth is the DNS method for the domain that owns the namespace in server.json +# (dev.dudko/... -> dudko.dev, app.miniaccountant/... -> miniaccountant.app). +# Reverse-DNS namespaces can only be claimed this way; GitHub OIDC grants +# io.github.* only. One-time setup per domain: +# 1. openssl genpkey -algorithm Ed25519 -out key.pem +# 2. a TXT record on the APEX of the domain (not under _mcp-auth - the registry +# does not look there): +# v=MCPv1; k=ed25519; p=$(openssl pkey -in key.pem -pubout -outform DER | tail -c 32 | base64) +# 3. the private half as the organization secret MCP_PRIVATE_KEY, with this +# repository given access to it. Either form works: the PEM file as is, or the +# hex seed mcp-publisher takes: +# openssl pkey -in key.pem -outform DER | tail -c 32 | od -An -tx1 | tr -d ' \n' +# then delete key.pem; never commit it. + +on: + push: + branches: ["main"] + tags: ["v*"] + paths: ["server.json"] # tag pushes ignore path filters + workflow_dispatch: + +concurrency: + group: publish-mcp-registry + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v5 + + - name: Tag must equal the version in server.json + if: startsWith(github.ref, 'refs/tags/') + run: | + version=$(jq -r .version server.json) + if [ "${GITHUB_REF_NAME#v}" != "$version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match server.json version $version. Bump server.json in a PR; the merge publishes it." + exit 1 + fi + + - name: Install mcp-publisher + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Validate server.json + run: ./mcp-publisher validate + + - name: Compare with the registry and npm + id: status + run: bash .github/scripts/registry-status.sh + + - name: Log in to the registry (DNS, ${{ steps.status.outputs.domain }}) + env: + MCP_PRIVATE_KEY: ${{ secrets.MCP_PRIVATE_KEY }} + DOMAIN: ${{ steps.status.outputs.domain }} + run: | + if [ -z "${MCP_PRIVATE_KEY//[[:space:]]/}" ]; then + echo "::error::MCP_PRIVATE_KEY is empty in this repository. It is an organization secret: Organization settings -> Secrets and variables -> Actions -> MCP_PRIVATE_KEY -> Repository access must include ${GITHUB_REPOSITORY}." + exit 1 + fi + + # Accept the PEM file or the bare hex seed; mcp-publisher wants the hex seed. + if grep -q -- '-----BEGIN' <<<"$MCP_PRIVATE_KEY"; then + umask 077 + printf '%s\n' "$MCP_PRIVATE_KEY" >"$RUNNER_TEMP/key.pem" + hex=$(openssl pkey -in "$RUNNER_TEMP/key.pem" -outform DER | tail -c 32 | od -An -tx1 | tr -d ' \n') + rm -f "$RUNNER_TEMP/key.pem" + else + hex=$(tr -d '[:space:]:' <<<"$MCP_PRIVATE_KEY" | tr '[:upper:]' '[:lower:]') + fi + echo "::add-mask::$hex" + if ! [[ $hex =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::MCP_PRIVATE_KEY is neither an Ed25519 PEM key nor a 64-character hex seed." + exit 1 + fi + + # The public half must be in a v=MCPv1 TXT record on the apex of the domain. + pub=$(python3 -c 'import sys; sys.stdout.buffer.write(bytes.fromhex(sys.argv[1]))' "302e020100300506032b657004220420$hex" \ + | openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64) + echo "public key from MCP_PRIVATE_KEY: $pub" + if records=$(dig +short TXT "$DOMAIN" 2>/dev/null) && grep -q 'v=MCPv1' <<<"$records"; then + if ! tr -d '"' <<<"$records" | grep -qF "p=$pub"; then + echo "::error::No TXT record on $DOMAIN matches MCP_PRIVATE_KEY. Expected: v=MCPv1; k=ed25519; p=$pub" + echo "Records found:"; grep 'v=MCPv1' <<<"$records" + exit 1 + fi + echo "TXT record on $DOMAIN matches" + else + echo "::warning::Could not see a v=MCPv1 TXT record on $DOMAIN from the runner; the login below is the real check." + fi + + ./mcp-publisher login dns --domain "$DOMAIN" --private-key "$hex" + + - name: Publish ${{ steps.status.outputs.name }}@${{ steps.status.outputs.version }} + if: steps.status.outputs.state == 'new' + run: ./mcp-publisher publish + + - name: Show what the registry now serves + if: steps.status.outputs.state == 'new' + env: + NAME: ${{ steps.status.outputs.name }} + VERSION: ${{ steps.status.outputs.version }} + run: | + sleep 5 + curl -fsS "https://registry.modelcontextprotocol.io/v0/servers/$(jq -rn --arg s "$NAME" '$s|@uri')/versions/$VERSION" | jq . diff --git a/.github/workflows/validate-server-json.yml b/.github/workflows/validate-server-json.yml new file mode 100644 index 0000000..e93421d --- /dev/null +++ b/.github/workflows/validate-server-json.yml @@ -0,0 +1,52 @@ +name: Validate server.json and versions + +# Catches a forgotten version bump before it reaches main: +# * server.json must pass the registry schema +# * if the registry already has this version, server.json must be identical to +# it - otherwise "version" was not bumped (the publish on merge would fail) +# * a new version must be above the latest published one +# * npm-backed servers: packages[].version must exist on npm with mcpName set +# * pull requests: a change to the Claude Code plugin needs a new +# .claude-plugin/plugin.json "version", or installed users never get it +# The weekly run also fails when npm has a newer release than server.json lists, +# so a package release that never reached the registry does not go unnoticed. + +on: + pull_request: + push: + branches: ["main"] + paths: + - "server.json" + - ".claude-plugin/**" + - ".github/workflows/validate-server-json.yml" + - ".github/scripts/**" + schedule: + - cron: "23 6 * * 1" + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout code + uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Install mcp-publisher + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr '[:upper:]' '[:lower:]')_$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/').tar.gz" | tar xz mcp-publisher + + - name: Validate server.json against the schema it declares + run: ./mcp-publisher validate + + - name: server.json against the registry and npm + env: + DRIFT_IS_ERROR: ${{ github.event_name == 'schedule' }} + run: bash .github/scripts/registry-status.sh + + - name: Plugin version bumped if the plugin changed + if: github.event_name == 'pull_request' + run: bash .github/scripts/plugin-version-check.sh "origin/${{ github.base_ref }}" diff --git a/.gitignore b/.gitignore index ca0c490..4a10015 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ mcp-registry-auth .mcpregistry_registry_token .mcpregistry_* publish.sh + +# mcp-publisher binary when run locally +/mcp-publisher +/mcp-publisher_*.tar.gz