diff --git a/.gitignore b/.gitignore index 53583ac..7e8e6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,7 @@ wheels/ # VSCode .vscode -.env \ No newline at end of file +.env +# Playwright MCP artifacts +.playwright-mcp/ +.DS_Store diff --git a/README.md b/README.md index af9f7a3..4e1725e 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ through your browser. The first time you connect, your client opens an Appwrite consent screen; approve the scopes and you're connected. There are no keys to copy. +![How the Appwrite MCP server handles OAuth and scopes](docs/appwrite-mcp-flow.svg) + ## Connect your client Pick your client below. Each adds the hosted Appwrite Cloud server. diff --git a/docs/appwrite-mcp-flow.png b/docs/appwrite-mcp-flow.png new file mode 100644 index 0000000..61f5cc1 Binary files /dev/null and b/docs/appwrite-mcp-flow.png differ diff --git a/docs/appwrite-mcp-flow.svg b/docs/appwrite-mcp-flow.svg new file mode 100644 index 0000000..c97b965 --- /dev/null +++ b/docs/appwrite-mcp-flow.svg @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + token in flight + + no token yet + + + + + AUTHORIZATION + User signs in + approves narrowed scopes + + 4 + ← access token returned to MCP client + + + + + + + + + MCP client + + + + + + + Claude + + + + + + Cursor + + + + + + Codex + + + + + + + OpenCode + + + + DISCOVERY + POST /mcp — no token + + + 1 + + 401 · full scope catalog + + + 2 + + /.well-known → auth server URL + + + 3 + + TOOL CALLS + Bearer token · tool calls + + + 5 + + + + Appwrite MCP server + mcp.appwrite.io/mcp + + + Verify token + issuer · RS256 via JWKS · aud = /mcp + + + 4 public tools + + + get_context + + search_tools + + call_tool + + search_docs + + + hidden catalog · 25 services · 100s of tools + writes need confirm_write: true + + + SCOPED REST ACCESS + same bearer, forwarded + + + 6 + + + + Appwrite Cloud ☁️ + /v1/oauth2/<project> + + + + Authorize access + + + + openid · email + + + + project:users.read + + + + project:databases.write + + + project:all + + + all + + the user narrows scopes here + + + + REST API + enforces scopes per route + denied → 401 + + + X-Appwrite-Project + + X-Appwrite-Mode: admin + diff --git a/src/mcp_server_appwrite/auth.py b/src/mcp_server_appwrite/auth.py index c77c2c9..15228e1 100644 --- a/src/mcp_server_appwrite/auth.py +++ b/src/mcp_server_appwrite/auth.py @@ -158,17 +158,21 @@ def authorization_server_metadata_sync() -> dict: 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.""" + """The scope set to advertise. By default (no curated list) this mirrors the + authorization server's full live ``scopes_supported`` catalog — clients then + request everything and consent-time narrowing is the control point. When a + curated list is configured (``MCP_OAUTH_SCOPES``), it is intersected with + the discovered catalog so a renamed/removed scope is never advertised, + falling back to the full discovered list when the intersection is empty.""" discovered = metadata.get("scopes_supported") if not isinstance(discovered, list): raise ValueError( f"authorization server discovery missing scopes_supported: {discovery_url()}" ) - scopes = [scope for scope in preferred_scopes() if scope in discovered] + preferred = preferred_scopes() + if not preferred: + return discovered + scopes = [scope for scope in preferred if scope in discovered] if scopes: return scopes _log( diff --git a/src/mcp_server_appwrite/constants.py b/src/mcp_server_appwrite/constants.py index 173d0f7..ead4859 100644 --- a/src/mcp_server_appwrite/constants.py +++ b/src/mcp_server_appwrite/constants.py @@ -50,12 +50,11 @@ DEFAULT_PROJECT_ID = "console" -PREFERRED_SCOPES = [ - "openid", - "profile", - "email", - "all", -] +# Curated scope allowlist. Empty means "no curation": the MCP mirrors the +# authorization server's full ``scopes_supported`` catalog so clients request +# every granular scope and the consent screen becomes the narrowing control +# point. A deployment can still pin a curated set via ``MCP_OAUTH_SCOPES``. +PREFERRED_SCOPES: list[str] = [] DISCOVERY_TTL_SECONDS = 300.0 diff --git a/src/mcp_server_appwrite/context.py b/src/mcp_server_appwrite/context.py index f021803..0dd8f88 100644 --- a/src/mcp_server_appwrite/context.py +++ b/src/mcp_server_appwrite/context.py @@ -92,6 +92,30 @@ def get_appwrite_context( ) ) + if not projects: + # Per-organization project listings need organization-tier scopes; a + # grant narrowed to project scopes only still enumerates its bound + # projects through the accessible-resources endpoint. + for accessible in _accessible_resource_ids(console_client, "projects"): + accessible_id = accessible.get("$id") + if not isinstance(accessible_id, str) or not accessible_id: + continue + if project_id and accessible_id != project_id: + continue + project_client = client_factory(accessible_id, organization_id) + project = _get_current_project(project_client, accessible_id) or { + "$id": accessible_id + } + projects.append( + _project_context( + project, + project_client, + organization_id=organization_id, + include_services=include_services, + sample_limit=sample_limit, + ) + ) + if project_id and not projects: project_client = client_factory(project_id, organization_id) project = _get_current_project(project_client, project_id) or { @@ -153,11 +177,45 @@ def _get_current_project(client: Client, project_id: str) -> dict[str, Any] | No return {"$id": project_id, "error": result.error} if not result.ok else None +def _accessible_resource_ids(client: Client, resource: str) -> list[dict[str, Any]]: + """Fallback discovery via the OAuth2 accessible-resources endpoints + (``/oauth2//organizations`` / ``/oauth2//projects``). + Their scopes are granted on every token, so a consent-narrowed grant (no + console-wide ``all``) can still enumerate exactly the resources it is + bound to. Returns id-only documents.""" + project_id = client.get_config("project") or "" + if not project_id: + return [] + result = _safe_call(client, "get", f"/oauth2/{project_id}/{resource}") + if not result.ok or not isinstance(result.value, dict): + return [] + items = result.value.get(resource) + if not isinstance(items, list): + return [] + return [ + {"$id": item["$id"]} + for item in items + if isinstance(item, dict) and isinstance(item.get("$id"), str) + ] + + def _list_organizations( client: Client, organization_id: str | None ) -> list[dict[str, Any]]: result = _safe_call(client, "get", "/organizations") if not result.ok or not isinstance(result.value, dict): + # The console-wide organizations listing needs scopes only the `all` + # grant carries; a narrowed grant falls back to the accessible- + # resources enumeration (ids only, names resolved by later calls). + accessible = _accessible_resource_ids(client, "organizations") + if accessible: + if organization_id: + return [ + organization + for organization in accessible + if organization.get("$id") == organization_id + ] + return accessible return ( [{"$id": organization_id, "error": result.error}] if organization_id else [] ) diff --git a/src/mcp_server_appwrite/http_app.py b/src/mcp_server_appwrite/http_app.py index 0c40453..9cc9bb9 100644 --- a/src/mcp_server_appwrite/http_app.py +++ b/src/mcp_server_appwrite/http_app.py @@ -62,6 +62,15 @@ def _log(message: str) -> None: print(f"[appwrite-mcp][http] {message}", file=sys.stderr, flush=True) +def _is_valid_scope_token(scope: str) -> bool: + """RFC 6749 §3.3 scope-token grammar: 1*( %x21 / %x23-5B / %x5D-7E ) — + printable ASCII minus space, double quote, and backslash.""" + return bool(scope) and all( + char == "\x21" or "\x23" <= char <= "\x5b" or "\x5d" <= char <= "\x7e" + for char in scope + ) + + async def _send_401(send: Send) -> None: """RFC 9728 §5.1 — 401 with a WWW-Authenticate pointing to resource metadata.""" metadata_url = resource_metadata_url() @@ -70,6 +79,24 @@ async def _send_401(send: Send) -> None: 'error_description="Authentication required"', f'resource_metadata="{metadata_url}"', ] + # MCP spec 2025-11-25 (SEP-835): clients treat the `scope` parameter as + # authoritative for what to request. Sourced from the same discovery-backed + # metadata as /.well-known; omitted entirely if discovery is unavailable — + # an unauthenticated 401 must never turn into a 500. The values land inside + # a quoted-string header, so anything outside the RFC 6749 §3.3 scope-token + # grammar (quotes, backslashes, control characters such as CRLF) is dropped + # rather than injected into the response head. + try: + scopes = (await protected_resource_metadata()).get("scopes_supported") or [] + scopes = [ + scope + for scope in scopes + if isinstance(scope, str) and _is_valid_scope_token(scope) + ] + if scopes: + parts.append(f'scope="{" ".join(scopes)}"') + except Exception: + pass www_authenticate = f"Bearer {', '.join(parts)}" body = json.dumps( diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index b69ba5c..26f6f1c 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -61,47 +61,32 @@ def test_advertised_scopes_use_cache_without_network(self): scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"] finally: auth._discovery_cache.pop(pid, None) - # None of the preferred scopes exist, so the full discovered list is - # mirrored (custom scope catalogs on self-hosted projects). + # No curated list configured, so the discovered catalog is mirrored. self.assertEqual(scopes, ["rows.read", "rows.write"]) - def test_advertised_scopes_prefer_curated_subset(self): - # The Cloud console authorization server advertises a very large - # fine-grained scope catalog; the MCP must advertise only the compact - # preferred set (clients request every advertised scope, and the - # authorize endpoint caps the scope parameter length). + def test_advertised_scopes_mirror_full_catalog_by_default(self): + # The MCP mirrors the authorization server's full scope catalog so + # clients request everything and consent-time narrowing is the control + # point (granular MCP scopes design). + catalog = [ + "openid", + "profile", + "email", + "phone", + "all", + "project:all", + "organization:all", + "project:users.read", + "project:users.write", + "organization:projects.read", + ] pid = auth.configured_project_id() - auth._store_discovery( - pid, - discovery_doc( - [ - "openid", - "profile", - "email", - "phone", - "all", - "project:all", - "organization:all", - "project:users.read", - "project:users.write", - "organization:projects.read", - ] - ), - ) - try: - scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"] - finally: - auth._discovery_cache.pop(pid, None) - self.assertEqual(scopes, ["openid", "profile", "email", "all"]) - - def test_advertised_scopes_drop_preferred_scopes_missing_from_discovery(self): - pid = auth.configured_project_id() - auth._store_discovery(pid, discovery_doc(["openid", "email", "all"])) + auth._store_discovery(pid, discovery_doc(catalog)) try: scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"] finally: auth._discovery_cache.pop(pid, None) - self.assertEqual(scopes, ["openid", "email", "all"]) + self.assertEqual(scopes, catalog) def test_advertised_scopes_env_override(self): pid = auth.configured_project_id() @@ -119,6 +104,46 @@ def test_advertised_scopes_env_override(self): auth._discovery_cache.pop(pid, None) self.assertEqual(scopes, ["openid", "project:all"]) + def test_advertised_scopes_env_override_drops_undiscovered_scopes(self): + # A curated scope missing from the live catalog is never advertised. + pid = auth.configured_project_id() + auth._store_discovery(pid, discovery_doc(["openid", "email", "all"])) + try: + with mock.patch.dict( + os.environ, {"MCP_OAUTH_SCOPES": "openid email all phone"} + ): + scopes = asyncio.run(auth.protected_resource_metadata())[ + "scopes_supported" + ] + finally: + auth._discovery_cache.pop(pid, None) + self.assertEqual(scopes, ["openid", "email", "all"]) + + def test_advertised_scopes_env_override_falls_back_to_full_catalog(self): + # When none of the curated scopes exist in the live catalog, the full + # discovered list is mirrored instead of advertising nothing. + pid = auth.configured_project_id() + auth._store_discovery(pid, discovery_doc(["rows.read", "rows.write"])) + try: + with mock.patch.dict(os.environ, {"MCP_OAUTH_SCOPES": "openid all"}): + scopes = asyncio.run(auth.protected_resource_metadata())[ + "scopes_supported" + ] + finally: + auth._discovery_cache.pop(pid, None) + self.assertEqual(scopes, ["rows.read", "rows.write"]) + + def test_advertised_scopes_raise_when_discovery_missing_scopes_supported(self): + pid = auth.configured_project_id() + doc = discovery_doc([]) + del doc["scopes_supported"] + auth._store_discovery(pid, doc) + try: + with self.assertRaises(ValueError): + asyncio.run(auth.protected_resource_metadata()) + finally: + auth._discovery_cache.pop(pid, None) + def test_discovery_cache_expires_after_ttl(self): pid = auth.configured_project_id() auth._store_discovery(pid, {"issuer": "x", "jwks_uri": "y"}) diff --git a/tests/unit/test_context.py b/tests/unit/test_context.py index ac65c81..7657263 100644 --- a/tests/unit/test_context.py +++ b/tests/unit/test_context.py @@ -1,6 +1,7 @@ import unittest from appwrite.client import Client +from appwrite.exception import AppwriteException from mcp_server_appwrite.context import get_appwrite_context from mcp_server_appwrite.server import _get_context_for_request @@ -116,6 +117,63 @@ def factory(project_id, organization_id): self.assertIn((None, "org-1"), seen) self.assertIn(("project-1", "org-1"), seen) + def test_oauth_context_falls_back_to_accessible_resources_when_narrowed(self): + # A consent-narrowed grant (no console-wide `all`) cannot use the + # console-tier /organizations and per-org project listings; discovery + # must fall back to the always-granted OAuth2 accessible-resources + # endpoints and still surface the bound organization and project. + def denied(_params): + raise AppwriteException("missing scopes", 401, "general_unauthorized_scope") + + console = FakeClient( + { + ("get", "/account"): denied, + ("get", "/organizations"): denied, + ("get", "/oauth2/console/organizations"): { + "total": 1, + "organizations": [{"$id": "org-1"}], + }, + ("get", "/oauth2/console/projects"): { + "total": 1, + "projects": [{"$id": "project-a"}], + }, + }, + project="console", + ) + org = FakeClient({("get", "/organization/projects"): denied}) + project = FakeClient( + { + ("get", "/project"): {"$id": "project-a", "name": "Project A"}, + ("get", "/tablesdb"): { + "total": 1, + "databases": [{"$id": "db-1", "name": "Main"}], + }, + ("get", "/storage/buckets"): denied, + }, + project="project-a", + ) + + def factory(project_id, organization_id): + if project_id: + return project + if organization_id: + return org + return console + + context = get_appwrite_context( + console, + mode="oauth_console", + client_factory=factory, + ) + + self.assertIn("error", context["account"]) + self.assertEqual(context["organizations"], [{"$id": "org-1"}]) + self.assertEqual(context["projects"][0]["$id"], "project-a") + self.assertEqual(context["projects"][0]["name"], "Project A") + self.assertEqual(context["projects"][0]["services"]["tablesdb"]["total"], 1) + # Ungranted probes surface their scope error instead of failing the call. + self.assertIn("error", context["projects"][0]["services"]["storage"]) + def test_compact_output_preserves_false_resource_state(self): client = FakeClient( { diff --git a/tests/unit/test_http_app.py b/tests/unit/test_http_app.py index 4904f6f..39d6408 100644 --- a/tests/unit/test_http_app.py +++ b/tests/unit/test_http_app.py @@ -1,7 +1,11 @@ +import asyncio import logging +import os import unittest +from unittest import mock -from mcp_server_appwrite.http_app import HealthzAccessLogFilter +from mcp_server_appwrite import auth +from mcp_server_appwrite.http_app import HealthzAccessLogFilter, _send_401 class HealthzAccessLogFilterTests(unittest.TestCase): @@ -37,5 +41,114 @@ def test_keeps_non_healthz_access_logs(self): self.assertTrue(self.filter.filter(record)) +class Send401Tests(unittest.TestCase): + ENV = { + "APPWRITE_ENDPOINT": "https://cloud.appwrite.io/v1", + "MCP_PUBLIC_URL": "https://mcp.appwrite.io", + "APPWRITE_PROJECT_ID": "console", + } + + def setUp(self): + patcher = mock.patch.dict(os.environ, self.ENV, clear=False) + patcher.start() + self.addCleanup(patcher.stop) + + def _challenge(self) -> str: + messages = [] + + async def send(message): + messages.append(message) + + asyncio.run(_send_401(send)) + start = messages[0] + self.assertEqual(start["status"], 401) + headers = dict(start["headers"]) + return headers[b"www-authenticate"].decode() + + def test_401_includes_scope_hint_from_discovery(self): + # SEP-835: the challenge's `scope` parameter mirrors the advertised + # catalog so clients know exactly what to request. + pid = "console" + auth._store_discovery( + pid, + { + "issuer": "https://cloud.appwrite.io/v1/oauth2/console", + "jwks_uri": "https://cloud.appwrite.io/v1/oauth2/console/jwks", + "scopes_supported": ["openid", "all", "project:users.read"], + }, + ) + try: + challenge = self._challenge() + finally: + auth._discovery_cache.pop(pid, None) + self.assertIn('resource_metadata="', challenge) + self.assertIn('scope="openid all project:users.read"', challenge) + + def test_401_scope_hint_drops_tokens_outside_rfc6749_grammar(self): + # The hint lands inside a quoted-string header value; a malicious or + # misconfigured authorization server must not be able to inject headers + # (CRLF) or break out of the quoted string (double quote, backslash). + pid = "console" + auth._store_discovery( + pid, + { + "issuer": "https://cloud.appwrite.io/v1/oauth2/console", + "jwks_uri": "https://cloud.appwrite.io/v1/oauth2/console/jwks", + "scopes_supported": [ + "openid", + 'evil"', + "back\\slash", + "crlf\r\nSet-Cookie: pwned=1", + "tab\tseparated", + "spa ce", + "", + 42, + "project:users.read", + ], + }, + ) + try: + challenge = self._challenge() + finally: + auth._discovery_cache.pop(pid, None) + self.assertIn('scope="openid project:users.read"', challenge) + self.assertNotIn("Set-Cookie", challenge) + self.assertNotIn("\r", challenge) + self.assertNotIn("\n", challenge) + self.assertNotIn("evil", challenge) + self.assertNotIn("back", challenge) + + def test_401_omits_scope_hint_when_no_valid_scope_tokens_remain(self): + pid = "console" + auth._store_discovery( + pid, + { + "issuer": "https://cloud.appwrite.io/v1/oauth2/console", + "jwks_uri": "https://cloud.appwrite.io/v1/oauth2/console/jwks", + "scopes_supported": ['bad"scope', "crlf\r\n"], + }, + ) + try: + challenge = self._challenge() + finally: + auth._discovery_cache.pop(pid, None) + self.assertIn('resource_metadata="', challenge) + self.assertNotIn("scope=", challenge) + + def test_401_omits_scope_hint_when_discovery_unavailable(self): + # An unauthenticated 401 must never fail because discovery is down. + with mock.patch.dict( + os.environ, + { + "APPWRITE_ENDPOINT": "http://127.0.0.1:1/v1", + "APPWRITE_PROJECT_ID": "unreachableproj", + }, + ): + challenge = self._challenge() + self.assertIn('error="invalid_token"', challenge) + self.assertIn('resource_metadata="', challenge) + self.assertNotIn("scope=", challenge) + + if __name__ == "__main__": unittest.main()