Skip to content

Commit ba0b18e

Browse files
authored
Merge pull request #53 from appwrite/fix/oauth-cloud-scope-model
Restore OAuth against the reworked Cloud scope model
2 parents b57c528 + 296c44e commit ba0b18e

17 files changed

Lines changed: 486 additions & 218 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ jobs:
3333
- name: Check formatting
3434
run: uv run --group dev black --check src tests
3535

36+
- name: Type check
37+
run: uv run --group dev pyright
38+
3639
unit:
3740
name: Unit
3841
runs-on: ubuntu-latest

AGENTS.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -93,20 +93,27 @@ Run these locally before opening a PR. They mirror the `CI` workflow
9393
```
9494
Run `uv run --group dev black src tests` (without `--check`) to auto-fix.
9595

96-
3. **Unit tests** (`unit` job)
96+
3. **Type check** (`lint` job)
97+
```bash
98+
uv run --group dev pyright
99+
```
100+
Pyright config lives in `pyproject.toml` (`[tool.pyright]`): basic mode over
101+
`src/`, Python 3.12, resolving against the project `.venv`.
102+
103+
4. **Unit tests** (`unit` job)
97104
```bash
98105
uv sync
99106
uv run python -m unittest discover -s tests/unit -v
100107
```
101108
Fast, no external services or credentials required.
102109

103-
4. **Docker build** (`docker` job)
110+
5. **Docker build** (`docker` job)
104111
```bash
105112
docker build -t appwrite-mcp:ci .
106113
```
107114
The hosted HTTP image must build cleanly.
108115

109-
5. **Integration tests** (`integration` job) — *CI runs these only for pushes and
116+
6. **Integration tests** (`integration` job) — *CI runs these only for pushes and
110117
for PRs from branches on the same repo (not forks).* They create and delete
111118
**real** Appwrite resources, so they need live credentials and are skipped
112119
when absent:

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ integration = [
3232
dev = [
3333
"black>=25.1.0",
3434
"ruff>=0.10.0",
35+
"pyright>=1.1.390",
3536
# Only needed by scripts/build_docs_index.py to (re)build the docs index.
3637
"pyyaml>=6.0",
3738
]
@@ -56,6 +57,13 @@ line-length = 88
5657
select = ["E", "F", "W", "I"]
5758
ignore = ["E501"]
5859

60+
[tool.pyright]
61+
pythonVersion = "3.12"
62+
include = ["src"]
63+
venvPath = "."
64+
venv = ".venv"
65+
typeCheckingMode = "basic"
66+
5967
[build-system]
6068
requires = ["hatchling"]
6169
build-backend = "hatchling.build"

src/mcp_server_appwrite/auth.py

Lines changed: 78 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,19 @@
1919
import time
2020
from urllib.parse import urlsplit, urlunsplit
2121

22-
import anyio
2322
import httpx
2423
import jwt
24+
from anyio import to_thread
2525
from jwt import PyJWKClient
2626
from mcp.server.auth.provider import AccessToken, TokenVerifier
2727

2828
from . import telemetry
29-
30-
DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
31-
DEFAULT_PROJECT_ID = "console"
29+
from .constants import (
30+
DEFAULT_ENDPOINT,
31+
DEFAULT_PROJECT_ID,
32+
DISCOVERY_TTL_SECONDS,
33+
PREFERRED_SCOPES,
34+
)
3235

3336

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

7174

72-
# Cache of scopes_supported, keyed by served project id (process lifetime; the
73-
# project OAuth config is effectively static). Failed lookups raise and are not
74-
# cached, so they retry.
75-
_discovery_cache: dict[str, dict] = {}
75+
def preferred_scopes() -> list[str]:
76+
override = os.getenv("MCP_OAUTH_SCOPES", "").split()
77+
return override or list(PREFERRED_SCOPES)
78+
79+
80+
# Discovery cache keyed by served project id: (monotonic fetch time, document).
81+
# Entries are refreshed after a TTL so authorization-server changes (issuer host,
82+
# scope model) propagate without a redeploy; if a refresh fails, the stale copy
83+
# keeps serving so an authorization-server blip doesn't take the MCP down.
84+
_discovery_cache: dict[str, tuple[float, dict]] = {}
85+
86+
87+
def _cached_discovery(project_id: str, *, allow_stale: bool = False) -> dict | None:
88+
entry = _discovery_cache.get(project_id)
89+
if entry is None:
90+
return None
91+
fetched_at, document = entry
92+
if allow_stale or time.monotonic() - fetched_at < DISCOVERY_TTL_SECONDS:
93+
return document
94+
return None
95+
96+
97+
def _store_discovery(project_id: str, document: dict) -> None:
98+
_discovery_cache[project_id] = (time.monotonic(), document)
7699

77100

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

92115
async def authorization_server_metadata() -> dict:
93116
project_id = configured_project_id()
94-
cached = _discovery_cache.get(project_id)
117+
cached = _cached_discovery(project_id)
95118
if cached is not None:
96119
return cached
97120

98121
url = discovery_url()
99-
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
100-
resp = await client.get(url)
101-
resp.raise_for_status()
102-
metadata = _validate_discovery(resp.json(), url)
103-
104-
_discovery_cache[project_id] = metadata
122+
try:
123+
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
124+
resp = await client.get(url)
125+
resp.raise_for_status()
126+
metadata = _validate_discovery(resp.json(), url)
127+
except Exception as exc:
128+
stale = _cached_discovery(project_id, allow_stale=True)
129+
if stale is not None:
130+
_log(f"Discovery refresh failed ({exc}); serving stale metadata.")
131+
return stale
132+
raise
133+
134+
_store_discovery(project_id, metadata)
105135
return metadata
106136

107137

108138
def authorization_server_metadata_sync() -> dict:
109139
project_id = configured_project_id()
110-
cached = _discovery_cache.get(project_id)
140+
cached = _cached_discovery(project_id)
111141
if cached is not None:
112142
return cached
113143

114144
url = discovery_url()
115-
resp = httpx.get(url, timeout=10.0, follow_redirects=True)
116-
resp.raise_for_status()
117-
metadata = _validate_discovery(resp.json(), url)
118-
_discovery_cache[project_id] = metadata
145+
try:
146+
resp = httpx.get(url, timeout=10.0, follow_redirects=True)
147+
resp.raise_for_status()
148+
metadata = _validate_discovery(resp.json(), url)
149+
except Exception as exc:
150+
stale = _cached_discovery(project_id, allow_stale=True)
151+
if stale is not None:
152+
_log(f"Discovery refresh failed ({exc}); serving stale metadata.")
153+
return stale
154+
raise
155+
156+
_store_discovery(project_id, metadata)
119157
return metadata
120158

121159

122-
async def supported_scopes() -> list[str]:
123-
"""Scopes advertised in the protected-resource metadata, sourced live from the
124-
served project's authorization-server discovery (`scopes_supported`). This is
125-
exactly the set the project's OAuth server will grant, so it never drifts from
126-
the tool surface. Raises if discovery is unreachable or malformed (the
127-
authorization server is the same Appwrite deployment this MCP depends on)."""
128-
metadata = await authorization_server_metadata()
129-
scopes = metadata.get("scopes_supported")
130-
if not isinstance(scopes, list):
160+
def _advertised_scopes(metadata: dict) -> list[str]:
161+
"""The scope set to advertise: the preferred scopes intersected with the
162+
authorization server's live ``scopes_supported`` (so a renamed/removed scope
163+
is never advertised). Falls back to mirroring the full discovery list when
164+
none of the preferred scopes exist — e.g. a self-hosted project with a
165+
custom, compact scope catalog."""
166+
discovered = metadata.get("scopes_supported")
167+
if not isinstance(discovered, list):
131168
raise ValueError(
132169
f"authorization server discovery missing scopes_supported: {discovery_url()}"
133170
)
134-
return scopes
171+
scopes = [scope for scope in preferred_scopes() if scope in discovered]
172+
if scopes:
173+
return scopes
174+
_log(
175+
"None of the preferred scopes are in the authorization server's "
176+
"scopes_supported; advertising the full discovered list."
177+
)
178+
return discovered
135179

136180

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

146190

147191
async def protected_resource_metadata() -> dict:
148-
"""RFC 9728 Protected Resource Metadata, with scopes sourced from AS discovery."""
192+
"""RFC 9728 Protected Resource Metadata, with scopes validated against AS
193+
discovery."""
149194
metadata = await authorization_server_metadata()
150-
scopes = metadata.get("scopes_supported")
151-
if not isinstance(scopes, list):
152-
raise ValueError(
153-
f"authorization server discovery missing scopes_supported: {discovery_url()}"
154-
)
155-
return build_resource_metadata(scopes, [metadata["issuer"]])
195+
return build_resource_metadata(_advertised_scopes(metadata), [metadata["issuer"]])
156196

157197

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

287327
async def verify_token(self, token: str) -> AccessToken | None:
288328
start = time.monotonic()
289-
access_token = await anyio.to_thread.run_sync(self._verify_sync, token)
329+
access_token = await to_thread.run_sync(self._verify_sync, token)
290330
duration = time.monotonic() - start
291331
if access_token is None:
292332
# The specific rejection reason was already counted in _verify_sync;
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Single home for the package's constants, grouped by the module that uses them."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from appwrite.models.bucket import Bucket
8+
from appwrite.models.database import Database
9+
from appwrite.models.function import Function
10+
from appwrite.models.message import Message
11+
from appwrite.models.site import Site
12+
from appwrite.models.team import Team
13+
from appwrite.models.user import User
14+
15+
# --- server ---------------------------------------------------------------
16+
17+
SERVER_VERSION = "0.8.1"
18+
19+
DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
20+
DEFAULT_TRANSPORT = "stdio"
21+
TRANSPORTS = {"stdio", "http"}
22+
VALIDATION_SERVICE_ORDER = (
23+
"tables_db",
24+
"users",
25+
"teams",
26+
"functions",
27+
"sites",
28+
"storage",
29+
"messaging",
30+
"locale",
31+
"avatars",
32+
)
33+
34+
# Service modules in the Appwrite SDK to skip (none by default — every service the
35+
# installed SDK ships is exposed). Add a module name here to hide a service.
36+
EXCLUDED_SERVICES: frozenset[str] = frozenset()
37+
38+
MAX_FETCH_BYTES = 25 * 1024 * 1024 # 25 MB cap on server-fetched files
39+
MAX_INLINE_BYTES = 256 * 1024 # 256 KB cap on decoded inline content
40+
FETCH_TIMEOUT_SECONDS = 30.0
41+
FETCH_MAX_REDIRECTS = 5
42+
43+
HOSTED_PATH_GUIDANCE = (
44+
"The hosted Appwrite MCP server cannot read local file paths. For '{param}', pass a "
45+
'public URL as {{"url": "https://..."}} (preferred), or a small file inline as '
46+
'{{"filename": "...", "content": "<base64>", "encoding": "base64"}}.'
47+
)
48+
49+
# --- auth -----------------------------------------------------------------
50+
51+
DEFAULT_PROJECT_ID = "console"
52+
53+
PREFERRED_SCOPES = [
54+
"openid",
55+
"profile",
56+
"email",
57+
"all",
58+
"project:all",
59+
"organization:all",
60+
]
61+
62+
DISCOVERY_TTL_SECONDS = 300.0
63+
64+
# --- http_app -------------------------------------------------------------
65+
66+
CORS_HEADERS = {
67+
"Access-Control-Allow-Origin": "*",
68+
"Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS",
69+
"Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
70+
"Access-Control-Expose-Headers": "Mcp-Session-Id, WWW-Authenticate",
71+
}
72+
73+
# --- operator -------------------------------------------------------------
74+
75+
SEARCH_LIMIT = 8
76+
PREVIEW_THRESHOLD = 800
77+
RESULT_STORE_SIZE = 50
78+
CATALOG_URI = "appwrite://operator/catalog"
79+
RESULT_URI_TEMPLATE = "appwrite://operator/results/{result_id}"
80+
VERBS = {"list", "get", "create", "update", "delete"}
81+
READ_VERBS = {"list", "get"}
82+
CREATE_HINTS = {"add", "build", "create", "insert", "make", "new", "provision"}
83+
UPDATE_HINTS = {"change", "edit", "modify", "rename", "set", "update"}
84+
DELETE_HINTS = {"delete", "destroy", "drop", "remove"}
85+
READ_HINTS = {"fetch", "find", "get", "list", "read", "search", "show", "view"}
86+
87+
# --- docs_search ----------------------------------------------------------
88+
89+
DOCS_TOOL_NAME = "appwrite_search_docs"
90+
EMBED_MODEL = "text-embedding-3-small"
91+
DOCS_DEFAULT_LIMIT = 5
92+
DOCS_MAX_LIMIT = 10
93+
DOCS_DEFAULT_MIN_SCORE = 0.25
94+
DOCS_MIN_QUERY_LENGTH = 3
95+
96+
DATA_DIR = Path(__file__).parent / "data"
97+
VECTORS_FILE = "docs_index.npz"
98+
META_FILE = "docs_index_meta.json"
99+
100+
# --- context --------------------------------------------------------------
101+
102+
SERVICE_PROBES = {
103+
"tablesdb": {
104+
"path": "/tablesdb",
105+
"items_key": "databases",
106+
"model": Database,
107+
},
108+
"users": {
109+
"path": "/users",
110+
"items_key": "users",
111+
"model": User,
112+
},
113+
"storage": {
114+
"path": "/storage/buckets",
115+
"items_key": "buckets",
116+
"model": Bucket,
117+
},
118+
"functions": {
119+
"path": "/functions",
120+
"items_key": "functions",
121+
"model": Function,
122+
},
123+
"sites": {
124+
"path": "/sites",
125+
"items_key": "sites",
126+
"model": Site,
127+
},
128+
"messaging": {
129+
"path": "/messaging/messages",
130+
"items_key": "messages",
131+
"model": Message,
132+
},
133+
"teams": {
134+
"path": "/teams",
135+
"items_key": "teams",
136+
"model": Team,
137+
},
138+
}
139+
140+
REDACTED_KEYS = {"password", "secret", "key", "token", "otp", "cookie", "session"}
141+
142+
# --- telemetry ------------------------------------------------------------
143+
144+
ACTIVE_WINDOW_SECONDS = 300.0 # rolling window for "active users/clients" gauges

0 commit comments

Comments
 (0)