diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39db668..6a9f8dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Build hosted HTTP image - run: docker build -t appwrite-mcp:ci . + run: docker build --build-arg PACKAGE_VERSION=0.0.0+ci -t appwrite-mcp:ci . integration: name: Integration diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index af41b41..2a566b4 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -44,11 +44,23 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Derive package version + id: package-version + run: | + TAG="${{ github.event.release.tag_name || '' }}" + if [ -n "$TAG" ]; then + echo "package_version=${TAG#v}" >> "$GITHUB_OUTPUT" + else + echo "package_version=0.0.0+${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + fi + - name: Build and push uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 with: context: . push: true + build-args: | + PACKAGE_VERSION=${{ steps.package-version.outputs.package_version }} tags: | ${{ env.REGISTRY_GITHUB }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8418c86..658b8d2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Check out code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -24,22 +26,11 @@ jobs: python -m pip install --upgrade pip pip install build==1.5.0 twine==6.2.0 - - name: Check release metadata + - name: Derive release version run: | - VERSION="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" TAG="${{ github.event.release.tag_name }}" - if [ "$TAG" != "v$VERSION" ]; then - echo "Release tag $TAG does not match pyproject version v$VERSION" >&2 - exit 1 - fi - SERVER_VERSION="$(python -c 'import json; print(json.load(open("server.json"))["version"])')" - if [ "$SERVER_VERSION" != "$VERSION" ]; then - echo "server.json version $SERVER_VERSION does not match pyproject version $VERSION" >&2 - exit 1 - fi - PACKAGE_VERSION="$(python -c 'import json; data=json.load(open("server.json")); print(next(package["version"] for package in data["packages"] if package["identifier"] == "mcp-server-appwrite"))')" - if [ "$PACKAGE_VERSION" != "$VERSION" ]; then - echo "server.json package version $PACKAGE_VERSION does not match pyproject version $VERSION" >&2 + if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Release tag $TAG must match vMAJOR.MINOR.PATCH" >&2 exit 1 fi @@ -62,6 +53,22 @@ jobs: steps: - name: Check out code uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Render MCP Registry metadata + run: | + TAG="${{ github.event.release.tag_name }}" + if ! echo "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "Release tag $TAG must match vMAJOR.MINOR.PATCH" >&2 + exit 1 + fi + python scripts/render_server_json.py "${TAG#v}" - name: Install mcp-publisher env: diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index 16b3125..584b70e 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -50,6 +50,8 @@ jobs: with: context: . push: true + build-args: | + PACKAGE_VERSION=0.0.0+${{ github.sha }} tags: | ${{ env.REGISTRY_GITHUB }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} ${{ env.REGISTRY_DOCKERHUB }}/${{ env.IMAGE_NAME }}:${{ env.TAG }} diff --git a/.gitignore b/.gitignore index 7e8e6e5..4e0c033 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ build/ dist/ wheels/ *.egg-info +server.json # Virtual environments .venv diff --git a/AGENTS.md b/AGENTS.md index 3817ed3..3602672 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,14 +146,17 @@ These are not gated on PRs the way `ci.yml` is, but be mindful when touching the ### Release metadata -When bumping the package version in `pyproject.toml`, keep `server.json` in sync: - -- `server.json.name` must stay `io.github.appwrite/mcp`. -- `server.json.version` must match `[project].version` in `pyproject.toml`. -- The `packages[]` entry for `mcp-server-appwrite` must use the same version. - -The publish workflow sends `server.json` to the MCP Registry after publishing the -PyPI package, so stale registry metadata will be published if these drift. +Release versions come from Git tags. Publish GitHub releases with tags in the +form `vMAJOR.MINOR.PATCH` (for example, `v0.8.8`). Python package builds derive +their version from the tag via Hatch VCS, and `server.json` is generated from +`server.template.json` during the publish workflow before sending metadata to the +MCP Registry. + +Do not hand-edit a version in `pyproject.toml`, `constants.py`, or `server.json`. +For local registry metadata checks, render the generated file explicitly: +```bash +uv run python scripts/render_server_json.py 0.8.8 +``` ## Conventions diff --git a/Dockerfile b/Dockerfile index 333cf39..bdaf990 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app +ARG PACKAGE_VERSION=0.0.0+docker +ENV SETUPTOOLS_SCM_PRETEND_VERSION=${PACKAGE_VERSION} + COPY --from=ghcr.io/astral-sh/uv:0.11.22 /uv /usr/local/bin/uv COPY pyproject.toml uv.lock README.md ./ diff --git a/pyproject.toml b/pyproject.toml index aebfc9e..fa7ebc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "mcp-server-appwrite" -version = "0.8.7" +dynamic = ["version"] description = "MCP (Model Context Protocol) server for Appwrite" readme = "README.md" requires-python = ">=3.12" @@ -45,6 +45,9 @@ mcp-server-appwrite = "mcp_server_appwrite.__main__:main" # Ship the prebuilt docs search index (non-.py package data) in the wheel. artifacts = ["src/mcp_server_appwrite/data/*", "src/mcp_server_appwrite/assets/*"] +[tool.hatch.version] +source = "vcs" + [tool.black] target-version = ["py312"] @@ -66,5 +69,5 @@ venv = ".venv" typeCheckingMode = "basic" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" diff --git a/scripts/render_server_json.py b/scripts/render_server_json.py new file mode 100644 index 0000000..facb877 --- /dev/null +++ b/scripts/render_server_json.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +PACKAGE_IDENTIFIER = "mcp-server-appwrite" + + +def render_server_metadata( + version: str, + *, + template_path: Path = Path("server.template.json"), + output_path: Path = Path("server.json"), +) -> None: + if not VERSION_RE.fullmatch(version): + raise ValueError(f"version must be MAJOR.MINOR.PATCH, got {version!r}") + + data: dict[str, Any] = json.loads(template_path.read_text()) + data["version"] = version + + for package in data.get("packages", []): + if package.get("identifier") == PACKAGE_IDENTIFIER: + package["version"] = version + break + else: + raise ValueError(f"{PACKAGE_IDENTIFIER!r} package entry not found") + + output_path.write_text(json.dumps(data, indent=2) + "\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Render MCP Registry metadata.") + parser.add_argument("version", help="Release version without leading v.") + parser.add_argument( + "--template", + type=Path, + default=Path("server.template.json"), + help="Path to the server metadata template.", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("server.json"), + help="Path to write rendered metadata.", + ) + args = parser.parse_args() + + render_server_metadata( + args.version, + template_path=args.template, + output_path=args.output, + ) + + +if __name__ == "__main__": + main() diff --git a/server.json b/server.template.json similarity index 90% rename from server.json rename to server.template.json index 923bb3e..8b41735 100644 --- a/server.json +++ b/server.template.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json", "name": "io.github.appwrite/mcp", "description": "MCP (Model Context Protocol) server for Appwrite", - "version": "0.8.7", + "version": "__VERSION__", "repository": { "url": "https://github.com/appwrite/mcp", "source": "github" @@ -15,7 +15,7 @@ ], "packages": [ { - "version": "0.8.7", + "version": "__VERSION__", "registryType": "pypi", "identifier": "mcp-server-appwrite", "transport": { diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index 3129e1d..ee0f345 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -2,6 +2,7 @@ from __future__ import annotations +from importlib import metadata as importlib_metadata from pathlib import Path from appwrite.models.bucket import Bucket @@ -14,7 +15,15 @@ # --- server --------------------------------------------------------------- -SERVER_VERSION = "0.8.6" + +def _resolve_server_version() -> str: + try: + return importlib_metadata.version("mcp-server-appwrite") + except importlib_metadata.PackageNotFoundError: + return "0.0.0+unknown" + + +SERVER_VERSION = _resolve_server_version() DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1" # Region reported by single-region deployments; carries no region subdomain. diff --git a/tests/unit/test_release_metadata.py b/tests/unit/test_release_metadata.py new file mode 100644 index 0000000..8f30aed --- /dev/null +++ b/tests/unit/test_release_metadata.py @@ -0,0 +1,87 @@ +import importlib.util +import json +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from mcp_server_appwrite import constants + + +def _load_render_module(): + script_path = Path(__file__).parents[2] / "scripts" / "render_server_json.py" + spec = importlib.util.spec_from_file_location("render_server_json", script_path) + if spec is None or spec.loader is None: + raise RuntimeError("Unable to load render_server_json.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class ServerVersionTests(unittest.TestCase): + def test_resolve_server_version_uses_package_metadata(self): + with patch.object( + constants.importlib_metadata, "version", return_value="1.2.3" + ): + self.assertEqual(constants._resolve_server_version(), "1.2.3") + + def test_resolve_server_version_falls_back_when_metadata_missing(self): + with patch.object( + constants.importlib_metadata, + "version", + side_effect=constants.importlib_metadata.PackageNotFoundError, + ): + self.assertEqual(constants._resolve_server_version(), "0.0.0+unknown") + + +class RenderServerMetadataTests(unittest.TestCase): + def test_render_server_metadata_sets_all_release_versions(self): + module = _load_render_module() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + template_path = tmp_path / "server.template.json" + output_path = tmp_path / "server.json" + template_path.write_text( + json.dumps( + { + "version": "__VERSION__", + "packages": [ + { + "identifier": "mcp-server-appwrite", + "version": "__VERSION__", + } + ], + } + ) + ) + + module.render_server_metadata( + "1.2.3", + template_path=template_path, + output_path=output_path, + ) + + rendered = json.loads(output_path.read_text()) + self.assertEqual(rendered["version"], "1.2.3") + self.assertEqual(rendered["packages"][0]["version"], "1.2.3") + + def test_render_server_metadata_rejects_non_release_version(self): + module = _load_render_module() + + with tempfile.TemporaryDirectory() as tmpdir: + tmp_path = Path(tmpdir) + template_path = tmp_path / "server.template.json" + output_path = tmp_path / "server.json" + template_path.write_text('{"version": "__VERSION__", "packages": []}') + + with self.assertRaises(ValueError): + module.render_server_metadata( + "1.2.3.dev1", + template_path=template_path, + output_path=output_path, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 39d3e22..cec984e 100644 --- a/uv.lock +++ b/uv.lock @@ -639,7 +639,6 @@ cli = [ [[package]] name = "mcp-server-appwrite" -version = "0.8.7" source = { editable = "." } dependencies = [ { name = "anyio" },