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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Check formatting
run: uv run --group dev black --check src tests

- name: Type check
run: uv run --group dev pyright

unit:
name: Unit
runs-on: ubuntu-latest
Expand Down
13 changes: 10 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,27 @@ Run these locally before opening a PR. They mirror the `CI` workflow
```
Run `uv run --group dev black src tests` (without `--check`) to auto-fix.

3. **Unit tests** (`unit` job)
3. **Type check** (`lint` job)
```bash
uv run --group dev pyright
```
Pyright config lives in `pyproject.toml` (`[tool.pyright]`): basic mode over
`src/`, Python 3.12, resolving against the project `.venv`.

4. **Unit tests** (`unit` job)
```bash
uv sync
uv run python -m unittest discover -s tests/unit -v
```
Fast, no external services or credentials required.

4. **Docker build** (`docker` job)
5. **Docker build** (`docker` job)
```bash
docker build -t appwrite-mcp:ci .
```
The hosted HTTP image must build cleanly.

5. **Integration tests** (`integration` job) — *CI runs these only for pushes and
6. **Integration tests** (`integration` job) — *CI runs these only for pushes and
for PRs from branches on the same repo (not forks).* They create and delete
**real** Appwrite resources, so they need live credentials and are skipped
when absent:
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ integration = [
dev = [
"black>=25.1.0",
"ruff>=0.10.0",
"pyright>=1.1.390",
# Only needed by scripts/build_docs_index.py to (re)build the docs index.
"pyyaml>=6.0",
]
Expand All @@ -56,6 +57,13 @@ line-length = 88
select = ["E", "F", "W", "I"]
ignore = ["E501"]

[tool.pyright]
pythonVersion = "3.12"
include = ["src"]
venvPath = "."
venv = ".venv"
typeCheckingMode = "basic"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
116 changes: 78 additions & 38 deletions src/mcp_server_appwrite/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,19 @@
import time
from urllib.parse import urlsplit, urlunsplit

import anyio
import httpx
import jwt
from anyio import to_thread
from jwt import PyJWKClient
from mcp.server.auth.provider import AccessToken, TokenVerifier

from . import telemetry

DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
DEFAULT_PROJECT_ID = "console"
from .constants import (
DEFAULT_ENDPOINT,
DEFAULT_PROJECT_ID,
DISCOVERY_TTL_SECONDS,
PREFERRED_SCOPES,
)


def _log(message: str) -> None:
Expand Down Expand Up @@ -69,10 +72,30 @@ def resource_metadata_url() -> str:
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))


# Cache of scopes_supported, keyed by served project id (process lifetime; the
# project OAuth config is effectively static). Failed lookups raise and are not
# cached, so they retry.
_discovery_cache: dict[str, dict] = {}
def preferred_scopes() -> list[str]:
override = os.getenv("MCP_OAUTH_SCOPES", "").split()
return override or list(PREFERRED_SCOPES)


# Discovery cache keyed by served project id: (monotonic fetch time, document).
# Entries are refreshed after a TTL so authorization-server changes (issuer host,
# scope model) propagate without a redeploy; if a refresh fails, the stale copy
# keeps serving so an authorization-server blip doesn't take the MCP down.
_discovery_cache: dict[str, tuple[float, dict]] = {}


def _cached_discovery(project_id: str, *, allow_stale: bool = False) -> dict | None:
entry = _discovery_cache.get(project_id)
if entry is None:
return None
fetched_at, document = entry
if allow_stale or time.monotonic() - fetched_at < DISCOVERY_TTL_SECONDS:
return document
return None


def _store_discovery(project_id: str, document: dict) -> None:
_discovery_cache[project_id] = (time.monotonic(), document)


def discovery_url() -> str:
Expand All @@ -91,47 +114,68 @@ def _validate_discovery(doc: dict, url: str) -> dict:

async def authorization_server_metadata() -> dict:
project_id = configured_project_id()
cached = _discovery_cache.get(project_id)
cached = _cached_discovery(project_id)
if cached is not None:
return cached

url = discovery_url()
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
metadata = _validate_discovery(resp.json(), url)

_discovery_cache[project_id] = metadata
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(url)
resp.raise_for_status()
metadata = _validate_discovery(resp.json(), url)
except Exception as exc:
stale = _cached_discovery(project_id, allow_stale=True)
if stale is not None:
_log(f"Discovery refresh failed ({exc}); serving stale metadata.")
return stale
raise

_store_discovery(project_id, metadata)
return metadata


def authorization_server_metadata_sync() -> dict:
project_id = configured_project_id()
cached = _discovery_cache.get(project_id)
cached = _cached_discovery(project_id)
if cached is not None:
return cached

url = discovery_url()
resp = httpx.get(url, timeout=10.0, follow_redirects=True)
resp.raise_for_status()
metadata = _validate_discovery(resp.json(), url)
_discovery_cache[project_id] = metadata
try:
resp = httpx.get(url, timeout=10.0, follow_redirects=True)
resp.raise_for_status()
metadata = _validate_discovery(resp.json(), url)
except Exception as exc:
stale = _cached_discovery(project_id, allow_stale=True)
if stale is not None:
_log(f"Discovery refresh failed ({exc}); serving stale metadata.")
return stale
raise

_store_discovery(project_id, metadata)
return metadata
Comment on lines 115 to 157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Continuous retry overhead when AS is down

Both authorization_server_metadata() and authorization_server_metadata_sync() return stale data on a failed refresh, but they don't update the cached timestamp. This means every subsequent request — including every _verify_sync call, which runs in a thread-pool thread — will attempt a fresh 10-second HTTP fetch before falling back to the stale copy. Under any meaningful load during an AS outage, concurrent threads each block for the full timeout=10.0, which can exhaust the threadpool and dramatically degrade response times despite the MCP continuing to serve correct results. A simple fix is to bump the cached entry's timestamp when a stale hit is served so the entry is treated as fresh for the next TTL window.



async def supported_scopes() -> list[str]:
"""Scopes advertised in the protected-resource metadata, sourced live from the
served project's authorization-server discovery (`scopes_supported`). This is
exactly the set the project's OAuth server will grant, so it never drifts from
the tool surface. Raises if discovery is unreachable or malformed (the
authorization server is the same Appwrite deployment this MCP depends on)."""
metadata = await authorization_server_metadata()
scopes = metadata.get("scopes_supported")
if not isinstance(scopes, list):
def _advertised_scopes(metadata: dict) -> list[str]:
"""The scope set to advertise: the preferred scopes intersected with the
authorization server's live ``scopes_supported`` (so a renamed/removed scope
is never advertised). Falls back to mirroring the full discovery list when
none of the preferred scopes exist — e.g. a self-hosted project with a
custom, compact scope catalog."""
discovered = metadata.get("scopes_supported")
if not isinstance(discovered, list):
raise ValueError(
f"authorization server discovery missing scopes_supported: {discovery_url()}"
)
return scopes
scopes = [scope for scope in preferred_scopes() if scope in discovered]
if scopes:
return scopes
_log(
"None of the preferred scopes are in the authorization server's "
"scopes_supported; advertising the full discovered list."
)
return discovered
Comment on lines +160 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 MCP_OAUTH_SCOPES override silently falls back to the full catalog

When MCP_OAUTH_SCOPES is set but none of the specified scopes exist in the AS's scopes_supported, _advertised_scopes falls back to mirroring the full discovered list. For Cloud, that's 120+ scopes — the overflow condition the env var is specifically meant to prevent. An operator who sets this variable to work around a non-standard AS catalog would find it has no effect, and the same scope-parameter overflow will occur. The fallback log message fires, but the behavior is counter-intuitive and the current tests only cover the case where the override scopes exist in the catalog.



def build_resource_metadata(scopes: list[str], authorization_servers=None) -> dict:
Expand All @@ -145,14 +189,10 @@ def build_resource_metadata(scopes: list[str], authorization_servers=None) -> di


async def protected_resource_metadata() -> dict:
"""RFC 9728 Protected Resource Metadata, with scopes sourced from AS discovery."""
"""RFC 9728 Protected Resource Metadata, with scopes validated against AS
discovery."""
metadata = await authorization_server_metadata()
scopes = metadata.get("scopes_supported")
if not isinstance(scopes, list):
raise ValueError(
f"authorization server discovery missing scopes_supported: {discovery_url()}"
)
return build_resource_metadata(scopes, [metadata["issuer"]])
return build_resource_metadata(_advertised_scopes(metadata), [metadata["issuer"]])


def project_id_from_issuer(iss: str | None) -> str | None:
Expand Down Expand Up @@ -286,7 +326,7 @@ def _audience_ok(self, aud, expected_resource: str) -> bool:

async def verify_token(self, token: str) -> AccessToken | None:
start = time.monotonic()
access_token = await anyio.to_thread.run_sync(self._verify_sync, token)
access_token = await to_thread.run_sync(self._verify_sync, token)
duration = time.monotonic() - start
if access_token is None:
# The specific rejection reason was already counted in _verify_sync;
Expand Down
144 changes: 144 additions & 0 deletions src/mcp_server_appwrite/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Single home for the package's constants, grouped by the module that uses them."""

from __future__ import annotations

from pathlib import Path

from appwrite.models.bucket import Bucket
from appwrite.models.database import Database
from appwrite.models.function import Function
from appwrite.models.message import Message
from appwrite.models.site import Site
from appwrite.models.team import Team
from appwrite.models.user import User

# --- server ---------------------------------------------------------------

SERVER_VERSION = "0.8.1"

DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
DEFAULT_TRANSPORT = "stdio"
TRANSPORTS = {"stdio", "http"}
VALIDATION_SERVICE_ORDER = (
"tables_db",
"users",
"teams",
"functions",
"sites",
"storage",
"messaging",
"locale",
"avatars",
)

# Service modules in the Appwrite SDK to skip (none by default — every service the
# installed SDK ships is exposed). Add a module name here to hide a service.
EXCLUDED_SERVICES: frozenset[str] = frozenset()

MAX_FETCH_BYTES = 25 * 1024 * 1024 # 25 MB cap on server-fetched files
MAX_INLINE_BYTES = 256 * 1024 # 256 KB cap on decoded inline content
FETCH_TIMEOUT_SECONDS = 30.0
FETCH_MAX_REDIRECTS = 5

HOSTED_PATH_GUIDANCE = (
"The hosted Appwrite MCP server cannot read local file paths. For '{param}', pass a "
'public URL as {{"url": "https://..."}} (preferred), or a small file inline as '
'{{"filename": "...", "content": "<base64>", "encoding": "base64"}}.'
)

# --- auth -----------------------------------------------------------------

DEFAULT_PROJECT_ID = "console"

PREFERRED_SCOPES = [
"openid",
"profile",
"email",
"all",
"project:all",
"organization:all",
]

DISCOVERY_TTL_SECONDS = 300.0

# --- http_app -------------------------------------------------------------

CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
"Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate",
}

# --- operator -------------------------------------------------------------

SEARCH_LIMIT = 8
PREVIEW_THRESHOLD = 800
RESULT_STORE_SIZE = 50
CATALOG_URI = "appwrite://operator/catalog"
RESULT_URI_TEMPLATE = "appwrite://operator/results/{result_id}"
VERBS = {"list", "get", "create", "update", "delete"}
READ_VERBS = {"list", "get"}
CREATE_HINTS = {"add", "build", "create", "insert", "make", "new", "provision"}
UPDATE_HINTS = {"change", "edit", "modify", "rename", "set", "update"}
DELETE_HINTS = {"delete", "destroy", "drop", "remove"}
READ_HINTS = {"fetch", "find", "get", "list", "read", "search", "show", "view"}

# --- docs_search ----------------------------------------------------------

DOCS_TOOL_NAME = "appwrite_search_docs"
EMBED_MODEL = "text-embedding-3-small"
DOCS_DEFAULT_LIMIT = 5
DOCS_MAX_LIMIT = 10
DOCS_DEFAULT_MIN_SCORE = 0.25
DOCS_MIN_QUERY_LENGTH = 3

DATA_DIR = Path(__file__).parent / "data"
VECTORS_FILE = "docs_index.npz"
META_FILE = "docs_index_meta.json"

# --- context --------------------------------------------------------------

SERVICE_PROBES = {
"tablesdb": {
"path": "/tablesdb",
"items_key": "databases",
"model": Database,
},
"users": {
"path": "/users",
"items_key": "users",
"model": User,
},
"storage": {
"path": "/storage/buckets",
"items_key": "buckets",
"model": Bucket,
},
"functions": {
"path": "/functions",
"items_key": "functions",
"model": Function,
},
"sites": {
"path": "/sites",
"items_key": "sites",
"model": Site,
},
"messaging": {
"path": "/messaging/messages",
"items_key": "messages",
"model": Message,
},
"teams": {
"path": "/teams",
"items_key": "teams",
"model": Team,
},
}

REDACTED_KEYS = {"password", "secret", "key", "token", "otp", "cookie", "session"}

# --- telemetry ------------------------------------------------------------

ACTIVE_WINDOW_SECONDS = 300.0 # rolling window for "active users/clients" gauges
Loading
Loading