Skip to content

Commit 802706f

Browse files
committed
docs+tests: cover RFC 7591 dynamic client registration
The cloud OAuth server now advertises an open registration_endpoint (RFC 7591), so MCP clients self-register without pre-shared credentials. This server is the OAuth 2.1 Resource Server and needs no runtime change; document the now-live self-service registration step and lock the discovery contract it depends on. - README: explicit self-service DCR step (public/PKCE, no client_id/secret to provision), project-setup note, and regional APPWRITE_ENDPOINT caveat (token iss is validated against it). - unit: supported_scopes works against DCR-enabled discovery (no network). - integration: assert the AS advertises registration_endpoint == issuer/register and exposes scopes_supported; skips when the endpoint predates DCR.
1 parent 43ce3f0 commit 802706f

3 files changed

Lines changed: 127 additions & 2 deletions

File tree

README.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,16 @@ The MCP server validates the bearer access token on every request and forwards i
4646

4747
1. The client requests `/<project_id>/mcp` without a token and receives `401` with a `WWW-Authenticate` header pointing to the protected-resource metadata.
4848
2. The client fetches `GET /.well-known/oauth-protected-resource/<project_id>/mcp` (RFC 9728), which lists the project's authorization server (`<APPWRITE_ENDPOINT>/oauth2/<project_id>`) and supported scopes.
49-
3. The client discovers the authorization server (RFC 8414 / OIDC), registers (RFC 7591), and runs the OAuth 2.1 + PKCE authorization-code flow, including the RFC 8707 `resource` parameter that binds the token's audience to this server.
50-
4. The client calls `/<project_id>/mcp` with `Authorization: Bearer <token>`.
49+
3. The client discovers the authorization server (RFC 8414 / OIDC) and **self-registers** via RFC 7591 Dynamic Client Registration — the project's OAuth server exposes an open `registration_endpoint`, so there is no client ID or secret to pre-provision. MCP clients register as public (PKCE) clients automatically.
50+
4. The client runs the OAuth 2.1 + PKCE authorization-code flow, including the RFC 8707 `resource` parameter that binds the token's audience to this server.
51+
5. The client calls `/<project_id>/mcp` with `Authorization: Bearer <token>`.
5152

5253
## Project setup
5354

5455
Each project must enable its OAuth server (`oAuth2Server.enabled = true`) and include the scopes the MCP advertises in `oAuth2Server.scopes`. The advertised set covers read+write for users, sessions, teams, databases (tables/columns/indexes/rows), storage (buckets/files), functions (executions), messaging (providers/topics/subscribers/targets/messages), and sites, plus `locale.read` and `avatars.read`. Clients request only the subset they need; the consent screen and the API's per-route scope checks enforce what is actually granted.
5556

57+
No OAuth client needs to be created by hand: enabling the OAuth server also exposes the RFC 7591 `registration_endpoint`, and MCP clients self-register against it on first connect. Each registered client becomes an `apps` document in the project.
58+
5659
## Tool surface
5760

5861
The server starts in a compact workflow so the MCP client only sees a small operator-style surface while the full Appwrite catalog stays internal.
@@ -69,6 +72,8 @@ The server starts in a compact workflow so the MCP client only sees a small oper
6972

7073
The server is a standard ASGI app. Configure it via environment variables (see [`.env.example`](.env.example)) — chiefly `APPWRITE_ENDPOINT` (the Appwrite Cloud base) and `MCP_PUBLIC_URL` (the external URL clients use to reach this server, used to build canonical resource URIs and metadata). `GET /healthz` is a liveness probe.
7174

75+
> **Regional endpoints:** `APPWRITE_ENDPOINT` must be the host that actually **issues** tokens. The server validates each token's `iss` claim against this endpoint, so if your project lives in a region whose discovery reports a regional issuer (e.g. `https://fra.cloud.appwrite.io/v1`), set `APPWRITE_ENDPOINT` to that regional host — otherwise valid tokens are rejected.
76+
7277
### Docker (recommended)
7378

7479
```bash
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Live contract test for the OAuth authorization-server discovery the MCP relies on.
2+
3+
The hosted MCP server is an OAuth 2.1 Resource Server: it points clients at the
4+
project's Appwrite authorization server, and MCP-aware clients then self-register
5+
via RFC 7591 Dynamic Client Registration. That registration step only works if the
6+
authorization server advertises ``registration_endpoint`` in its discovery document
7+
(added by the cloud ``feat/oauth2-dynamic-client-registration`` work).
8+
9+
This test locks that contract end-to-end against the configured Appwrite endpoint:
10+
the same discovery document the MCP fetches for ``scopes_supported`` must advertise
11+
the registration endpoint the README promises. It is skipped without live config.
12+
13+
Note: it asserts against the document's own ``issuer`` (not the request host) so it
14+
is correct even when the endpoint regionally redirects (e.g. ``fra.cloud.appwrite.io``).
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import os
20+
import unittest
21+
22+
import httpx
23+
24+
from mcp_server_appwrite import auth
25+
26+
from .support import requires_live_integration
27+
28+
29+
@requires_live_integration
30+
class OAuthDiscoveryContractTests(unittest.TestCase):
31+
def _discovery(self) -> dict:
32+
project_id = os.environ["APPWRITE_PROJECT_ID"]
33+
url = f"{auth.issuer_url(project_id)}/.well-known/openid-configuration"
34+
resp = httpx.get(url, timeout=10.0, follow_redirects=True)
35+
resp.raise_for_status()
36+
return resp.json()
37+
38+
def test_discovery_advertises_dynamic_client_registration(self):
39+
doc = self._discovery()
40+
41+
# The fields the OAuth 2.1 + DCR client flow depends on.
42+
issuer = doc.get("issuer")
43+
self.assertTrue(issuer, "discovery document missing issuer")
44+
self.assertTrue(doc.get("token_endpoint"), "discovery missing token_endpoint")
45+
46+
# RFC 7591: registration must be advertised for clients to self-register.
47+
# Skip (rather than fail) when the endpoint predates the dynamic client
48+
# registration rollout, so this stays green against not-yet-upgraded
49+
# deployments and starts verifying the moment the endpoint exposes it.
50+
if "registration_endpoint" not in doc:
51+
self.skipTest(
52+
"authorization server does not advertise registration_endpoint yet "
53+
"(RFC 7591 dynamic client registration not deployed for this endpoint)"
54+
)
55+
self.assertEqual(doc["registration_endpoint"], f"{issuer}/register")
56+
57+
def test_discovery_exposes_supported_scopes(self):
58+
# The MCP sources its protected-resource metadata scopes from here.
59+
doc = self._discovery()
60+
self.assertIsInstance(
61+
doc.get("scopes_supported"),
62+
list,
63+
"discovery missing scopes_supported (MCP cannot advertise scopes)",
64+
)
65+
66+
67+
if __name__ == "__main__":
68+
unittest.main()

tests/unit/test_auth.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,58 @@ def test_supported_scopes_uses_cache_without_network(self):
4747
auth._scopes_cache.pop("cachedproj", None)
4848
self.assertEqual(scopes, ["rows.read", "rows.write"])
4949

50+
def test_supported_scopes_reads_dcr_enabled_discovery(self):
51+
# The authorization server's discovery document now also advertises
52+
# `registration_endpoint` (RFC 7591). Sourcing scopes must keep working
53+
# against that document — the MCP points clients at this same AS, and
54+
# the clients self-register there. This guards the discovery contract
55+
# without a network round-trip.
56+
discovery = {
57+
"issuer": "https://cloud.appwrite.io/v1/oauth2/proj1",
58+
"registration_endpoint": "https://cloud.appwrite.io/v1/oauth2/proj1/register",
59+
"scopes_supported": ["users.read", "rows.write"],
60+
}
61+
seen_urls: list[str] = []
62+
63+
class _FakeResponse:
64+
def raise_for_status(self):
65+
return None
66+
67+
def json(self):
68+
return discovery
69+
70+
class _FakeAsyncClient:
71+
def __init__(self, *args, **kwargs):
72+
pass
73+
74+
async def __aenter__(self):
75+
return self
76+
77+
async def __aexit__(self, *args):
78+
return False
79+
80+
async def get(self, url):
81+
seen_urls.append(url)
82+
return _FakeResponse()
83+
84+
with mock.patch.object(auth.httpx, "AsyncClient", _FakeAsyncClient):
85+
try:
86+
scopes = asyncio.run(auth.supported_scopes("dcrproj"))
87+
finally:
88+
auth._scopes_cache.pop("dcrproj", None)
89+
90+
self.assertEqual(scopes, ["users.read", "rows.write"])
91+
# Discovery is read from the OIDC well-known path under the project issuer.
92+
self.assertEqual(
93+
seen_urls,
94+
["https://cloud.appwrite.io/v1/oauth2/dcrproj/.well-known/openid-configuration"],
95+
)
96+
# The registration endpoint the MCP points clients to follows `issuer/register`.
97+
self.assertEqual(
98+
discovery["registration_endpoint"],
99+
f"{discovery['issuer']}/register",
100+
)
101+
50102
def test_supported_scopes_raises_when_discovery_unreachable(self):
51103
# Point discovery at an unroutable address so the fetch fails fast.
52104
with mock.patch.dict(

0 commit comments

Comments
 (0)