2222from pathlib import Path
2323from types import UnionType
2424from typing import Any , Union , get_args , get_origin
25- from urllib .parse import unquote , urlsplit
25+ from urllib .parse import unquote , urlsplit , urlunsplit
2626
2727import httpx
2828import mcp .server .stdio
4141
4242from . import telemetry
4343from .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+
224290def 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 )
0 commit comments