1919import time
2020from urllib .parse import urlsplit , urlunsplit
2121
22- import anyio
2322import httpx
2423import jwt
24+ from anyio import to_thread
2525from jwt import PyJWKClient
2626from mcp .server .auth .provider import AccessToken , TokenVerifier
2727
2828from . 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
3437def _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
78101def discovery_url () -> str :
@@ -91,47 +114,68 @@ def _validate_discovery(doc: dict, url: str) -> dict:
91114
92115async 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
108138def 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
137181def 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
147191async 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
158198def 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;
0 commit comments