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)