Skip to content

Commit f9f4989

Browse files
authored
Merge pull request #57 from appwrite/feat/advertise-full-scope-catalog
Advertise the authorization server's full scope catalog + 401 scope hint
2 parents b20f9c5 + 3856ab0 commit f9f4989

11 files changed

Lines changed: 504 additions & 48 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ wheels/
1212
# VSCode
1313
.vscode
1414

15-
.env
15+
.env
16+
# Playwright MCP artifacts
17+
.playwright-mcp/
18+
.DS_Store

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ through your browser. The first time you connect, your client opens an Appwrite
1111
consent screen; approve the scopes and you're connected. There are no keys to
1212
copy.
1313

14+
![How the Appwrite MCP server handles OAuth and scopes](docs/appwrite-mcp-flow.svg)
15+
1416
## Connect your client
1517

1618
Pick your client below. Each adds the hosted Appwrite Cloud server.

docs/appwrite-mcp-flow.png

505 KB
Loading

docs/appwrite-mcp-flow.svg

Lines changed: 167 additions & 0 deletions
Loading

src/mcp_server_appwrite/auth.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,21 @@ def authorization_server_metadata_sync() -> dict:
158158

159159

160160
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."""
161+
"""The scope set to advertise. By default (no curated list) this mirrors the
162+
authorization server's full live ``scopes_supported`` catalog — clients then
163+
request everything and consent-time narrowing is the control point. When a
164+
curated list is configured (``MCP_OAUTH_SCOPES``), it is intersected with
165+
the discovered catalog so a renamed/removed scope is never advertised,
166+
falling back to the full discovered list when the intersection is empty."""
166167
discovered = metadata.get("scopes_supported")
167168
if not isinstance(discovered, list):
168169
raise ValueError(
169170
f"authorization server discovery missing scopes_supported: {discovery_url()}"
170171
)
171-
scopes = [scope for scope in preferred_scopes() if scope in discovered]
172+
preferred = preferred_scopes()
173+
if not preferred:
174+
return discovered
175+
scopes = [scope for scope in preferred if scope in discovered]
172176
if scopes:
173177
return scopes
174178
_log(

src/mcp_server_appwrite/constants.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,11 @@
5252

5353
DEFAULT_PROJECT_ID = "console"
5454

55-
PREFERRED_SCOPES = [
56-
"openid",
57-
"profile",
58-
"email",
59-
"all",
60-
]
55+
# Curated scope allowlist. Empty means "no curation": the MCP mirrors the
56+
# authorization server's full ``scopes_supported`` catalog so clients request
57+
# every granular scope and the consent screen becomes the narrowing control
58+
# point. A deployment can still pin a curated set via ``MCP_OAUTH_SCOPES``.
59+
PREFERRED_SCOPES: list[str] = []
6160

6261
# Shared TTL for cached upstream lookups (OAuth discovery, project regions).
6362
CACHE_TTL_SECONDS = 300.0

src/mcp_server_appwrite/context.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,30 @@ def get_appwrite_context(
9292
)
9393
)
9494

95+
if not projects:
96+
# Per-organization project listings need organization-tier scopes; a
97+
# grant narrowed to project scopes only still enumerates its bound
98+
# projects through the accessible-resources endpoint.
99+
for accessible in _accessible_resource_ids(console_client, "projects"):
100+
accessible_id = accessible.get("$id")
101+
if not isinstance(accessible_id, str) or not accessible_id:
102+
continue
103+
if project_id and accessible_id != project_id:
104+
continue
105+
project_client = client_factory(accessible_id, organization_id)
106+
project = _get_current_project(project_client, accessible_id) or {
107+
"$id": accessible_id
108+
}
109+
projects.append(
110+
_project_context(
111+
project,
112+
project_client,
113+
organization_id=organization_id,
114+
include_services=include_services,
115+
sample_limit=sample_limit,
116+
)
117+
)
118+
95119
if project_id and not projects:
96120
project_client = client_factory(project_id, organization_id)
97121
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
153177
return {"$id": project_id, "error": result.error} if not result.ok else None
154178

155179

180+
def _accessible_resource_ids(client: Client, resource: str) -> list[dict[str, Any]]:
181+
"""Fallback discovery via the OAuth2 accessible-resources endpoints
182+
(``/oauth2/<project>/organizations`` / ``/oauth2/<project>/projects``).
183+
Their scopes are granted on every token, so a consent-narrowed grant (no
184+
console-wide ``all``) can still enumerate exactly the resources it is
185+
bound to. Returns id-only documents."""
186+
project_id = client.get_config("project") or ""
187+
if not project_id:
188+
return []
189+
result = _safe_call(client, "get", f"/oauth2/{project_id}/{resource}")
190+
if not result.ok or not isinstance(result.value, dict):
191+
return []
192+
items = result.value.get(resource)
193+
if not isinstance(items, list):
194+
return []
195+
return [
196+
{"$id": item["$id"]}
197+
for item in items
198+
if isinstance(item, dict) and isinstance(item.get("$id"), str)
199+
]
200+
201+
156202
def _list_organizations(
157203
client: Client, organization_id: str | None
158204
) -> list[dict[str, Any]]:
159205
result = _safe_call(client, "get", "/organizations")
160206
if not result.ok or not isinstance(result.value, dict):
207+
# The console-wide organizations listing needs scopes only the `all`
208+
# grant carries; a narrowed grant falls back to the accessible-
209+
# resources enumeration (ids only, names resolved by later calls).
210+
accessible = _accessible_resource_ids(client, "organizations")
211+
if accessible:
212+
if organization_id:
213+
return [
214+
organization
215+
for organization in accessible
216+
if organization.get("$id") == organization_id
217+
]
218+
return accessible
161219
return (
162220
[{"$id": organization_id, "error": result.error}] if organization_id else []
163221
)

src/mcp_server_appwrite/http_app.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ def _log(message: str) -> None:
6262
print(f"[appwrite-mcp][http] {message}", file=sys.stderr, flush=True)
6363

6464

65+
def _is_valid_scope_token(scope: str) -> bool:
66+
"""RFC 6749 §3.3 scope-token grammar: 1*( %x21 / %x23-5B / %x5D-7E ) —
67+
printable ASCII minus space, double quote, and backslash."""
68+
return bool(scope) and all(
69+
char == "\x21" or "\x23" <= char <= "\x5b" or "\x5d" <= char <= "\x7e"
70+
for char in scope
71+
)
72+
73+
6574
async def _send_401(send: Send) -> None:
6675
"""RFC 9728 §5.1 — 401 with a WWW-Authenticate pointing to resource metadata."""
6776
metadata_url = resource_metadata_url()
@@ -70,6 +79,24 @@ async def _send_401(send: Send) -> None:
7079
'error_description="Authentication required"',
7180
f'resource_metadata="{metadata_url}"',
7281
]
82+
# MCP spec 2025-11-25 (SEP-835): clients treat the `scope` parameter as
83+
# authoritative for what to request. Sourced from the same discovery-backed
84+
# metadata as /.well-known; omitted entirely if discovery is unavailable —
85+
# an unauthenticated 401 must never turn into a 500. The values land inside
86+
# a quoted-string header, so anything outside the RFC 6749 §3.3 scope-token
87+
# grammar (quotes, backslashes, control characters such as CRLF) is dropped
88+
# rather than injected into the response head.
89+
try:
90+
scopes = (await protected_resource_metadata()).get("scopes_supported") or []
91+
scopes = [
92+
scope
93+
for scope in scopes
94+
if isinstance(scope, str) and _is_valid_scope_token(scope)
95+
]
96+
if scopes:
97+
parts.append(f'scope="{" ".join(scopes)}"')
98+
except Exception:
99+
pass
73100
www_authenticate = f"Bearer {', '.join(parts)}"
74101

75102
body = json.dumps(

tests/unit/test_auth.py

Lines changed: 59 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -61,47 +61,32 @@ def test_advertised_scopes_use_cache_without_network(self):
6161
scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"]
6262
finally:
6363
auth._discovery_cache.pop(pid, None)
64-
# None of the preferred scopes exist, so the full discovered list is
65-
# mirrored (custom scope catalogs on self-hosted projects).
64+
# No curated list configured, so the discovered catalog is mirrored.
6665
self.assertEqual(scopes, ["rows.read", "rows.write"])
6766

68-
def test_advertised_scopes_prefer_curated_subset(self):
69-
# The Cloud console authorization server advertises a very large
70-
# fine-grained scope catalog; the MCP must advertise only the compact
71-
# preferred set (clients request every advertised scope, and the
72-
# authorize endpoint caps the scope parameter length).
67+
def test_advertised_scopes_mirror_full_catalog_by_default(self):
68+
# The MCP mirrors the authorization server's full scope catalog so
69+
# clients request everything and consent-time narrowing is the control
70+
# point (granular MCP scopes design).
71+
catalog = [
72+
"openid",
73+
"profile",
74+
"email",
75+
"phone",
76+
"all",
77+
"project:all",
78+
"organization:all",
79+
"project:users.read",
80+
"project:users.write",
81+
"organization:projects.read",
82+
]
7383
pid = auth.configured_project_id()
74-
auth._store_discovery(
75-
pid,
76-
discovery_doc(
77-
[
78-
"openid",
79-
"profile",
80-
"email",
81-
"phone",
82-
"all",
83-
"project:all",
84-
"organization:all",
85-
"project:users.read",
86-
"project:users.write",
87-
"organization:projects.read",
88-
]
89-
),
90-
)
91-
try:
92-
scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"]
93-
finally:
94-
auth._discovery_cache.pop(pid, None)
95-
self.assertEqual(scopes, ["openid", "profile", "email", "all"])
96-
97-
def test_advertised_scopes_drop_preferred_scopes_missing_from_discovery(self):
98-
pid = auth.configured_project_id()
99-
auth._store_discovery(pid, discovery_doc(["openid", "email", "all"]))
84+
auth._store_discovery(pid, discovery_doc(catalog))
10085
try:
10186
scopes = asyncio.run(auth.protected_resource_metadata())["scopes_supported"]
10287
finally:
10388
auth._discovery_cache.pop(pid, None)
104-
self.assertEqual(scopes, ["openid", "email", "all"])
89+
self.assertEqual(scopes, catalog)
10590

10691
def test_advertised_scopes_env_override(self):
10792
pid = auth.configured_project_id()
@@ -119,6 +104,46 @@ def test_advertised_scopes_env_override(self):
119104
auth._discovery_cache.pop(pid, None)
120105
self.assertEqual(scopes, ["openid", "project:all"])
121106

107+
def test_advertised_scopes_env_override_drops_undiscovered_scopes(self):
108+
# A curated scope missing from the live catalog is never advertised.
109+
pid = auth.configured_project_id()
110+
auth._store_discovery(pid, discovery_doc(["openid", "email", "all"]))
111+
try:
112+
with mock.patch.dict(
113+
os.environ, {"MCP_OAUTH_SCOPES": "openid email all phone"}
114+
):
115+
scopes = asyncio.run(auth.protected_resource_metadata())[
116+
"scopes_supported"
117+
]
118+
finally:
119+
auth._discovery_cache.pop(pid, None)
120+
self.assertEqual(scopes, ["openid", "email", "all"])
121+
122+
def test_advertised_scopes_env_override_falls_back_to_full_catalog(self):
123+
# When none of the curated scopes exist in the live catalog, the full
124+
# discovered list is mirrored instead of advertising nothing.
125+
pid = auth.configured_project_id()
126+
auth._store_discovery(pid, discovery_doc(["rows.read", "rows.write"]))
127+
try:
128+
with mock.patch.dict(os.environ, {"MCP_OAUTH_SCOPES": "openid all"}):
129+
scopes = asyncio.run(auth.protected_resource_metadata())[
130+
"scopes_supported"
131+
]
132+
finally:
133+
auth._discovery_cache.pop(pid, None)
134+
self.assertEqual(scopes, ["rows.read", "rows.write"])
135+
136+
def test_advertised_scopes_raise_when_discovery_missing_scopes_supported(self):
137+
pid = auth.configured_project_id()
138+
doc = discovery_doc([])
139+
del doc["scopes_supported"]
140+
auth._store_discovery(pid, doc)
141+
try:
142+
with self.assertRaises(ValueError):
143+
asyncio.run(auth.protected_resource_metadata())
144+
finally:
145+
auth._discovery_cache.pop(pid, None)
146+
122147
def test_discovery_cache_expires_after_ttl(self):
123148
pid = auth.configured_project_id()
124149
auth._store_discovery(pid, {"issuer": "x", "jwks_uri": "y"})

tests/unit/test_context.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import unittest
22

33
from appwrite.client import Client
4+
from appwrite.exception import AppwriteException
45

56
from mcp_server_appwrite.context import get_appwrite_context
67
from mcp_server_appwrite.server import _get_context_for_request
@@ -116,6 +117,63 @@ def factory(project_id, organization_id):
116117
self.assertIn((None, "org-1"), seen)
117118
self.assertIn(("project-1", "org-1"), seen)
118119

120+
def test_oauth_context_falls_back_to_accessible_resources_when_narrowed(self):
121+
# A consent-narrowed grant (no console-wide `all`) cannot use the
122+
# console-tier /organizations and per-org project listings; discovery
123+
# must fall back to the always-granted OAuth2 accessible-resources
124+
# endpoints and still surface the bound organization and project.
125+
def denied(_params):
126+
raise AppwriteException("missing scopes", 401, "general_unauthorized_scope")
127+
128+
console = FakeClient(
129+
{
130+
("get", "/account"): denied,
131+
("get", "/organizations"): denied,
132+
("get", "/oauth2/console/organizations"): {
133+
"total": 1,
134+
"organizations": [{"$id": "org-1"}],
135+
},
136+
("get", "/oauth2/console/projects"): {
137+
"total": 1,
138+
"projects": [{"$id": "project-a"}],
139+
},
140+
},
141+
project="console",
142+
)
143+
org = FakeClient({("get", "/organization/projects"): denied})
144+
project = FakeClient(
145+
{
146+
("get", "/project"): {"$id": "project-a", "name": "Project A"},
147+
("get", "/tablesdb"): {
148+
"total": 1,
149+
"databases": [{"$id": "db-1", "name": "Main"}],
150+
},
151+
("get", "/storage/buckets"): denied,
152+
},
153+
project="project-a",
154+
)
155+
156+
def factory(project_id, organization_id):
157+
if project_id:
158+
return project
159+
if organization_id:
160+
return org
161+
return console
162+
163+
context = get_appwrite_context(
164+
console,
165+
mode="oauth_console",
166+
client_factory=factory,
167+
)
168+
169+
self.assertIn("error", context["account"])
170+
self.assertEqual(context["organizations"], [{"$id": "org-1"}])
171+
self.assertEqual(context["projects"][0]["$id"], "project-a")
172+
self.assertEqual(context["projects"][0]["name"], "Project A")
173+
self.assertEqual(context["projects"][0]["services"]["tablesdb"]["total"], 1)
174+
# Ungranted probes surface their scope error instead of failing the call.
175+
self.assertIn("error", context["projects"][0]["services"]["storage"])
176+
119177
def test_compact_output_preserves_false_resource_state(self):
120178
client = FakeClient(
121179
{

0 commit comments

Comments
 (0)