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
19 changes: 18 additions & 1 deletion .github/workflows/create-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
87 changes: 87 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -14,7 +14,7 @@
"registryType": "pypi",
"registryBaseUrl": "https://pypi.org",
"identifier": "xbrlkit",
"version": "0.16.4",
"version": "0.16.5",
"runtimeHint": "uvx",
"runtimeArguments": [
{
Expand Down
64 changes: 64 additions & 0 deletions tests/test_server_json.py
Original file line number Diff line number Diff line change
@@ -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)