From 7a5592991573ae96fe01f6f2db41d40cb1192333 Mon Sep 17 00:00:00 2001 From: arpan Date: Wed, 16 Sep 2026 01:22:24 +0530 Subject: [PATCH 1/2] Publish to the MCP registry on the release tag, under a name it will accept `server.json` has looked right and been unpublishable since it landed. Two things, both of which only the registry could have said: - The namespace is matched against the OIDC token's `repository_owner` with a case-sensitive `HasPrefix`, and nothing lowercases either side. This repository is owned by `CTRLRun`, so `io.github.ctrlrun/...` is a 403. Of the 1,200 `io.github.*` entries the registry serves today, 795 carry a mixed-case namespace and not one differs in case from its own repository owner. - `description` is capped at 100 characters. Ours was 288, and `mcp-publisher validate` answers 422 naming the field. The shorter one keeps the opening sentence; the rest of it is the page `websiteUrl` already points at. `mcp-publisher validate` passes against the live registry with both fixed. The publish then rides the release rather than waiting for someone to run a CLI: a `registry` job on kernel tags, after `pypi`, because the registry verifies the claim by reading `mcp-name:` out of the long description PyPI is serving. No credential is stored anywhere; GitHub mints an OIDC token and the registry grants `io.github.CTRLRun/*` against it for five minutes, which is the argument trusted publishing already makes for PyPI in the job above. The publisher binary is pinned by version and digest, the job leaves alone a version the registry already has, and it names whichever of PyPI or the marker is missing instead of leaving an ownership error to be decoded. Four tests hold it: the namespace equals the repository owner, the two capped fields fit, the job waits on `pypi` with `id-token` and nothing else, and the binary is pinned and checked. Signed-off-by: arpan --- .github/workflows/publish.yml | 110 +++++++++++++++++++++++++++++++ README.md | 2 +- server.json | 4 +- tests/test_repository_signals.py | 83 ++++++++++++++++++++++- 4 files changed, 195 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bd7f80a8..8abd2c50 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -191,3 +191,113 @@ jobs: name: dist path: dist/ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + + # The official MCP registry indexes where a server lives; it hosts no artifacts of its own. It + # verifies the claim by fetching the PyPI metadata for the exact version named in `server.json` + # and looking for `mcp-name: ` in the long description, which for this distribution is + # the README. So the registry can only be told about a version PyPI already serves, and this + # job waits on `pypi` rather than running beside it. A failure here cannot unpublish anything; + # the release is already out, and the job re-runs from the Actions tab. + # + # `server.json` needs no edit at release time. Its two version fields are pinned to + # `pyproject.toml`, and its name to the README marker, by + # `test_the_registry_manifest_agrees_with_the_version_and_the_readme_marker`. + registry: + needs: [build, pypi] + if: needs.build.outputs.kernel == 'true' + runs-on: ubuntu-latest + environment: + name: mcp-registry + url: https://registry.modelcontextprotocol.io/v0/servers/io.github.CTRLRun%2Fctrlrun-mcp-operator/versions + permissions: + # The whole credential. GitHub mints an OIDC token, the registry reads `repository_owner` + # from it and grants `io.github.CTRLRun/*` for five minutes. No token is stored anywhere, + # which is the same argument trusted publishing makes for PyPI above. + id-token: write + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Pinned by version *and* by digest, for the same reason every action above is pinned to a + # commit: `releases/latest` is whatever the registry cut this morning. Bump both together + # when the registry's audience changes -- the CLI says "invalid audience" when it is too + # old, which is the only signal that a bump is due. + - name: Install mcp-publisher 1.8.1 + env: + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + run: | + set -eu + url=https://github.com/modelcontextprotocol/registry/releases/download/v1.8.1/mcp-publisher_linux_amd64.tar.gz + curl -fsSL --retry 3 -o mcp-publisher.tar.gz "$url" + echo "$MCP_PUBLISHER_SHA256 mcp-publisher.tar.gz" | sha256sum --check --strict + tar xzf mcp-publisher.tar.gz mcp-publisher + ./mcp-publisher --help >/dev/null + + # Idempotent, like `release.yml`'s "Already released": a re-run of a job that succeeded + # would otherwise fail on a version the registry already has, and a red tick on a release + # that shipped correctly is a lie that costs someone an afternoon. + - name: Already in the registry + id: existing + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -eu + name=$(python3 -c "import json;print(json.load(open('server.json'))['name'])") + url="https://registry.modelcontextprotocol.io/v0/servers/${name//\//%2F}/versions/$VERSION" + code=$(curl -sS -o /dev/null -w '%{http_code}' "$url") + case "$code" in + 404) echo "exists=false" >> "$GITHUB_OUTPUT" + echo "$name $VERSION is not in the registry yet" ;; + 200) echo "exists=true" >> "$GITHUB_OUTPUT" + echo "$name $VERSION is already published; leaving it alone" ;; + # Anything else is the registry being unreachable or changing shape, and publishing + # blind on top of that is how a version gets published twice. + *) echo "::error::$url answered $code"; exit 1 ;; + esac + + # The ownership check the registry is about to make, made here first. PyPI serves the new + # version's metadata within seconds of the upload, but "within seconds" is a race, and the + # registry's own error for losing it ("the server name must appear as mcp-name: ...") reads + # as a missing marker rather than as a version that has not landed yet. + - name: PyPI serves this version, and it carries the marker + if: steps.existing.outputs.exists == 'false' + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + set -eu + name=$(python3 -c "import json;print(json.load(open('server.json'))['name'])") + served=false + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsSL "https://pypi.org/pypi/ctrlrun/$VERSION/json" -o metadata.json; then + served=true + break + fi + echo "attempt $attempt: PyPI does not serve $VERSION yet" + sleep 15 + done + if [ "$served" != true ]; then + echo "::error::PyPI still does not serve ctrlrun $VERSION after ten tries" + exit 1 + fi + python3 - "$name" <<'PY' + import json, sys + name = sys.argv[1] + description = json.load(open("metadata.json"))["info"]["description"] + token = f"mcp-name: {name}" + if token not in description: + sys.exit( + f"::error::the published README carries no {token!r}. The registry verifies " + "the PyPI namespace by finding that exact string in the long description; add " + "it to README.md and cut a release that carries it." + ) + print(f"{token!r} is in the published description") + PY + + # The namespace comes from the OIDC token's `repository_owner`, so this publishes as the + # organisation that owns this repository and nothing else. + - name: Publish to the MCP registry + if: steps.existing.outputs.exists == 'false' + run: | + set -eu + ./mcp-publisher login github-oidc + ./mcp-publisher publish diff --git a/README.md b/README.md index 66fd1ef0..d900e6c5 100644 --- a/README.md +++ b/README.md @@ -439,4 +439,4 @@ where it is going. Releases carry PyPI provenance attestations from GitHub Actio Apache-2.0. The enforcement kernel is and will remain fully open source. - + diff --git a/server.json b/server.json index 57009a75..cbc2b658 100644 --- a/server.json +++ b/server.json @@ -1,8 +1,8 @@ { "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", - "name": "io.github.ctrlrun/ctrlrun-mcp-operator", + "name": "io.github.CTRLRun/ctrlrun-mcp-operator", "title": "CTRLRun Operator", - "description": "Answer CTRLRun approvals from an MCP client. Lists the pending approval requests, inspects a held action, grants or denies one, resolves an ambiguous effect, and reads receipts, effects and stats. Read tools answer without a credential; write tools refuse without one that names a person.", + "description": "Answer CTRLRun approvals from an MCP client: list what is held, inspect it, grant or deny.", "websiteUrl": "https://docs.ctrlrun.dev/mcp/overview", "repository": { "url": "https://github.com/CTRLRun/ctrlrun", diff --git a/tests/test_repository_signals.py b/tests/test_repository_signals.py index c2228962..bb36c89e 100644 --- a/tests/test_repository_signals.py +++ b/tests/test_repository_signals.py @@ -355,7 +355,88 @@ def test_the_registry_manifest_agrees_with_the_version_and_the_readme_marker(): assert packages[0]["version"] == version name = manifest["name"] - assert re.search(rf"mcp-name:\s*{re.escape(name)}(?![\w./-])", readme) + # The registry's matcher is `strings.Index(description, "mcp-name: " + name)` followed by a + # boundary check, so the separator is one space exactly and the case is the manifest's. A + # tab, two spaces or a lowercased namespace all read fine to a human and none of them match. + marker = readme.find(f"mcp-name: {name}") + assert marker != -1, f"README carries no 'mcp-name: {name}'" + assert re.match(r"(-->|\s|$)", readme[marker + len(f"mcp-name: {name}") :]), ( + "the marker is glued to a trailing character, which the registry reads as a longer name" + ) + + +def test_the_registry_namespace_is_the_github_owner_with_its_own_case(): + """The registry decides what a publisher may claim by reading `repository_owner` out of the + GitHub OIDC token (or the organisation's login, on the token path), formatting it into + `io.github./*`, and matching that against `server.json`'s name with + `strings.HasPrefix`. That compare is case-sensitive and nothing lowercases either side, so a + manifest saying `io.github.ctrlrun/...` in a repository owned by `CTRLRun` is a 403 at + publish time and a name that reads perfectly well in review. + + Checked against the live registry rather than argued from the source: of 1,200 `io.github.*` + entries, 795 carry a mixed-case namespace and not one differs in case from its own + repository owner. Publishing is also the point of no return -- a name cannot be changed + afterwards without stranding whoever pinned it -- so the pin belongs here, before the first + publish, and not in the release checklist. + """ + manifest = json.loads((REPO_ROOT / "server.json").read_text(encoding="utf-8")) + owner = manifest["repository"]["url"].removeprefix("https://github.com/").split("/")[0] + namespace = manifest["name"].split("/")[0] + + assert namespace == f"io.github.{owner}", ( + f"{namespace} is not the namespace {owner} owns; the publish would be refused" + ) + + +def test_the_registry_manifest_fits_the_fields_the_registry_will_accept(): + """`description` and `title` are capped at 100 characters, and the cap is enforced where it + cannot be seen: `mcp-publisher validate` returns a 422 naming the field, and nothing in this + repository would have said so first. The manifest shipped at 288 characters for a day. + """ + manifest = json.loads((REPO_ROOT / "server.json").read_text(encoding="utf-8")) + + for field in ("description", "title"): + assert 1 <= len(manifest[field]) <= 100, f"{field} is {len(manifest[field])} characters" + + +def test_the_publish_workflow_tells_the_registry_after_pypi(): + """The registry verifies ownership by fetching the PyPI metadata for the version the + manifest names and finding the README marker in it, so a job that raced the upload would + fail on an ownership error that has nothing to do with ownership. `needs: pypi` is what + orders them, and it is asserted here because the ordering is invisible in the file: the + jobs are siblings, and nothing but this key stops them running together. + """ + workflow = _workflow("publish.yml") + job = workflow["jobs"]["registry"] + + assert "pypi" in job["needs"], "the registry would be told about an unpublished version" + assert "kernel == 'true'" in job["if"], "an adapter tag publishes no MCP server" + # Exact, not a superset. `id-token` is the entire credential; the publish stores nothing. + assert job["permissions"] == {"id-token": "write", "contents": "read"} + + script = "\n".join(step.get("run", "") for step in job["steps"]) + assert "mcp-publisher login github-oidc" in script, "a stored token would outlive the job" + assert "./mcp-publisher publish" in script + + +def test_the_publisher_binary_is_pinned_and_checked(): + """`releases/latest` is whatever the registry cut this morning, downloaded into a job that + holds a publish credential. The version is pinned in the URL and the bytes are checked + against a digest, which is `test_every_action_is_pinned_to_a_commit`'s argument for a + dependency that arrives by `curl` rather than by `uses:`. + """ + steps = _workflow("publish.yml")["jobs"]["registry"]["steps"] + # The digest is passed through `env:` rather than written into the script, so the step is + # read whole; a check that only read `run:` would pass on a workflow carrying no digest. + script = "\n".join( + step.get("run", "") + "\n".join(str(value) for value in step.get("env", {}).values()) + for step in steps + ) + + assert "releases/latest" not in script, "the publisher would change under the release" + assert re.search(r"releases/download/v\d+\.\d+\.\d+/mcp-publisher_", script) + assert re.search(r"\b[0-9a-f]{64}\b", script), "no digest to check the download against" + assert "sha256sum --check --strict" in script def test_how_this_is_built_states_the_review_gap_and_the_tooling_once(): From dad713c7c3e4fa97c0d96ea169fcf55abd328658 Mon Sep 17 00:00:00 2001 From: arpan Date: Wed, 16 Sep 2026 01:29:43 +0530 Subject: [PATCH 2/2] Check both spellings of the comment close in the marker boundary CodeQL is right and it is not only a lint: the registry's boundary rule accepts `-->` and `--!>`, and a check that knew only the first would fail a README the registry is happy with. The rule is now the registry's own, whole: end of content, any character a server name cannot carry, or either close. Signed-off-by: arpan --- tests/test_repository_signals.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_repository_signals.py b/tests/test_repository_signals.py index bb36c89e..cf9d4d0b 100644 --- a/tests/test_repository_signals.py +++ b/tests/test_repository_signals.py @@ -360,7 +360,12 @@ def test_the_registry_manifest_agrees_with_the_version_and_the_readme_marker(): # tab, two spaces or a lowercased namespace all read fine to a human and none of them match. marker = readme.find(f"mcp-name: {name}") assert marker != -1, f"README carries no 'mcp-name: {name}'" - assert re.match(r"(-->|\s|$)", readme[marker + len(f"mcp-name: {name}") :]), ( + # Its boundary rule, in full: end of content, any character a server name cannot contain, + # or a comment close. Both spellings of the close, because the registry accepts both and a + # check that knew only `-->` would reject a README the registry is happy with. + rest = readme[marker + len(f"mcp-name: {name}") :] + boundary = rest == "" or not re.match(r"[A-Za-z0-9._/-]", rest) or re.match(r"--!?>", rest) + assert boundary, ( "the marker is glued to a trailing character, which the registry reads as a longer name" )