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
71 changes: 71 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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<version>` 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=<base64 public key>`
- 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
```
48 changes: 48 additions & 0 deletions .github/scripts/plugin-version-check.sh
Original file line number Diff line number Diff line change
@@ -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 <base-ref> 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 <base-ref>}
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"
109 changes: 109 additions & 0 deletions .github/scripts/registry-status.sh
Original file line number Diff line number Diff line change
@@ -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"
128 changes: 128 additions & 0 deletions .github/workflows/publish-mcp.yml
Original file line number Diff line number Diff line change
@@ -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<version>; 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 .
Loading
Loading