-
Notifications
You must be signed in to change notification settings - Fork 17
Restore OAuth against the reworked Cloud scope model #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
|
@@ -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: | ||
|
|
@@ -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 | ||
|
|
||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When |
||
|
|
||
|
|
||
| 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 | |
|
|
||
|
|
||
| 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: | ||
|
|
@@ -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; | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both
authorization_server_metadata()andauthorization_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_synccall, 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 fulltimeout=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.