From fdb3283d23a2c51339aa653876de19803fc0b22b Mon Sep 17 00:00:00 2001 From: "Joseph T. French" Date: Wed, 16 Sep 2026 15:04:22 -0500 Subject: [PATCH] chore(release): publish the MCP Registry listing with every release server.json pins an exact xbrlkit version, and clients that install from the Official MCP Registry run that version. It was published by hand and had fallen behind PyPI (0.16.4 listed, 0.16.5 released), so registry installs missed the latest fixes and tool changes. - create-release.yml bumps server.json's version fields in the same commit as pyproject.toml. - publish.yml checks that server.json names the release, validates it against the registry, and, after the PyPI upload and once PyPI serves the version, logs in by DNS and publishes the listing. It skips a version that is already listed. The key comes from a production environment secret, MCP_REGISTRY_PRIVATE_KEY. - mcp-publisher is pinned to v1.8.1 by sha256 and installed outside the checkout. - tests/test_server_json.py keeps server.json and pyproject.toml on one version, checks the README ownership token and description length, and pins the description's tool count to the server's real tool list. - server.json moves to 0.16.5, the current release. --- .github/workflows/create-release.yml | 19 +++++- .github/workflows/publish.yml | 87 ++++++++++++++++++++++++++++ server.json | 4 +- tests/test_server_json.py | 64 ++++++++++++++++++++ 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 tests/test_server_json.py diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 31c4453..d6da5f6 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -105,11 +105,28 @@ jobs: # Update version in pyproject.toml sed -i "s/^version = \"$CURRENT_VERSION\"/version = \"$NEW_VERSION\"/" pyproject.toml + # server.json is the Official MCP Registry listing. Clients that install + # from the registry run exactly the version it pins, so it moves with the + # package in the same commit; publish.yml lists it after the PyPI upload. + python - "$NEW_VERSION" <<'PY' + import json + import sys + + version = sys.argv[1] + with open("server.json") as f: + listing = json.load(f) + listing["version"] = version + for package in listing["packages"]: + package["version"] = version + with open("server.json", "w") as f: + f.write(json.dumps(listing, indent=2, ensure_ascii=False) + "\n") + PY + # Update lock file uv sync --all-extras # Commit and push to main - git add pyproject.toml + git add pyproject.toml server.json git commit -m "Release v$NEW_VERSION" git push origin main diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 243269c..1ce7af9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -40,6 +40,47 @@ jobs: echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT echo "Current version: $CURRENT_VERSION" + # Before anything irreversible: the registry listing must name this version. + # create-release.yml bumps both together; a hand edit could split them. + - name: Check server.json matches the release + run: | + python - <<'PY' + import json + import tomllib + + project = tomllib.load(open("pyproject.toml", "rb"))["project"] + listing = json.load(open("server.json")) + versions = {listing["version"], *(p["version"] for p in listing["packages"])} + if versions != {project["version"]}: + raise SystemExit(f"server.json pins {sorted(versions)}, pyproject.toml releases {project['version']}") + if f"mcp-name: {listing['name']}" not in open(project["readme"]).read(): + raise SystemExit(f"README is missing the registry ownership token for {listing['name']}") + print(f"server.json lists {listing['name']} {project['version']}") + PY + + # Pinned by version and checksum (the sha256 is the one in the release's + # registry_1.8.1_checksums.txt), so a new publisher build never runs here + # unreviewed. Installed outside the checkout, so the build below cannot + # sweep the binary into the sdist. + - name: Install mcp-publisher + env: + MCP_PUBLISHER_VERSION: v1.8.1 + MCP_PUBLISHER_SHA256: a06c9096dcb9727c13555b6be26c7effa707b01f06a4c561ba7a3635443cf2cc + run: | + cd "$RUNNER_TEMP" + curl -fsSL -o mcp-publisher.tar.gz \ + "https://github.com/modelcontextprotocol/registry/releases/download/${MCP_PUBLISHER_VERSION}/mcp-publisher_linux_amd64.tar.gz" + echo "${MCP_PUBLISHER_SHA256} mcp-publisher.tar.gz" | sha256sum -c - + tar -xzf mcp-publisher.tar.gz mcp-publisher + echo "$RUNNER_TEMP" >> "$GITHUB_PATH" + "$RUNNER_TEMP/mcp-publisher" --version + + # The registry's own validation, which needs no login. Run before the PyPI + # upload: a version number is burned once uploaded, and a listing the + # registry rejects could not be published for it afterwards. + - name: Validate server.json against the registry + run: mcp-publisher validate server.json + - name: Build package run: python -m build @@ -66,3 +107,49 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: verbose: true + + # The Official MCP Registry validates a PyPI listing against the uploaded + # release (the version must exist and its README must carry the mcp-name + # token), so wait until PyPI serves it. + - name: Wait for PyPI to serve the release + if: steps.check-pypi.outputs.version_exists == 'false' + run: | + VERSION="${{ steps.current-version.outputs.current_version }}" + PACKAGE_NAME=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['name'])") + for _ in $(seq 1 30); do + if curl -sf -o /dev/null "https://pypi.org/pypi/${PACKAGE_NAME}/${VERSION}/json"; then + echo "PyPI serves ${PACKAGE_NAME} ${VERSION}" + exit 0 + fi + sleep 10 + done + echo "::error::PyPI did not serve ${PACKAGE_NAME} ${VERSION} within 5 minutes" + exit 1 + + # Clients that install from the registry run the version it pins, so a + # release the registry does not list never reaches them. The ai.robosystems + # namespace is proven by DNS on robosystems.ai; the key is a production + # environment secret. Skips when this version is already listed, so the + # listing is published at most once per release. + - name: Publish to the MCP Registry + if: steps.check-pypi.outputs.version_exists == 'false' + env: + MCP_REGISTRY_DOMAIN: robosystems.ai + MCP_REGISTRY_PRIVATE_KEY: ${{ secrets.MCP_REGISTRY_PRIVATE_KEY }} + run: | + NAME=$(python -c "import json; print(json.load(open('server.json'))['name'])") + VERSION=$(python -c "import json; print(json.load(open('server.json'))['version'])") + ENCODED=$(python -c "import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=''))" "$NAME") + STATUS=$(curl -s -o /dev/null -w '%{http_code}' \ + "https://registry.modelcontextprotocol.io/v0/servers/${ENCODED}/versions/${VERSION}") + if [ "$STATUS" = "200" ]; then + echo "${NAME} ${VERSION} is already listed" + exit 0 + fi + if [ -z "$MCP_REGISTRY_PRIVATE_KEY" ]; then + echo "::error::MCP_REGISTRY_PRIVATE_KEY is not set on the production environment, so ${NAME} ${VERSION} was not listed" + exit 1 + fi + mcp-publisher login dns --domain "$MCP_REGISTRY_DOMAIN" --private-key "$MCP_REGISTRY_PRIVATE_KEY" + mcp-publisher publish server.json + echo "Listed ${NAME} ${VERSION} on the Official MCP Registry" diff --git a/server.json b/server.json index dd6d263..88425ad 100644 --- a/server.json +++ b/server.json @@ -3,7 +3,7 @@ "name": "ai.robosystems/xbrlkit", "title": "xbrlkit", "description": "Read SEC EDGAR and XBRL filings with an AI: 18 tools over the filing itself. Local, free, no account", - "version": "0.16.4", + "version": "0.16.5", "websiteUrl": "https://github.com/RoboFinSystems/xbrlkit#readme", "repository": { "url": "https://github.com/RoboFinSystems/xbrlkit", @@ -14,7 +14,7 @@ "registryType": "pypi", "registryBaseUrl": "https://pypi.org", "identifier": "xbrlkit", - "version": "0.16.4", + "version": "0.16.5", "runtimeHint": "uvx", "runtimeArguments": [ { diff --git a/tests/test_server_json.py b/tests/test_server_json.py new file mode 100644 index 0000000..df26fcf --- /dev/null +++ b/tests/test_server_json.py @@ -0,0 +1,64 @@ +"""server.json, the Official MCP Registry listing, tracks the package. + +Clients that install from the registry run exactly the version server.json +pins, so it has to be the version pyproject.toml releases. create-release.yml +bumps both in one commit and publish.yml lists it after the PyPI upload; these +tests keep a hand edit from splitting them, and keep the description true. +""" + +from __future__ import annotations + +import json +import re +import tomllib +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def _listing() -> dict: + return json.loads((ROOT / "server.json").read_text()) + + +def _project() -> dict: + return tomllib.loads((ROOT / "pyproject.toml").read_text())["project"] + + +def test_the_listing_pins_the_released_version() -> None: + listing = _listing() + version = _project()["version"] + assert listing["version"] == version + assert [package["version"] for package in listing["packages"]] == [version] + + +def test_the_listing_names_this_package_and_its_readme_carries_the_token() -> None: + listing = _listing() + project = _project() + assert [package["identifier"] for package in listing["packages"]] == [project["name"]] + # The registry proves PyPI ownership by finding this line in the README. + readme = (ROOT / project["readme"]).read_text() + assert f"mcp-name: {listing['name']}" in readme + + +def test_the_description_fits_the_registry() -> None: + assert len(_listing()["description"]) <= 100 + + +@pytest.mark.asyncio +async def test_a_tool_count_in_the_description_is_the_servers(tmp_path: Path) -> None: + match = re.search(r"\b(\d+) tools\b", _listing()["description"]) + if match is None: + pytest.skip("the description names no tool count") + from mcp.client import Client + + from xbrlkit.serve import FilingSession, build_server + + session = FilingSession() + try: + async with Client(build_server(session, tmp_path)) as client: + tools = (await client.list_tools()).tools + finally: + session.close() + assert int(match.group(1)) == len(tools)