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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions .github/workflows/production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
35 changes: 21 additions & 14 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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}"
Comment thread
greptile-apps[bot] marked this conversation as resolved.

- name: Install mcp-publisher
env:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ build/
dist/
wheels/
*.egg-info
server.json

# Virtual environments
.venv
Expand Down
19 changes: 11 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ./
Expand Down
7 changes: 5 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"]

Expand All @@ -66,5 +69,5 @@ venv = ".venv"
typeCheckingMode = "basic"

[build-system]
requires = ["hatchling"]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
60 changes: 60 additions & 0 deletions scripts/render_server_json.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 2 additions & 2 deletions server.json → server.template.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -15,7 +15,7 @@
],
"packages": [
{
"version": "0.8.7",
"version": "__VERSION__",
"registryType": "pypi",
"identifier": "mcp-server-appwrite",
"transport": {
Expand Down
11 changes: 10 additions & 1 deletion src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/unit/test_release_metadata.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 0 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading