Skip to content

Commit 5d42eac

Browse files
authored
Merge pull request #58 from appwrite/fix/cross-region-project-routing
Route project-scoped calls to the project's home region
2 parents 975a39d + 3256d50 commit 5d42eac

5 files changed

Lines changed: 139 additions & 8 deletions

File tree

src/mcp_server_appwrite/auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@
2727

2828
from . import telemetry
2929
from .constants import (
30+
CACHE_TTL_SECONDS,
3031
DEFAULT_ENDPOINT,
3132
DEFAULT_PROJECT_ID,
32-
DISCOVERY_TTL_SECONDS,
3333
PREFERRED_SCOPES,
3434
)
3535

@@ -89,7 +89,7 @@ def _cached_discovery(project_id: str, *, allow_stale: bool = False) -> dict | N
8989
if entry is None:
9090
return None
9191
fetched_at, document = entry
92-
if allow_stale or time.monotonic() - fetched_at < DISCOVERY_TTL_SECONDS:
92+
if allow_stale or time.monotonic() - fetched_at < CACHE_TTL_SECONDS:
9393
return document
9494
return None
9595

src/mcp_server_appwrite/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
SERVER_VERSION = "0.8.3"
1818

1919
DEFAULT_ENDPOINT = "https://cloud.appwrite.io/v1"
20+
# Region reported by single-region deployments; carries no region subdomain.
21+
DEFAULT_REGION = "default"
2022
DEFAULT_TRANSPORT = "stdio"
2123
TRANSPORTS = {"stdio", "http"}
2224
VALIDATION_SERVICE_ORDER = (
@@ -57,7 +59,8 @@
5759
"all",
5860
]
5961

60-
DISCOVERY_TTL_SECONDS = 300.0
62+
# Shared TTL for cached upstream lookups (OAuth discovery, project regions).
63+
CACHE_TTL_SECONDS = 300.0
6164

6265
# --- http_app -------------------------------------------------------------
6366

src/mcp_server_appwrite/server.py

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from pathlib import Path
2323
from types import UnionType
2424
from typing import Any, Union, get_args, get_origin
25-
from urllib.parse import unquote, urlsplit
25+
from urllib.parse import unquote, urlsplit, urlunsplit
2626

2727
import httpx
2828
import mcp.server.stdio
@@ -41,8 +41,10 @@
4141

4242
from . import telemetry
4343
from .constants import (
44+
CACHE_TTL_SECONDS,
4445
CATALOG_URI,
4546
DEFAULT_ENDPOINT,
47+
DEFAULT_REGION,
4648
DEFAULT_TRANSPORT,
4749
EXCLUDED_SERVICES,
4850
FETCH_MAX_REDIRECTS,
@@ -221,14 +223,79 @@ def build_client_for_request(
221223
return client
222224

223225

226+
# Appwrite Cloud is multi-region: project metadata is global (any gateway can
227+
# list projects), but a project's data plane lives only in its home region and
228+
# must be addressed through the region subdomain (e.g.
229+
# ``https://sgp.cloud.appwrite.io/v1``); other gateways reject project-scoped
230+
# calls with 401 general_access_forbidden. Regions are looked up once per
231+
# project via the console API and cached briefly; the 'default' sentinel is
232+
# cached like any region, so a project that migrates regions may be routed to
233+
# its old home for up to CACHE_TTL_SECONDS.
234+
_project_region_cache: dict[str, tuple[str, float]] = {}
235+
236+
237+
def resolve_region_endpoint(base_endpoint: str, region: str | None) -> str:
238+
"""Return the endpoint for a project homed in ``region`` by prefixing the
239+
region subdomain onto the configured endpoint. Single-region deployments
240+
(which report the ``default`` region), malformed regions, and endpoints
241+
already prefixed with the region pass through unchanged."""
242+
if not region or region == DEFAULT_REGION or not region.isalnum():
243+
return base_endpoint
244+
split = urlsplit(base_endpoint)
245+
hostname = split.hostname or ""
246+
if not hostname or hostname.startswith(f"{region}."):
247+
return base_endpoint
248+
if "@" in split.netloc:
249+
# Prefixing the netloc would land the region on the userinfo, not the
250+
# host; credential-bearing endpoints are never regional, so pass through.
251+
return base_endpoint
252+
return urlunsplit(split._replace(netloc=f"{region}.{split.netloc}"))
253+
254+
255+
def _lookup_project_region(
256+
console_project_id: str, bearer_token: str, target_project: str
257+
) -> str | None:
258+
"""Fetch ``target_project``'s home region from the console API. Project
259+
metadata is global, so this succeeds from any gateway. Successful lookups
260+
are cached; on failure the caller falls back to the configured endpoint,
261+
preserving the previous behavior."""
262+
cached = _project_region_cache.get(target_project)
263+
if cached and cached[1] > time.monotonic():
264+
return cached[0]
265+
client = build_client_for_request(console_project_id, bearer_token)
266+
try:
267+
project = client.call(
268+
"get",
269+
f"/projects/{target_project}",
270+
# The SDK does not turn set_project into a header on raw call();
271+
# send the console project header explicitly (as context.py does).
272+
headers={
273+
"accept": "application/json",
274+
"x-appwrite-project": console_project_id,
275+
},
276+
params={},
277+
)
278+
except Exception:
279+
return None
280+
region = project.get("region") if isinstance(project, dict) else None
281+
if not isinstance(region, str) or not region:
282+
return None
283+
_project_region_cache[target_project] = (
284+
region,
285+
time.monotonic() + CACHE_TTL_SECONDS,
286+
)
287+
return region
288+
289+
224290
def resolve_client(
225291
target_project: str | None = None, organization_id: str | None = None
226292
) -> Client:
227293
"""Build the Appwrite client for the current request from its OAuth access
228294
token. The token is read from the request context populated by the auth
229295
middleware and carries the project it was issued for (the console). Pass
230296
``target_project``/``organization_id`` to scope the call to one of the user's
231-
own projects/organizations."""
297+
own projects/organizations. Project-scoped calls are routed to the target
298+
project's home-region endpoint (see ``resolve_region_endpoint``)."""
232299
access_token = get_access_token()
233300
if access_token is None:
234301
raise RuntimeError("No authenticated Appwrite access token in request context.")
@@ -237,9 +304,16 @@ def resolve_client(
237304
project_id = claims.get("project_id")
238305
if not project_id:
239306
raise RuntimeError("Authenticated token is missing a project identifier.")
307+
308+
base_endpoint = os.getenv("APPWRITE_ENDPOINT", DEFAULT_ENDPOINT)
309+
endpoint = base_endpoint
310+
if target_project:
311+
region = _lookup_project_region(project_id, access_token.token, target_project)
312+
endpoint = resolve_region_endpoint(base_endpoint, region)
240313
return build_client_for_request(
241314
project_id,
242315
access_token.token,
316+
endpoint=endpoint,
243317
target_project=target_project,
244318
organization_id=organization_id,
245319
)

tests/unit/test_auth.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def test_discovery_cache_expires_after_ttl(self):
127127
# Age the entry past the TTL.
128128
fetched_at, doc = auth._discovery_cache[pid]
129129
auth._discovery_cache[pid] = (
130-
fetched_at - auth.DISCOVERY_TTL_SECONDS - 1,
130+
fetched_at - auth.CACHE_TTL_SECONDS - 1,
131131
doc,
132132
)
133133
self.assertIsNone(auth._cached_discovery(pid))
@@ -142,7 +142,7 @@ def test_stale_discovery_served_when_refresh_fails(self):
142142
auth._store_discovery(pid, stale_doc)
143143
fetched_at, doc = auth._discovery_cache[pid]
144144
auth._discovery_cache[pid] = (
145-
fetched_at - auth.DISCOVERY_TTL_SECONDS - 1,
145+
fetched_at - auth.CACHE_TTL_SECONDS - 1,
146146
doc,
147147
)
148148

tests/unit/test_server.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import tempfile
66
import unittest
77
from pathlib import Path
8-
from unittest.mock import patch
8+
from unittest.mock import Mock, patch
99

1010
import mcp.types as types
1111
from appwrite.enums.browser import Browser
@@ -23,6 +23,7 @@
2323
build_operator,
2424
parse_args,
2525
register_services,
26+
resolve_region_endpoint,
2627
validate_services,
2728
)
2829
from mcp_server_appwrite.tool_manager import ToolManager
@@ -617,5 +618,58 @@ def test_http_instructions_mention_url_upload(self):
617618
self.assertNotIn("upload", stdio.lower())
618619

619620

621+
class RegionRoutingTests(unittest.TestCase):
622+
BASE = "https://cloud.appwrite.io/v1"
623+
624+
def setUp(self):
625+
server_module._project_region_cache.clear()
626+
627+
def test_resolve_region_endpoint(self):
628+
self.assertEqual(
629+
resolve_region_endpoint(self.BASE, "sgp"),
630+
"https://sgp.cloud.appwrite.io/v1",
631+
)
632+
# No region, single-region deployments, malformed regions, and
633+
# already-prefixed endpoints pass through unchanged.
634+
for region in (None, "default", "sgp.evil.example", "sgp/../x", ""):
635+
self.assertEqual(resolve_region_endpoint(self.BASE, region), self.BASE)
636+
prefixed = "https://sgp.cloud.appwrite.io/v1"
637+
self.assertEqual(resolve_region_endpoint(prefixed, "sgp"), prefixed)
638+
639+
def test_lookup_project_region_caches_successful_lookups(self):
640+
client = Mock()
641+
client.call.return_value = {"region": "sgp"}
642+
with patch.object(
643+
server_module, "build_client_for_request", return_value=client
644+
):
645+
for _ in range(2):
646+
region = server_module._lookup_project_region("console", "tok", "proj")
647+
self.assertEqual(region, "sgp")
648+
client.call.assert_called_once()
649+
650+
def test_lookup_project_region_failure_falls_back_uncached(self):
651+
client = Mock()
652+
client.call.side_effect = RuntimeError("console unavailable")
653+
with patch.object(
654+
server_module, "build_client_for_request", return_value=client
655+
):
656+
self.assertIsNone(
657+
server_module._lookup_project_region("console", "tok", "proj")
658+
)
659+
self.assertEqual(server_module._project_region_cache, {})
660+
661+
def test_resolve_client_routes_target_project_to_home_region(self):
662+
token = Mock(token="tok", claims={"project_id": "console"})
663+
with (
664+
patch.dict(os.environ, {}, clear=True),
665+
patch.object(server_module, "get_access_token", return_value=token),
666+
patch.object(server_module, "_lookup_project_region", return_value="sgp"),
667+
):
668+
client = server_module.resolve_client(target_project="proj")
669+
# _endpoint is SDK-internal, but it is the only place the resolved
670+
# endpoint is observable without a network call (context.py reads it too).
671+
self.assertEqual(client._endpoint, "https://sgp.cloud.appwrite.io/v1")
672+
673+
620674
if __name__ == "__main__":
621675
unittest.main()

0 commit comments

Comments
 (0)