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
34 changes: 30 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ jobs:
environment: release
permissions:
contents: read
id-token: write # mcp-publisher authenticates by GitHub OIDC
id-token: write # reserved; the registry itself uses DNS auth
steps:
- uses: actions/checkout@v4

Expand Down Expand Up @@ -130,12 +130,38 @@ jobs:
echo "publish=true" >> "$GITHUB_OUTPUT"
fi

- name: Is the signing key configured?
id: key
env:
MCP_PRIVATE_KEY: ${{ secrets.MCP_PRIVATE_KEY }}
run: |
if [ -n "$MCP_PRIVATE_KEY" ]; then
echo "have_key=true" >> "$GITHUB_OUTPUT"
else
echo "have_key=false" >> "$GITHUB_OUTPUT"
{
echo "### MCP registry: skipped"
echo
echo "\`MCP_PRIVATE_KEY\` is not set, so the registry entry stays at its"
echo "current version. npm published normally."
echo
echo "The name \`com.greencalculus/api\` is a DNS namespace and can only be"
echo "published with the key matching the \`v=MCPv1\` TXT record on"
echo "greencalculus.com. GitHub OIDC only grants \`io.github.greencalculus/*\`."
} >> "$GITHUB_STEP_SUMMARY"
fi

- name: Publish to the MCP registry
if: steps.check.outputs.publish == 'true'
if: steps.check.outputs.publish == 'true' && steps.key.outputs.have_key == 'true'
env:
MCP_PRIVATE_KEY: ${{ secrets.MCP_PRIVATE_KEY }}
run: |
curl -sSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_$(uname -s | tr A-Z a-z)_$(uname -m | sed 's/x86_64/amd64/').tar.gz" \
| tar xz mcp-publisher
./mcp-publisher validate
# The OIDC token is short-lived, so log in immediately before publishing.
./mcp-publisher login github-oidc
# DNS auth, not OIDC: the OIDC identity is only granted
# io.github.greencalculus/*, and this server is published as
# com.greencalculus/api. The key must match the v=MCPv1 TXT record.
# The session is short-lived, so log in immediately before publishing.
./mcp-publisher login dns --domain greencalculus.com --private-key "$MCP_PRIVATE_KEY"
./mcp-publisher publish
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,9 +102,17 @@ weekly, so a publish that failed is retried without a new commit.

`server.json` is the registry manifest, and the workflow rewrites its version
from `package.json` before publishing — one source of truth, three places that
have to agree. npm authenticates by
[trusted publishing](https://docs.npmjs.com/trusted-publishers) and the registry
by GitHub OIDC, so there is no publishing token in this repo.
have to agree.

npm authenticates by [trusted publishing](https://docs.npmjs.com/trusted-publishers),
so there is no npm token here. The registry needs one secret, and the reason is
worth knowing: we publish as `com.greencalculus/api`, a DNS namespace, and GitHub
OIDC only ever grants `io.github.<org>/*`. So the registry step signs with the key
matching the `v=MCPv1` TXT record on greencalculus.com, held as `MCP_PRIVATE_KEY`.
[`scripts/rotate-registry-key.sh`](./scripts/rotate-registry-key.sh) generates a
fresh pair and installs it without ever printing the private half; it prints the
TXT record to publish. Without the secret the registry step skips and says so —
npm still publishes.

## Also available

Expand Down
48 changes: 48 additions & 0 deletions scripts/rotate-registry-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Generate the Ed25519 keypair the MCP registry uses to prove we own
# greencalculus.com, and install the private half as a repo secret.
#
# We publish as com.greencalculus/api — a DNS namespace. GitHub OIDC only ever
# grants io.github.<org>/*, so the release workflow authenticates with this key
# instead, and it must match the v=MCPv1 TXT record on the domain.
#
# The private key is piped straight into `gh secret set`. It is never printed,
# never written outside a temp dir, and the temp dir is removed on exit.
#
# Usage: ./scripts/rotate-registry-key.sh
# Then: replace the v=MCPv1 TXT record with the line this prints.

set -euo pipefail

REPO="greencalculus/greencalculus-mcp"
DOMAIN="greencalculus.com"

command -v openssl >/dev/null || { echo "openssl not found" >&2; exit 1; }
command -v gh >/dev/null || { echo "gh not found" >&2; exit 1; }

TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT

openssl genpkey -algorithm ed25519 -out "$TMP/key.pem" 2>/dev/null

# Ed25519 DER ends with the raw 32-byte seed; the registry wants it as hex.
HEX=$(openssl pkey -in "$TMP/key.pem" -outform DER 2>/dev/null | tail -c 32 | xxd -p -c 64)
PUB=$(openssl pkey -in "$TMP/key.pem" -pubout -outform DER 2>/dev/null | tail -c 32 | base64)

[ "${#HEX}" -eq 64 ] || { echo "private key is ${#HEX} hex chars, expected 64" >&2; exit 1; }
[ "${#PUB}" -eq 44 ] || { echo "public key is ${#PUB} base64 chars, expected 44" >&2; exit 1; }

printf '%s' "$HEX" | gh secret set MCP_PRIVATE_KEY --repo "$REPO"

cat <<EOF

Secret MCP_PRIVATE_KEY is set on $REPO.

Last step — replace the existing v=MCPv1 TXT record on $DOMAIN with:

v=MCPv1; k=ed25519; p=$PUB

Publishing stays broken until that record matches, because the registry
verifies the signature against whatever the domain currently advertises.
EOF
Loading